v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Python
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
backend/.venv/
|
||||
backend/.pytest_cache/
|
||||
backend/*.db
|
||||
backend/*.db-wal
|
||||
backend/*.db-shm
|
||||
backend/openapi.json
|
||||
|
||||
# Node / сборка фронта
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# DEV-вход (по нику) физически НЕ попадает в прод-образ
|
||||
backend/app/auth/dev_stub.py
|
||||
backend/app/routers/dev_auth.py
|
||||
|
||||
# Тесты и dev-манифест зависимостей в прод-образе не нужны (ставим из requirements.txt)
|
||||
backend/tests/
|
||||
backend/pyproject.toml
|
||||
|
||||
# Прочее
|
||||
.env
|
||||
data/
|
||||
**/.DS_Store
|
||||
*.md
|
||||
.claude/
|
||||
@@ -0,0 +1,55 @@
|
||||
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
||||
# ║ Forbidden Stars — единый .env (dev и prod) ║
|
||||
# ║ Скопируйте в `.env`, заполните секреты. Реальный `.env` в git НЕ идёт. ║
|
||||
# ║ Переключение dev/prod — одной строкой APP_ENV (ниже). ║
|
||||
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
||||
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
||||
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
|
||||
# test — прод-клон в Docker локально (порт 8080), вход только TG
|
||||
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
||||
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
||||
APP_ENV=development
|
||||
|
||||
# ─── АДМИНИСТРАТОР (вход по логину/паролю, отдельно от Telegram) ──────────────
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-admin-password
|
||||
ADMIN_BOOTSTRAP_ENABLED=true
|
||||
|
||||
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
|
||||
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
|
||||
# Telegram. Отдельного переключателя нет. Для Telegram нужны токен и юзернейм бота
|
||||
# (@BotFather) и /setdomain на ваш HTTPS-домен.
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_BOT_USERNAME=
|
||||
PUBLIC_BASE_URL=
|
||||
|
||||
# ─── БЕЗОПАСНОСТЬ / СЕССИИ ────────────────────────────────────────────────────
|
||||
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
||||
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
||||
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
||||
SECRET_KEY=change-me-dev-secret-not-for-production
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_USER_TTL_MINUTES=10080
|
||||
JWT_ADMIN_TTL_MINUTES=480
|
||||
# dev/test (локально, по HTTP) → false; production за HTTPS на Pi → true.
|
||||
COOKIE_SECURE=false
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ────────────
|
||||
# dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL
|
||||
# (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml).
|
||||
DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db
|
||||
PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db
|
||||
|
||||
# Каталог загрузок (зарезервировано под фото/видео), тоже раздельно.
|
||||
DEV_UPLOAD_DIR=./data/dev/uploads
|
||||
PROD_UPLOAD_DIR=/data/uploads
|
||||
|
||||
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
||||
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
||||
APP_TZ_OFFSET_HOURS=3
|
||||
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
LOG_LEVEL=INFO
|
||||
@@ -0,0 +1,20 @@
|
||||
# ── Нормализация переводов строк ──────────────────────────────────────────────
|
||||
# Шелл-скрипты обязаны быть с LF: в Linux-контейнере и на Pi CRLF ломает shebang.
|
||||
# entrypoint.sh контейнер чинит сам (sed в Dockerfile), но backup.sh запускается
|
||||
# с хоста Pi — для него LF в репозитории критичен.
|
||||
*.sh text eol=lf
|
||||
backend/entrypoint.sh text eol=lf
|
||||
scripts/backup.sh text eol=lf
|
||||
|
||||
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прод/тест) ─────
|
||||
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
||||
# но в архив деплоя (scripts/export-*.sh) не идут. На Docker-сборку НЕ влияет —
|
||||
# там чистоту образа обеспечивает .dockerignore.
|
||||
backend/tests/ export-ignore
|
||||
backend/app/auth/dev_stub.py export-ignore
|
||||
backend/app/routers/dev_auth.py export-ignore
|
||||
backend/pyproject.toml export-ignore
|
||||
README.md export-ignore
|
||||
.gitignore export-ignore
|
||||
.gitattributes export-ignore
|
||||
.dockerignore export-ignore
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Базы данных / данные (dev-БД лежит в backend/data/dev/)
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
data/
|
||||
backend/dev.db*
|
||||
|
||||
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod)
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
# Node / фронт
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Сгенерированный снапшот OpenAPI (контракт фронта закоммичен в schema.d.ts)
|
||||
backend/openapi.json
|
||||
|
||||
# Бэкапы (создаёт scripts/backup.sh на Pi)
|
||||
backups/
|
||||
|
||||
# Редактор / ОС
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# ─── Этап 1: сборка SPA ───────────────────────────────────────────────────────
|
||||
FROM node:20-bookworm-slim AS frontend
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Сначала зависимости (кеш слоёв). Используем lock-файл, если он есть.
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
COPY frontend/ ./
|
||||
# Типы schema.d.ts уже в репозитории — бэкенд для сборки не нужен.
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# ─── Этап 2: рантайм FastAPI (ARM64-совместимый glibc-образ) ──────────────────
|
||||
FROM python:3.12-slim-bookworm AS runtime
|
||||
# Контейнер — всегда production. БД/загрузки выбираются по APP_ENV (см. config.py):
|
||||
# prod → /data на томе. SPA отдаётся из /app/static.
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
APP_ENV=production \
|
||||
STATIC_DIR=/app/static
|
||||
|
||||
# tini — корректная обработка сигналов (чек-пойнт WAL при остановке).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/ /app/
|
||||
COPY --from=frontend /app/frontend/dist /app/static
|
||||
|
||||
# Непривилегированный пользователь + том данных + исполняемый entrypoint (LF).
|
||||
RUN sed -i 's/\r$//' /app/entrypoint.sh \
|
||||
&& chmod +x /app/entrypoint.sh \
|
||||
&& useradd --create-home --uid 10001 appuser \
|
||||
&& mkdir -p /data/uploads \
|
||||
&& chown -R appuser:appuser /app /data
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]
|
||||
@@ -20,7 +20,23 @@ docker-compose.yml
|
||||
|
||||
## Локальная разработка
|
||||
|
||||
Бэкенд и фронт запускаются раздельно; Vite проксирует `/api` на бэкенд.
|
||||
### Единый лаунчер (`run.ps1` / `run.sh`)
|
||||
|
||||
После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно,
|
||||
решает `APP_ENV` в корневом `.env`:
|
||||
|
||||
```powershell
|
||||
.\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh)
|
||||
```
|
||||
|
||||
| `APP_ENV` в `.env` | что делает лаунчер |
|
||||
|---|---|
|
||||
| `development` | `uvicorn --reload` (бэк) + `vite` (фронт) нативно, в двух окнах |
|
||||
| `test` | `docker compose` прод-клон на :8080 (со сборкой образа) |
|
||||
| `production` | не запускает — прод деплоится отдельно (см. «Git и деплой») |
|
||||
|
||||
Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта
|
||||
(после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд.
|
||||
|
||||
### 1) Бэкенд
|
||||
|
||||
@@ -91,32 +107,37 @@ FastAPI отдаёт собранный SPA и API с одного origin. Ми
|
||||
только через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
|
||||
Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000).
|
||||
|
||||
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
|
||||
Вручную (тот же эффект):
|
||||
```bash
|
||||
cp .env.test.example .env.test # заполнить при необходимости
|
||||
docker compose --env-file .env.test -f docker-compose.test.yml up -d --build
|
||||
docker compose -f docker-compose.test.yml up -d --build
|
||||
# открыть http://localhost:8080 (Swagger: /api/docs)
|
||||
docker compose -f docker-compose.test.yml down -v # остановить и стереть тестовые данные
|
||||
```
|
||||
|
||||
- Окружение принудительно `production` (клон Pi), но `COOKIE_SECURE=false` (локально HTTP).
|
||||
- Окружение `test` (прод-клон), но `COOKIE_SECURE=false` (локально по HTTP).
|
||||
- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри
|
||||
контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`).
|
||||
- Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi).
|
||||
- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; вход **игроков** —
|
||||
только через Telegram (нужен бот + публичный HTTPS/туннель на `localhost:8080`).
|
||||
- Если пароль/секрет содержит `$`, для docker compose экранируйте его как `$$`
|
||||
(для dev-uvicorn экранирование не нужно — pydantic читает `$` дословно).
|
||||
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
||||
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
|
||||
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
||||
`python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`).
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Методы входа зависят от окружения (`APP_ENV`):
|
||||
|
||||
| | dev | prod |
|
||||
| | dev | test / prod |
|
||||
|---|---|---|
|
||||
| Telegram Login Widget | ✓ | ✓ (единственный) |
|
||||
| Вход по нику (stub) | ✓ | ✗ (физически отсутствует) |
|
||||
|
||||
- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
|
||||
файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
|
||||
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV != production`
|
||||
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
|
||||
(`app/main.py`), а на фронте dev-блок вырезается из прод-сборки (`import.meta.env.DEV`).
|
||||
- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`).
|
||||
`GET /api/auth/config` отдаёт доступные методы и `telegram_bot_username` для виджета.
|
||||
@@ -127,29 +148,41 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
||||
3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`).
|
||||
4. Домену нужен HTTPS (например, Cloudflare Tunnel) — виджет не работает по голому HTTP.
|
||||
|
||||
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен в обоих окружениях.
|
||||
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях.
|
||||
|
||||
## Окружения (dev / test / prod)
|
||||
|
||||
Один и тот же код; контур выбирается тем, **чем и с каким `.env` запускаешь**:
|
||||
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер):
|
||||
|
||||
| | dev | test (прод-клон локально) | prod (Pi) |
|
||||
|---|---|---|---|
|
||||
| Запуск | `uvicorn --reload` + `vite` | `docker compose -f docker-compose.test.yml` | `docker compose` |
|
||||
| Env-файл | `.env` (корень) | `.env.test` (корень) | `.env` (корень, на Pi) |
|
||||
| `APP_ENV` | `development` | `production` (форсится) | `production` (форсится) |
|
||||
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` |
|
||||
| `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) |
|
||||
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
|
||||
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
|
||||
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
|
||||
| Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram |
|
||||
|
||||
- **Структура БД одна** (общие миграции Alembic), **файлы разные** — путь выбирается по
|
||||
`APP_ENV` (`DEV_DATABASE_URL` / `PROD_DATABASE_URL`). Данные дева — в `backend/data/dev/`
|
||||
(в образ **не попадают**: `data/` в `.dockerignore`).
|
||||
- **В Docker идёт только прод-код:** контейнер принудительно `APP_ENV=production`, dev-вход
|
||||
(stub) физически исключён из образа. `test` — это тот же прод-образ, просто локально.
|
||||
- **`.env` один на машину**, в корне (рядом с `.env.example`). `test` использует свой
|
||||
`.env.test` (чтобы крутить прод-клон рядом с dev, не мешая ему). Реальные `.env`/`.env.test`
|
||||
хранятся только локально; рядом лежат шаблоны `*.example`.
|
||||
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
|
||||
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
|
||||
`production`. Отдельного `.env.test` больше нет.
|
||||
- **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL`
|
||||
(`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это
|
||||
РАЗНЫЕ тома). Данные дева в образ **не попадают** (`data/` в `.dockerignore`).
|
||||
- **В Docker идёт только прод-код:** dev-вход (stub) и тесты физически исключены из образа
|
||||
(`.dockerignore`); `test` — тот же образ, что и прод, просто локально и с `APP_ENV=test`.
|
||||
|
||||
## Git и деплой
|
||||
|
||||
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь.
|
||||
- Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во
|
||||
всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный;
|
||||
чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов.
|
||||
- **Деплой на Pi:** `git pull` ветки `main` → `docker compose up -d --build`.
|
||||
- **Чистая выгрузка в папку без git** (опц.): `scripts/export-prod.sh <dir> main` (через
|
||||
`git archive` + `export-ignore` — в папку идёт ровно прод-набор, без лаунчера/тестов/dev-входа).
|
||||
|
||||
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
||||
|
||||
## Дополнения и фракции
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Конфигурация Alembic. URL берётся из настроек приложения (env.py), здесь — заглушка.
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = sqlite:///./dev.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Окружение Alembic. URL берётся из настроек, metadata — из SQLModel."""
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Регистрируем все модели в SQLModel.metadata.
|
||||
import app.models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=settings.database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
render_as_batch=True, # обязательно для SQLite (ALTER через copy-and-recreate)
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Начальная схема (все таблицы v1).
|
||||
|
||||
Создаём схему из метаданных SQLModel — гарантирует точное соответствие моделям
|
||||
для greenfield-проекта. Последующие изменения схемы — обычными op.* миграциями.
|
||||
|
||||
Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2026-06-15
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from alembic import op
|
||||
|
||||
# Импорт моделей наполняет SQLModel.metadata.
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision: str = "0001_initial"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
SQLModel.metadata.create_all(bind)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
SQLModel.metadata.drop_all(bind)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Сидинг справочников: дополнения и фракции (идемпотентно).
|
||||
|
||||
Revision ID: 0002_seed_reference
|
||||
Revises: 0001_initial
|
||||
Create Date: 2026-06-15
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from alembic import op
|
||||
from app.seed.reference_data import seed_reference_data
|
||||
|
||||
revision: str = "0002_seed_reference"
|
||||
down_revision: Union[str, None] = "0001_initial"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
with Session(bind=bind) as session:
|
||||
seed_reference_data(session)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
op.execute("DELETE FROM factions")
|
||||
op.execute("DELETE FROM expansions")
|
||||
_ = bind
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Жизненный цикл партии: статус, тайминг, длительность, причина победы; place → nullable.
|
||||
|
||||
Идемпотентна: на свежей БД (0001 создаёт схему из актуальных моделей через create_all)
|
||||
столбцы уже есть → no-op; на существующей БД (ревизия 0002, старая схема) — добавляет
|
||||
столбцы и делает place nullable. render_as_batch включён в env.py (нужен для SQLite).
|
||||
|
||||
Revision ID: 0003_match_lifecycle
|
||||
Revises: 0002_seed_reference
|
||||
Create Date: 2026-06-15
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003_match_lifecycle"
|
||||
down_revision: Union[str, None] = "0002_seed_reference"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
|
||||
match_cols = {c["name"] for c in insp.get_columns("matches")}
|
||||
add = []
|
||||
if "status" not in match_cols:
|
||||
add.append(sa.Column("status", sa.String(16), nullable=False, server_default="finished"))
|
||||
if "started_at" not in match_cols:
|
||||
add.append(sa.Column("started_at", sa.DateTime(), nullable=True))
|
||||
if "finished_at" not in match_cols:
|
||||
add.append(sa.Column("finished_at", sa.DateTime(), nullable=True))
|
||||
if "duration_minutes" not in match_cols:
|
||||
add.append(sa.Column("duration_minutes", sa.Integer(), nullable=True))
|
||||
if "win_reason" not in match_cols:
|
||||
add.append(sa.Column("win_reason", sa.String(16), nullable=True))
|
||||
|
||||
if add:
|
||||
with op.batch_alter_table("matches") as b:
|
||||
for col in add:
|
||||
b.add_column(col)
|
||||
# Бэкфилл для существующих (уже завершённых) партий.
|
||||
op.execute("UPDATE matches SET status = 'finished' WHERE status IS NULL")
|
||||
op.execute("UPDATE matches SET started_at = created_at WHERE started_at IS NULL")
|
||||
op.execute("UPDATE matches SET finished_at = created_at WHERE finished_at IS NULL")
|
||||
|
||||
# place → nullable, если ещё NOT NULL.
|
||||
place_col = next(c for c in insp.get_columns("match_participants") if c["name"] == "place")
|
||||
if not place_col["nullable"]:
|
||||
with op.batch_alter_table("match_participants") as b:
|
||||
b.alter_column("place", existing_type=sa.Integer(), nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("matches") as b:
|
||||
for name in ("win_reason", "duration_minutes", "finished_at", "started_at", "status"):
|
||||
try:
|
||||
b.drop_column(name)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "[entrypoint] Применение миграций…"
|
||||
alembic upgrade head
|
||||
|
||||
echo "[entrypoint] Бутстрап справочников и администратора…"
|
||||
python -m app.bootstrap
|
||||
|
||||
echo "[entrypoint] Запуск сервера…"
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1
|
||||
@@ -0,0 +1,32 @@
|
||||
[project]
|
||||
name = "forbidden-stars-backend"
|
||||
version = "0.1.0"
|
||||
description = "Forbidden Stars — учёт партий: ядро + REST API (FastAPI)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.111",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"sqlmodel>=0.0.21",
|
||||
"alembic>=1.13",
|
||||
"pydantic-settings>=2.3",
|
||||
"PyJWT>=2.8",
|
||||
"bcrypt>=4.1",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.2",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
@@ -0,0 +1,9 @@
|
||||
# Рантайм-зависимости (используются в Docker-сборке для кеширования слоёв)
|
||||
fastapi>=0.111
|
||||
uvicorn[standard]>=0.30
|
||||
sqlmodel>=0.0.21
|
||||
alembic>=1.13
|
||||
pydantic-settings>=2.3
|
||||
PyJWT>=2.8
|
||||
bcrypt>=4.1
|
||||
python-multipart>=0.0.9
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Фикстуры тестов: изолированная in-memory БД + TestClient + помощники."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# Тесты НИКОГДА не работают с БД дева/прода. Форсируем тестовое окружение и
|
||||
# in-memory БД для ГЛОБАЛЬНОГО движка ещё ДО импорта приложения — даже случайное
|
||||
# обращение к app.db.session.engine не затронет файлы дева/прода, какой бы
|
||||
# APP_ENV ни был унаследован из окружения.
|
||||
os.environ["APP_ENV"] = "development"
|
||||
os.environ["DEV_DATABASE_URL"] = "sqlite://"
|
||||
os.environ["FS_STARTUP_BOOTSTRAP"] = "0" # тесты сами поднимают схему/данные
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
from sqlmodel import Session, SQLModel, create_engine # noqa: E402
|
||||
|
||||
import app.models # noqa: F401,E402 (регистрация моделей)
|
||||
from app.core.security import hash_password # noqa: E402
|
||||
from app.db.session import get_session # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.models import User # noqa: E402
|
||||
from app.seed.reference_data import seed_reference_data # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine():
|
||||
eng = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
with Session(eng) as s:
|
||||
seed_reference_data(s)
|
||||
yield eng
|
||||
# In-memory БД исчезает с закрытием соединения StaticPool; drop_all не нужен
|
||||
# (и невозможен из-за циклической FK users↔groups в SQLite).
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(engine):
|
||||
def _get_session():
|
||||
with Session(engine) as s:
|
||||
yield s
|
||||
|
||||
app.dependency_overrides[get_session] = _get_session
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_admin(engine):
|
||||
def _make(username: str = "admin", password: str = "secret123") -> int:
|
||||
with Session(engine) as s:
|
||||
admin = User(
|
||||
nickname=username,
|
||||
role="admin",
|
||||
auth_provider="local",
|
||||
password_hash=hash_password(password),
|
||||
)
|
||||
s.add(admin)
|
||||
s.commit()
|
||||
s.refresh(admin)
|
||||
return admin.id
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
# ─── Помощники ────────────────────────────────────────────────────────────────
|
||||
|
||||
def csrf_headers(client: TestClient) -> dict:
|
||||
token = client.cookies.get("csrf_token")
|
||||
return {"X-CSRF-Token": token} if token else {}
|
||||
|
||||
|
||||
def login(client: TestClient, nickname: str) -> dict:
|
||||
# csrf_headers пуст при первом входе и содержит токен при смене пользователя
|
||||
# (когда уже есть сессионная cookie и middleware требует заголовок).
|
||||
r = client.post("/api/auth/dev/login", json={"nickname": nickname}, headers=csrf_headers(client))
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def start_match(client: TestClient, group_id: int, roster: list[dict]):
|
||||
"""roster: [{user_id, faction_id, was_random?}] → ответ старта (in_progress)."""
|
||||
return client.post(
|
||||
"/api/matches",
|
||||
json={"group_id": group_id, "participants": roster},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
|
||||
|
||||
def finish_match(
|
||||
client: TestClient,
|
||||
match_id: int,
|
||||
results: list[dict],
|
||||
win_reason: str = "objectives",
|
||||
overall_comment: str | None = None,
|
||||
):
|
||||
"""results: [{user_id, place, comment?, faction_id?}]."""
|
||||
body: dict = {"participants": results, "win_reason": win_reason}
|
||||
if overall_comment is not None:
|
||||
body["overall_comment"] = overall_comment
|
||||
return client.post(
|
||||
f"/api/matches/{match_id}/finish", json=body, headers=csrf_headers(client)
|
||||
)
|
||||
|
||||
|
||||
def create_finished_match(
|
||||
client: TestClient, group_id: int, players: list[dict], win_reason: str = "objectives"
|
||||
) -> dict:
|
||||
"""players: [{user_id, faction_id, place, comment?}] → завершённая партия (для статистики)."""
|
||||
roster = [{"user_id": p["user_id"], "faction_id": p["faction_id"]} for p in players]
|
||||
started = start_match(client, group_id, roster)
|
||||
assert started.status_code == 200, started.text
|
||||
results = [
|
||||
{"user_id": p["user_id"], "place": p["place"], "comment": p.get("comment")}
|
||||
for p in players
|
||||
]
|
||||
r = finish_match(client, started.json()["id"], results, win_reason)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Тесты: лимиты (группа/партия), правка партии админом, переименование фракции, фильтры."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import create_finished_match, csrf_headers, login, start_match
|
||||
|
||||
|
||||
def _exp_id(client: TestClient, code: str) -> int:
|
||||
return next(e["id"] for e in client.get("/api/expansions").json() if e["code"] == code)
|
||||
|
||||
|
||||
def _admin_login(client: TestClient, make_admin) -> None:
|
||||
make_admin("admin", "secret123")
|
||||
r = client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"username": "admin", "password": "secret123"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_group_size_limit(client: TestClient):
|
||||
login(client, "Owner")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Большая", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
# owner = 1; добавляем до 10, 10-й участник сверх лимита → 409.
|
||||
for i in range(1, 11):
|
||||
client.post("/api/auth/dev/users", json={"nickname": f"U{i}"}, headers=csrf_headers(client))
|
||||
statuses = []
|
||||
for i in range(1, 11):
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": f"U{i}"}, headers=csrf_headers(client)
|
||||
)
|
||||
statuses.append(r.status_code)
|
||||
# 9 успешных (итого 10 с владельцем), 10-й — отказ.
|
||||
assert statuses[:9] == [200] * 9
|
||||
assert statuses[9] == 409
|
||||
|
||||
|
||||
def test_match_player_limit(client: TestClient):
|
||||
me = login(client, "Owner")
|
||||
fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": [fw, fv]}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
ids = [me["id"]]
|
||||
for i in range(1, 7):
|
||||
client.post("/api/auth/dev/users", json={"nickname": f"P{i}"}, headers=csrf_headers(client))
|
||||
ids.append(
|
||||
client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": f"P{i}"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
)
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
fids = [f["id"] for f in factions]
|
||||
# 7 игроков на старте → 422 (максимум 6).
|
||||
roster = [{"user_id": uid, "faction_id": fids[i]} for i, uid in enumerate(ids)]
|
||||
r = start_match(client, gid, roster)
|
||||
assert r.status_code == 422
|
||||
assert "6" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_admin_edit_match_and_filters(client: TestClient, make_admin):
|
||||
me = login(client, "Аня")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||
mid = create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
|
||||
{"user_id": b, "faction_id": factions["eldar"], "place": 2},
|
||||
],
|
||||
)["id"]
|
||||
|
||||
_admin_login(client, make_admin)
|
||||
|
||||
# Фильтры: по игроку и по фракции находят партию.
|
||||
assert any(m["id"] == mid for m in client.get(f"/api/admin/matches?user_id={b}").json())
|
||||
assert any(
|
||||
m["id"] == mid for m in client.get(f"/api/admin/matches?faction_id={factions['orks']}").json()
|
||||
)
|
||||
|
||||
# Правка: меняем фракцию Бори eldar → chaos.
|
||||
r = client.patch(
|
||||
f"/api/admin/matches/{mid}",
|
||||
json={
|
||||
"participants": [
|
||||
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
|
||||
{"user_id": b, "faction_id": factions["chaos"], "place": 2},
|
||||
]
|
||||
},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
detail = client.get(f"/api/admin/matches/{mid}").json()
|
||||
bp = next(p for p in detail["participants"] if p["user_id"] == b)
|
||||
assert bp["faction_name"] == "Хаоситы"
|
||||
|
||||
|
||||
def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
|
||||
me = login(client, "Кто-то")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Друг"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Друг"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||
mid = create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
|
||||
{"user_id": b, "faction_id": factions["eldar"], "place": 2},
|
||||
],
|
||||
)["id"]
|
||||
|
||||
_admin_login(client, make_admin)
|
||||
r = client.patch(
|
||||
f"/api/admin/factions/{factions['orks']}",
|
||||
json={"name_ru": "Орки WAAAGH"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Изменение отражается в существующей партии (имя хранится один раз).
|
||||
detail = client.get(f"/api/admin/matches/{mid}").json()
|
||||
ap = next(p for p in detail["participants"] if p["user_id"] == me["id"])
|
||||
assert ap["faction_name"] == "Орки WAAAGH"
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Аутентификация: методы по окружению, dev-вход, Telegram (проверка подписи)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import csrf_headers
|
||||
|
||||
|
||||
def test_auth_config_dev_has_both_methods(client: TestClient):
|
||||
cfg = client.get("/api/auth/config").json()
|
||||
assert "telegram" in cfg["methods"]
|
||||
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
||||
|
||||
|
||||
def test_enabled_methods_by_env(monkeypatch):
|
||||
from app.auth.registry import enabled_methods
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "app_env", "development")
|
||||
assert set(enabled_methods()) == {"telegram", "stub"}
|
||||
monkeypatch.setattr(settings, "app_env", "test")
|
||||
assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
assert enabled_methods() == ["telegram"] # prod → только Telegram
|
||||
|
||||
|
||||
def test_env_flags_and_db_path(monkeypatch):
|
||||
"""dev → файл дева; test и prod → том /data (общая ветвь is_development)."""
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db")
|
||||
monkeypatch.setattr(settings, "prod_database_url", "sqlite:////data/prod.db")
|
||||
|
||||
monkeypatch.setattr(settings, "app_env", "development")
|
||||
assert settings.is_development and settings.database_url == "sqlite:///dev.db"
|
||||
monkeypatch.setattr(settings, "app_env", "test")
|
||||
assert settings.is_test and settings.database_url == "sqlite:////data/prod.db"
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
|
||||
|
||||
|
||||
def test_dev_login_works(client: TestClient):
|
||||
r = client.post("/api/auth/dev/login", json={"nickname": "Тестер"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["nickname"] == "Тестер"
|
||||
|
||||
|
||||
def _telegram_payload(token: str, **fields) -> dict:
|
||||
data = {"id": 777, "first_name": "Иван", "username": "ivan_tg", "auth_date": int(time.time())}
|
||||
data.update(fields)
|
||||
secret = hashlib.sha256(token.encode()).digest()
|
||||
check = "\n".join(f"{k}={data[k]}" for k in sorted(data))
|
||||
sig = hmac.new(secret, check.encode(), hashlib.sha256).hexdigest()
|
||||
return {**data, "hash": sig}
|
||||
|
||||
|
||||
def test_telegram_login_valid_signature(client: TestClient, monkeypatch):
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||
payload = _telegram_payload("TEST_BOT_TOKEN")
|
||||
r = client.post("/api/auth/telegram", json=payload)
|
||||
assert r.status_code == 200, r.text
|
||||
me = r.json()
|
||||
assert me["nickname"] # пользователь создан
|
||||
assert me["telegram_id"] == 777
|
||||
|
||||
|
||||
def test_telegram_login_bad_signature_rejected(client: TestClient, monkeypatch):
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||
payload = _telegram_payload("TEST_BOT_TOKEN")
|
||||
payload["hash"] = "deadbeef" # подделка
|
||||
r = client.post("/api/auth/telegram", json=payload)
|
||||
assert r.status_code == 401
|
||||
_ = csrf_headers # (для единообразия импорта)
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Сквозной тест ядра: вход → группа → участники → партия → статистика → админ."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import csrf_headers, finish_match, login, start_match
|
||||
|
||||
|
||||
def _expansion_id(client: TestClient, code: str) -> int:
|
||||
r = client.get("/api/expansions")
|
||||
assert r.status_code == 200, r.text
|
||||
return next(e["id"] for e in r.json() if e["code"] == code)
|
||||
|
||||
|
||||
def test_full_flow(client: TestClient, make_admin):
|
||||
# 1) Вход игрока A — групп ещё нет.
|
||||
me = login(client, "Иван")
|
||||
assert me["groups"] == []
|
||||
assert me["active_group_id"] is None
|
||||
|
||||
# 2) Без группы создать партию нельзя (NO_GROUP).
|
||||
r = client.post(
|
||||
"/api/matches",
|
||||
json={"group_id": 1, "played_at": "2026-06-15", "participants": []},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert r.json()["error"]["code"] == "NO_GROUP"
|
||||
|
||||
# 3) Создать группу с дополнением Forgotten Worlds.
|
||||
fw = _expansion_id(client, "forgotten_worlds")
|
||||
r = client.post(
|
||||
"/api/groups",
|
||||
json={"name": "Вечер 40k", "expansion_ids": [fw]},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
group = r.json()
|
||||
gid = group["id"]
|
||||
assert group["my_role"] == "owner"
|
||||
assert fw in group["expansion_ids"]
|
||||
|
||||
# Активная группа выставилась.
|
||||
me = client.get("/api/users/me").json()
|
||||
assert me["active_group_id"] == gid
|
||||
|
||||
# 4) Создать игрока B и добавить в группу по нику.
|
||||
rb = client.post(
|
||||
"/api/auth/dev/users", json={"nickname": "Олег"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert rb.status_code == 200, rb.text
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/members",
|
||||
json={"nickname": "Олег"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
b_id = r.json()["user_id"]
|
||||
|
||||
# 5) Доступные фракции = база (4) + Forgotten Worlds (4) = 8.
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
assert len(factions) == 8
|
||||
codes = {f["code"] for f in factions}
|
||||
assert "orks" in codes and "tau" in codes
|
||||
assert "inquisition" not in codes # Forsaken Voids не подключён
|
||||
|
||||
# 6) Рандом фракции — из доступного набора.
|
||||
r = client.post(
|
||||
"/api/matches/randomize-faction",
|
||||
json={"group_id": gid, "exclude_faction_ids": []},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["faction"]["id"] in {f["id"] for f in factions}
|
||||
|
||||
# 7) Двухэтапная партия: старт (A=Орки рандом, B=Тау), затем завершение (места).
|
||||
orks = next(f["id"] for f in factions if f["code"] == "orks")
|
||||
tau = next(f["id"] for f in factions if f["code"] == "tau")
|
||||
a_id = me["id"]
|
||||
started = start_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": a_id, "faction_id": orks, "was_random": True},
|
||||
{"user_id": b_id, "faction_id": tau},
|
||||
],
|
||||
)
|
||||
assert started.status_code == 200, started.text
|
||||
sm = started.json()
|
||||
assert sm["status"] == "in_progress"
|
||||
assert all(p["place"] is None for p in sm["participants"])
|
||||
|
||||
# Незавершённая партия в статистику пока не идёт.
|
||||
assert client.get("/api/home").json()["profile"]["overall"]["games"] == 0
|
||||
|
||||
fin = finish_match(
|
||||
client,
|
||||
sm["id"],
|
||||
[{"user_id": a_id, "place": 1}, {"user_id": b_id, "place": 2}],
|
||||
win_reason="objectives",
|
||||
overall_comment="Хорошая партия",
|
||||
)
|
||||
assert fin.status_code == 200, fin.text
|
||||
match = fin.json()
|
||||
assert match["status"] == "finished"
|
||||
assert match["win_reason"] == "objectives"
|
||||
assert match["duration_minutes"] is not None and match["duration_minutes"] >= 30
|
||||
assert match["player_count"] == 2
|
||||
|
||||
# 8) Список партий группы и статистика.
|
||||
lst = client.get(f"/api/groups/{gid}/matches").json()
|
||||
assert lst["total"] == 1
|
||||
gstats = client.get(f"/api/groups/{gid}/stats").json()
|
||||
assert gstats["total_matches"] == 1
|
||||
# Игр меньше MIN_GAMES → попадают в provisional.
|
||||
prov_ids = {e["user_id"] for e in gstats["provisional"]}
|
||||
assert a_id in prov_ids and b_id in prov_ids
|
||||
|
||||
# 9) Главная: профиль и топ.
|
||||
home = client.get("/api/home").json()
|
||||
assert home["profile"]["overall"]["games"] == 1
|
||||
assert home["profile"]["overall"]["wins"] == 1
|
||||
assert home["active_group"]["id"] == gid
|
||||
|
||||
# 10) Дубликат фракции запрещён уже на старте.
|
||||
r = start_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": a_id, "faction_id": orks},
|
||||
{"user_id": b_id, "faction_id": orks},
|
||||
],
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["error"]["code"] == "DUPLICATE_PARTICIPANT"
|
||||
|
||||
# 11) Админ: вход и доступ; пользовательская сессия в админку не проходит.
|
||||
make_admin("admin", "secret123")
|
||||
# Пользователь не может в админку.
|
||||
assert client.get("/api/admin/users").status_code in (401, 403)
|
||||
# Вход админа.
|
||||
ra = client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"username": "admin", "password": "secret123"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert ra.status_code == 200, ra.text
|
||||
users = client.get("/api/admin/users").json()
|
||||
nicks = {u["nickname"] for u in users}
|
||||
assert {"Иван", "Олег"}.issubset(nicks)
|
||||
|
||||
|
||||
def test_in_progress_excluded_and_win_reason_required(client: TestClient):
|
||||
me = login(client, "Хост")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Гость2"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Гость2"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
f1, f2 = factions[0]["id"], factions[1]["id"]
|
||||
|
||||
started = start_match(client, gid, [
|
||||
{"user_id": me["id"], "faction_id": f1},
|
||||
{"user_id": b, "faction_id": f2},
|
||||
])
|
||||
assert started.status_code == 200, started.text
|
||||
mid = started.json()["id"]
|
||||
|
||||
# Незавершённая видна в списке группы (можно дозавершить), но не в топе.
|
||||
lst = client.get(f"/api/groups/{gid}/matches").json()
|
||||
assert lst["items"][0]["status"] == "in_progress"
|
||||
assert client.get("/api/stats/leaderboard").json()["entries"] == []
|
||||
assert client.get("/api/stats/leaderboard").json()["provisional"] == []
|
||||
|
||||
# Незавершённая видна на главной (с именем группы) для быстрого перехода.
|
||||
home = client.get("/api/home").json()
|
||||
assert any(x["id"] == mid for x in home["in_progress"])
|
||||
assert home["in_progress"][0]["group_name"] == "Группа"
|
||||
|
||||
# Завершение без причины победы → 422 (поле обязательно).
|
||||
no_reason = client.post(
|
||||
f"/api/matches/{mid}/finish",
|
||||
json={"participants": [
|
||||
{"user_id": me["id"], "place": 1},
|
||||
{"user_id": b, "place": 2},
|
||||
]},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert no_reason.status_code == 422
|
||||
|
||||
# С причиной победы → 200 и теперь учитывается в статистике.
|
||||
ok = finish_match(client, mid, [
|
||||
{"user_id": me["id"], "place": 1},
|
||||
{"user_id": b, "place": 2},
|
||||
], win_reason="worlds")
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert client.get("/api/users/me/stats").json()["overall"]["games"] == 1
|
||||
# После завершения партия пропадает из «незавершённых» на главной.
|
||||
assert all(x["id"] != mid for x in client.get("/api/home").json()["in_progress"])
|
||||
|
||||
|
||||
def test_owner_can_finish_member_started_match(client: TestClient):
|
||||
"""Владелец группы может завершить партию, начатую другим участником (can_modify с бэкенда)."""
|
||||
a = login(client, "Хозяин")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Согрупник"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Согрупник"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
f1, f2 = factions[0]["id"], factions[1]["id"]
|
||||
|
||||
# Участник (не владелец) входит и стартует партию.
|
||||
login(client, "Согрупник")
|
||||
started = start_match(client, gid, [
|
||||
{"user_id": a["id"], "faction_id": f1},
|
||||
{"user_id": b, "faction_id": f2},
|
||||
])
|
||||
assert started.status_code == 200, started.text
|
||||
mid = started.json()["id"]
|
||||
# Создатель видит can_modify=True.
|
||||
assert client.get(f"/api/matches/{mid}").json()["can_modify"] is True
|
||||
|
||||
# Владелец возвращается: тоже может завершить (форма должна показываться).
|
||||
login(client, "Хозяин")
|
||||
assert client.get(f"/api/matches/{mid}").json()["can_modify"] is True
|
||||
fin = finish_match(client, mid, [
|
||||
{"user_id": a["id"], "place": 1},
|
||||
{"user_id": b, "place": 2},
|
||||
], win_reason="objectives")
|
||||
assert fin.status_code == 200, fin.text
|
||||
|
||||
|
||||
def test_any_member_can_finish_and_manage(client: TestClient):
|
||||
"""Рядовой участник (не создатель, не владелец) может завершать партии и менять дополнения."""
|
||||
a = login(client, "Овнер")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Компания", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
for nick in ("Бэ", "Цэ"):
|
||||
client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client))
|
||||
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "Бэ"}, headers=csrf_headers(client)).json()["user_id"]
|
||||
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "Цэ"}, headers=csrf_headers(client)).json()["user_id"]
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
|
||||
# Владелец стартует партию A+B+C.
|
||||
started = start_match(client, gid, [
|
||||
{"user_id": a["id"], "faction_id": fids[0]},
|
||||
{"user_id": b, "faction_id": fids[1]},
|
||||
{"user_id": c, "faction_id": fids[2]},
|
||||
])
|
||||
mid = started.json()["id"]
|
||||
|
||||
# Рядовой участник C завершает партию.
|
||||
login(client, "Цэ")
|
||||
assert client.get(f"/api/matches/{mid}").json()["can_modify"] is True
|
||||
fin = finish_match(client, mid, [
|
||||
{"user_id": a["id"], "place": 1},
|
||||
{"user_id": b, "place": 2},
|
||||
{"user_id": c, "place": 3},
|
||||
], win_reason="plastic")
|
||||
assert fin.status_code == 200, fin.text
|
||||
|
||||
# И может менять дополнения группы.
|
||||
fw = _expansion_id(client, "forgotten_worlds")
|
||||
r = client.put(
|
||||
f"/api/groups/{gid}/expansions", json={"expansion_ids": [fw]}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_disabled_account_cannot_login(client: TestClient, make_admin):
|
||||
# Создаём игрока и узнаём его id.
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Гость"})
|
||||
# Вход админа.
|
||||
make_admin("admin", "secret123")
|
||||
ra = client.post(
|
||||
"/api/admin/auth/login", json={"username": "admin", "password": "secret123"}
|
||||
)
|
||||
assert ra.status_code == 200, ra.text
|
||||
users = client.get("/api/admin/users").json()
|
||||
guest = next(u for u in users if u["nickname"] == "Гость")
|
||||
|
||||
# Админ отключает аккаунт.
|
||||
rp = client.patch(
|
||||
f"/api/admin/users/{guest['id']}",
|
||||
json={"is_active": False},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert rp.status_code == 200, rp.text
|
||||
|
||||
# Вход под отключённым — запрещён с понятным кодом.
|
||||
rl = client.post("/api/auth/dev/login", json={"nickname": "Гость"})
|
||||
assert rl.status_code == 403
|
||||
assert rl.json()["error"]["code"] == "ACCOUNT_DISABLED"
|
||||
|
||||
# В dev-списке он помечен неактивным (для подсветки серым).
|
||||
dev_users = client.get("/api/auth/dev/users").json()
|
||||
guest_dev = next(u for u in dev_users if u["nickname"] == "Гость")
|
||||
assert guest_dev["is_active"] is False
|
||||
|
||||
|
||||
def test_tie_ranking_and_points(client: TestClient):
|
||||
"""Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется."""
|
||||
me = login(client, "A")
|
||||
r = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []},
|
||||
headers=csrf_headers(client))
|
||||
gid = r.json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "B"}, headers=csrf_headers(client))
|
||||
client.post("/api/auth/dev/users", json={"nickname": "C"}, headers=csrf_headers(client))
|
||||
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "B"},
|
||||
headers=csrf_headers(client)).json()["user_id"]
|
||||
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "C"},
|
||||
headers=csrf_headers(client)).json()["user_id"]
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
f1, f2, f3 = (factions[0]["id"], factions[1]["id"], factions[2]["id"])
|
||||
a = me["id"]
|
||||
|
||||
# Старт партии (мест ещё нет) — ранжирование проверяется на завершении.
|
||||
started = start_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": a, "faction_id": f1},
|
||||
{"user_id": b, "faction_id": f2},
|
||||
{"user_id": c, "faction_id": f3},
|
||||
],
|
||||
)
|
||||
assert started.status_code == 200, started.text
|
||||
mid = started.json()["id"]
|
||||
|
||||
# Некорректная расстановка 1,1,2 → отклоняется на финише.
|
||||
bad = finish_match(
|
||||
client,
|
||||
mid,
|
||||
[
|
||||
{"user_id": a, "place": 1},
|
||||
{"user_id": b, "place": 1},
|
||||
{"user_id": c, "place": 2},
|
||||
],
|
||||
)
|
||||
assert bad.status_code == 422
|
||||
assert bad.json()["error"]["code"] == "INVALID_RANKING"
|
||||
|
||||
# Корректная ничья за 2-е место: 1,2,2 → партия завершается.
|
||||
ok = finish_match(
|
||||
client,
|
||||
mid,
|
||||
[
|
||||
{"user_id": a, "place": 1},
|
||||
{"user_id": b, "place": 2},
|
||||
{"user_id": c, "place": 2},
|
||||
],
|
||||
)
|
||||
assert ok.status_code == 200, ok.text
|
||||
# Повторное завершение запрещено.
|
||||
again = finish_match(client, mid, [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}, {"user_id": c, "place": 2}])
|
||||
assert again.status_code == 409
|
||||
@@ -0,0 +1,41 @@
|
||||
# Локальный «клон прода» в контейнере — для проверки прод-сборки на своей машине
|
||||
# (а не на Raspberry Pi). Тот же образ, что и прод, но изолированные тома, единый .env
|
||||
# и порт 8080 (чтобы не конфликтовать с dev-uvicorn на :8000).
|
||||
#
|
||||
# Обычно запускается лаунчером при APP_ENV=test (run.ps1 / run.sh). Вручную:
|
||||
# docker compose -f docker-compose.test.yml up --build -d
|
||||
# Открыть: http://localhost:8080 (Swagger: /api/docs)
|
||||
# Остановить и стереть данные: docker compose -f docker-compose.test.yml down -v
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: forbidden-stars:test
|
||||
container_name: forbidden-stars-test
|
||||
restart: "no"
|
||||
init: true
|
||||
env_file:
|
||||
- .env # единый .env (тот же, что у dev/prod); секреты не в git
|
||||
environment:
|
||||
# Окружение test: прод-клон, но отличимый от прода (см. config.is_test).
|
||||
# Форсим здесь, чтобы значение не зависело от APP_ENV внутри .env.
|
||||
APP_ENV: test
|
||||
ports:
|
||||
- "8080:8000"
|
||||
volumes:
|
||||
- db-data-test:/data # изолированные тестовые данные
|
||||
- uploads-data-test:/data/uploads
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
mem_limit: 512m
|
||||
|
||||
volumes:
|
||||
db-data-test:
|
||||
uploads-data-test:
|
||||
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: forbidden-stars:latest
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
env_file:
|
||||
- .env # секреты/настройки (один общий формат для dev и prod)
|
||||
environment:
|
||||
# Прод обособлен: контейнер ВСЕГДА production и ИГНОРИРУЕТ APP_ENV из .env
|
||||
# (хоть development, хоть test). БД=/data, STATIC_DIR=/app/static — из Dockerfile.
|
||||
APP_ENV: production
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- db-data:/data # БД SQLite + WAL-сайдкары
|
||||
- uploads-data:/data/uploads # задел под фото/видео
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
mem_limit: 512m
|
||||
cpus: 1.5
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
uploads-data:
|
||||
@@ -0,0 +1,3 @@
|
||||
# Пути в OpenAPI уже содержат префикс /api, поэтому baseUrl пустой.
|
||||
# В dev Vite проксирует /api на бэкенд :8000; в prod — тот же origin.
|
||||
VITE_API_BASE_URL=
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#0f1115" />
|
||||
<title>Forbidden Stars — учёт партий</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2205
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "forbidden-stars-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"gen:api": "openapi-typescript http://localhost:8000/api/openapi.json -o src/api/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.408.0",
|
||||
"openapi-fetch": "^0.10.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-router-dom": "^6.25.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"openapi-typescript": "^7.3.0",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { router } from "./app/router";
|
||||
import { ToastProvider } from "./context/ToastContext";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "./schema";
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
// Пути в схеме уже содержат /api; baseUrl — origin (пусто в dev/prod).
|
||||
export const api = createClient<paths>({
|
||||
baseUrl: import.meta.env.VITE_API_BASE_URL || "",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
// CSRF double-submit: дублируем cookie csrf_token в заголовок на всех запросах.
|
||||
api.use({
|
||||
onRequest({ request }) {
|
||||
const csrf = readCookie("csrf_token");
|
||||
if (csrf) request.headers.set("X-CSRF-Token", csrf);
|
||||
return request;
|
||||
},
|
||||
});
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
details?: unknown;
|
||||
constructor(message: string, code: string, status: number, details?: unknown) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchResult<T> {
|
||||
data?: T;
|
||||
error?: unknown;
|
||||
response: Response;
|
||||
}
|
||||
|
||||
/** Разворачивает ответ openapi-fetch: возвращает данные или бросает ApiError. */
|
||||
export function unwrap<T>(res: FetchResult<T>): T {
|
||||
if (res.error || !res.response.ok) {
|
||||
const env = (res.error as { error?: { code?: string; message?: string; details?: unknown } })
|
||||
?.error;
|
||||
throw new ApiError(
|
||||
env?.message || "Ошибка запроса",
|
||||
env?.code || "ERROR",
|
||||
res.response.status,
|
||||
env?.details,
|
||||
);
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const qk = {
|
||||
me: ["me"] as const,
|
||||
adminMe: ["adminMe"] as const,
|
||||
home: ["home"] as const,
|
||||
leaderboard: ["leaderboard"] as const,
|
||||
expansions: ["expansions"] as const,
|
||||
factions: ["factions"] as const,
|
||||
myStats: ["myStats"] as const,
|
||||
groups: ["groups"] as const,
|
||||
group: (id: number) => ["group", id] as const,
|
||||
groupFactions: (id: number) => ["group", id, "factions"] as const,
|
||||
groupMembers: (id: number) => ["group", id, "members"] as const,
|
||||
groupMatches: (id: number) => ["group", id, "matches"] as const,
|
||||
groupStats: (id: number) => ["group", id, "stats"] as const,
|
||||
match: (id: number) => ["match", id] as const,
|
||||
authConfig: ["authConfig"] as const,
|
||||
devUsers: ["devUsers"] as const,
|
||||
adminUsers: ["adminUsers"] as const,
|
||||
adminGroups: ["adminGroups"] as const,
|
||||
adminMatches: ["adminMatches"] as const,
|
||||
adminLogs: ["adminLogs"] as const,
|
||||
};
|
||||
Vendored
+2842
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Navigate, createBrowserRouter } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { RequireAdmin, RequireAuth, RequireGroup } from "../auth/guards";
|
||||
import { AccountPage } from "../pages/AccountPage";
|
||||
import { CreateMatchPage } from "../pages/CreateMatchPage";
|
||||
import { GroupPage } from "../pages/GroupPage";
|
||||
import { GroupSettingsPage } from "../pages/GroupSettingsPage";
|
||||
import { HomePage } from "../pages/HomePage";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
import { MatchDetailPage } from "../pages/MatchDetailPage";
|
||||
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
|
||||
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
|
||||
import { AdminLayout } from "../pages/admin/AdminLayout";
|
||||
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
|
||||
import { AdminLoginPage } from "../pages/admin/AdminLoginPage";
|
||||
import { AdminRecordsPage } from "../pages/admin/AdminRecordsPage";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/admin/login", element: <AdminLoginPage /> },
|
||||
{
|
||||
path: "/",
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<AppShell />
|
||||
</RequireAuth>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <HomePage /> },
|
||||
{
|
||||
path: "match/new",
|
||||
element: (
|
||||
<RequireGroup>
|
||||
<CreateMatchPage />
|
||||
</RequireGroup>
|
||||
),
|
||||
},
|
||||
{ path: "match/:matchId", element: <MatchDetailPage /> },
|
||||
{ path: "group", element: <GroupPage /> },
|
||||
{
|
||||
path: "group/settings",
|
||||
element: (
|
||||
<RequireGroup>
|
||||
<GroupSettingsPage />
|
||||
</RequireGroup>
|
||||
),
|
||||
},
|
||||
{ path: "account", element: <AccountPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
element: (
|
||||
<RequireAdmin>
|
||||
<AdminLayout />
|
||||
</RequireAdmin>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/admin/records" replace /> },
|
||||
{ path: "records", element: <AdminRecordsPage /> },
|
||||
{ path: "accounts", element: <AdminAccountsPage /> },
|
||||
{ path: "factions", element: <AdminFactionsPage /> },
|
||||
{ path: "logs", element: <AdminLogsPage /> },
|
||||
],
|
||||
},
|
||||
{ path: "*", element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import { useAdminMe } from "../hooks/admin";
|
||||
|
||||
export function RequireAuth({ children }: PropsWithChildren) {
|
||||
const { data: me, isLoading } = useMe();
|
||||
const location = useLocation();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!me) return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function RequireGroup({ children }: PropsWithChildren) {
|
||||
const { data: me, isLoading } = useMe();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!me) return <Navigate to="/login" replace />;
|
||||
if (me.active_group_id == null) return <Navigate to="/" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function RequireAdmin({ children }: PropsWithChildren) {
|
||||
const { data: admin, isLoading } = useAdminMe();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!admin) return <Navigate to="/admin/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { BottomBar } from "./BottomBar";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
"/": "Forbidden Stars",
|
||||
"/group": "Группа",
|
||||
"/group/settings": "Настройки группы",
|
||||
"/account": "Аккаунт",
|
||||
"/match/new": "Новая партия",
|
||||
};
|
||||
|
||||
export function AppShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const title = TITLES[location.pathname] ?? "Forbidden Stars";
|
||||
const isHome = location.pathname === "/";
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="top-bar">
|
||||
{isHome ? (
|
||||
<span className="title">{title}</span>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
|
||||
← Назад
|
||||
</button>
|
||||
)}
|
||||
<span className="title small muted">{!isHome ? title : ""}</span>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<BottomBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Plus, Settings, Users } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useMe } from "../hooks/auth";
|
||||
|
||||
export function BottomBar() {
|
||||
const { data: me } = useMe();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const hasGroup = me?.active_group_id != null;
|
||||
const activeGroup = me?.groups.find((g) => g.id === me?.active_group_id);
|
||||
|
||||
const onCreate = () => {
|
||||
if (!hasGroup) {
|
||||
toast.show("Вы не состоите в группе. Создайте или вступите в группу.");
|
||||
navigate("/group");
|
||||
return;
|
||||
}
|
||||
navigate("/match/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bottom-bar">
|
||||
<button className="slot" onClick={() => navigate("/group")}>
|
||||
<Users size={20} />
|
||||
<span className="name">{activeGroup ? activeGroup.name : "Группа"}</span>
|
||||
</button>
|
||||
|
||||
<div className="spacer" />
|
||||
|
||||
<button className="slot" onClick={() => navigate("/account")}>
|
||||
<Settings size={20} />
|
||||
<span>Аккаунт</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="fab"
|
||||
aria-disabled={!hasGroup}
|
||||
aria-label="Создать партию"
|
||||
onClick={onCreate}
|
||||
>
|
||||
<Plus size={26} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useCreateGroup } from "../hooks/groups";
|
||||
import { useExpansions } from "../hooks/reference";
|
||||
|
||||
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
||||
const { data: expansions } = useExpansions();
|
||||
const createGroup = useCreateGroup();
|
||||
const toast = useToast();
|
||||
const [name, setName] = useState("");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const toggle = (id: number) =>
|
||||
setSelected((s) => {
|
||||
const next = new Set(s);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
|
||||
toast.show("Группа создана");
|
||||
onCreated?.();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Новая группа</h3>
|
||||
<div className="field">
|
||||
<label className="label">Название</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Например: Вечер 40k" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Дополнения группы</label>
|
||||
<div className="stack">
|
||||
{(expansions ?? []).map((e) => (
|
||||
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span>
|
||||
{e.name_ru}
|
||||
{e.is_base && <span className="muted small"> (всегда включено)</span>}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: "auto" }}
|
||||
disabled={e.is_base}
|
||||
checked={e.is_base || selected.has(e.id)}
|
||||
onChange={() => toggle(e.id)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
<button className="btn btn-primary" style={{ width: "100%" }} onClick={submit} disabled={createGroup.isPending}>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMe, useSetActiveGroup } from "../hooks/auth";
|
||||
|
||||
export function GroupSwitcher() {
|
||||
const { data: me } = useMe();
|
||||
const setActive = useSetActiveGroup();
|
||||
if (!me || me.groups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Мои группы</h3>
|
||||
<div className="stack">
|
||||
{me.groups.map((g) => {
|
||||
const active = g.id === me.active_group_id;
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
className={"btn" + (active ? " btn-primary" : "")}
|
||||
onClick={() => !active && setActive.mutate(g.id)}
|
||||
>
|
||||
{g.name} {active && "✓"} <span className="muted small">({g.role})</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { formatTime } from "../domain/format";
|
||||
import type { HomeInProgressMatch } from "../domain/types";
|
||||
|
||||
export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
||||
const navigate = useNavigate();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="card" style={{ borderColor: "var(--accent)" }}>
|
||||
<h3 style={{ color: "var(--accent-2)" }}>Незавершённые партии</h3>
|
||||
<div className="stack">
|
||||
{items.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className="row-between"
|
||||
onClick={() => navigate(`/match/${m.id}`)}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: "var(--surface-2)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: 12,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<b>{m.group_name}</b>
|
||||
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
|
||||
</div>
|
||||
<div className="muted small">
|
||||
{formatTime(m.started_at)} · {m.player_count} игр.
|
||||
</div>
|
||||
<div
|
||||
className="small"
|
||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{m.participants.map((p) => p.nickname).join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 4, color: "var(--accent-2)", flexShrink: 0 }}>
|
||||
Завершить <ChevronRight size={18} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { LeaderboardEntry } from "../domain/types";
|
||||
|
||||
function pct(v: number | null | undefined): string {
|
||||
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
||||
}
|
||||
|
||||
function Row({ entry }: { entry: LeaderboardEntry }) {
|
||||
return (
|
||||
<div className="lb-row">
|
||||
<div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}>
|
||||
{entry.rank ?? "—"}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{entry.nickname}</div>
|
||||
<div className="lb-metrics">
|
||||
<span>Игр: {entry.games}</span>
|
||||
<span>Побед: {entry.wins}</span>
|
||||
<span>WR: {pct(entry.win_rate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lb-score">{entry.score ?? "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Leaderboard({
|
||||
entries,
|
||||
provisional,
|
||||
}: {
|
||||
entries: LeaderboardEntry[];
|
||||
provisional?: LeaderboardEntry[];
|
||||
}) {
|
||||
if (entries.length === 0 && (!provisional || provisional.length === 0)) {
|
||||
return <div className="muted small">Пока нет сыгранных партий.</div>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{entries.map((e) => (
|
||||
<Row key={e.user_id} entry={e} />
|
||||
))}
|
||||
{provisional && provisional.length > 0 && (
|
||||
<>
|
||||
<div className="label" style={{ marginTop: 10 }}>
|
||||
Новички (мало игр)
|
||||
</div>
|
||||
{provisional.map((e) => (
|
||||
<Row key={e.user_id} entry={e} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { formatDuration } from "../domain/format";
|
||||
import { winReasonLabel } from "../domain/winReasons";
|
||||
import type { MatchListItem } from "../domain/types";
|
||||
|
||||
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
const navigate = useNavigate();
|
||||
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{items.map((m) => {
|
||||
const inProgress = m.status === "in_progress";
|
||||
const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
className="card"
|
||||
style={{ textAlign: "left", width: "100%", margin: 0 }}
|
||||
onClick={() => navigate(`/match/${m.id}`)}
|
||||
>
|
||||
<div className="row-between">
|
||||
<b>{m.played_at}</b>
|
||||
{inProgress ? (
|
||||
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
|
||||
) : (
|
||||
<span className="muted small">
|
||||
{formatDuration(m.duration_minutes)} · {winReasonLabel(m.win_reason)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
|
||||
{sorted.map((p) => (
|
||||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||||
<span className={"place-badge" + (p.place === 1 ? " first" : "")}>
|
||||
{p.place ?? "—"}
|
||||
</span>
|
||||
<span>{p.nickname}</span>
|
||||
<span className="muted small">· {p.faction_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ProfileStats } from "../domain/types";
|
||||
|
||||
function pct(v: number | null | undefined): string {
|
||||
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
||||
}
|
||||
|
||||
export function ProfileStatsCard({ stats }: { stats: ProfileStats }) {
|
||||
const o = stats.overall;
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Моя статистика</h3>
|
||||
<div className="row-between">
|
||||
<div>Игр</div>
|
||||
<b>{o.games}</b>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<div>Побед</div>
|
||||
<b>{o.wins}</b>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<div>Winrate</div>
|
||||
<b>{pct(o.win_rate)}</b>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<div>Среднее место</div>
|
||||
<b>{o.avg_place ?? "—"}</b>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<div>Очки (рейтинг)</div>
|
||||
<b className="lb-score">{o.score ?? "—"}</b>
|
||||
</div>
|
||||
{stats.most_played_faction && (
|
||||
<div className="row-between">
|
||||
<div>Любимая фракция</div>
|
||||
<b>{stats.most_played_faction.name_ru}</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function Spinner({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="center muted" style={{ padding: 40 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
border: "3px solid var(--border)",
|
||||
borderTopColor: "var(--accent)",
|
||||
borderRadius: "50%",
|
||||
margin: "0 auto 10px",
|
||||
animation: "fs-spin 0.8s linear infinite",
|
||||
}}
|
||||
/>
|
||||
{label ?? "Загрузка…"}
|
||||
<style>{`@keyframes fs-spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface TelegramUser {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
photo_url?: string;
|
||||
auth_date: number;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__fsTelegramAuth?: (user: TelegramUser) => void;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Официальный Telegram Login Widget. Требует бота (@BotFather) и /setdomain
|
||||
* на текущий HTTPS-домен. botUsername — без «@».
|
||||
*/
|
||||
export function TelegramLoginButton({
|
||||
botUsername,
|
||||
onAuth,
|
||||
}: {
|
||||
botUsername: string;
|
||||
onAuth: (user: TelegramUser) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
window.__fsTelegramAuth = (user) => onAuth(user);
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://telegram.org/js/telegram-widget.js?22";
|
||||
script.async = true;
|
||||
script.setAttribute("data-telegram-login", botUsername);
|
||||
script.setAttribute("data-size", "large");
|
||||
script.setAttribute("data-userpic", "false");
|
||||
script.setAttribute("data-request-access", "write");
|
||||
script.setAttribute("data-onauth", "__fsTelegramAuth(user)");
|
||||
|
||||
const node = ref.current;
|
||||
node?.appendChild(script);
|
||||
return () => {
|
||||
if (node) node.innerHTML = "";
|
||||
delete window.__fsTelegramAuth;
|
||||
};
|
||||
}, [botUsername, onAuth]);
|
||||
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
interface ToastCtx {
|
||||
show: (message: string) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<ToastCtx>({ show: () => {} });
|
||||
|
||||
export function ToastProvider({ children }: PropsWithChildren) {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const show = useCallback((msg: string) => {
|
||||
setMessage(msg);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setMessage(null), 2800);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ show }}>
|
||||
{children}
|
||||
{message && <div className="toast">{message}</div>}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastCtx {
|
||||
return useContext(Ctx);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Лимиты, зеркалят значения бэкенда (авторитетная проверка — на сервере).
|
||||
export const MAX_MATCH_PLAYERS = 6;
|
||||
export const MAX_GROUP_SIZE = 10;
|
||||
@@ -0,0 +1,39 @@
|
||||
// Отображаем время в фиксированном поясе +3 (МСК) независимо от пояса браузера.
|
||||
const APP_TZ_OFFSET_MIN = 3 * 60;
|
||||
|
||||
const p2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3
|
||||
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК.
|
||||
function shifted(iso: string): Date {
|
||||
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000);
|
||||
}
|
||||
|
||||
export function formatDuration(minutes: number | null | undefined): string {
|
||||
if (minutes == null) return "—";
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (h && m) return `≈ ${h} ч ${m} мин`;
|
||||
if (h) return `≈ ${h} ч`;
|
||||
return `≈ ${m} мин`;
|
||||
}
|
||||
|
||||
export function formatTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = shifted(iso);
|
||||
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = shifted(iso);
|
||||
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { components } from "../api/schema";
|
||||
|
||||
type S = components["schemas"];
|
||||
|
||||
export type Me = S["MeRead"];
|
||||
export type UserRead = S["UserRead"];
|
||||
export type GroupBrief = S["GroupBrief"];
|
||||
export type GroupDetail = S["GroupDetail"];
|
||||
export type ExpansionRead = S["ExpansionRead"];
|
||||
export type FactionRead = S["FactionRead"];
|
||||
export type MemberRead = S["MemberRead"];
|
||||
export type MatchRead = S["MatchRead"];
|
||||
export type MatchList = S["MatchList"];
|
||||
export type MatchListItem = S["MatchListItem"];
|
||||
export type Leaderboard = S["Leaderboard"];
|
||||
export type LeaderboardEntry = S["LeaderboardEntry"];
|
||||
export type ProfileStats = S["ProfileStats"];
|
||||
export type GroupStats = S["GroupStats"];
|
||||
export type HomeResponse = S["HomeResponse"];
|
||||
export type HomeInProgressMatch = S["HomeInProgressMatch"];
|
||||
export type FactionStat = S["FactionStat"];
|
||||
export type AuthConfig = S["AuthConfig"];
|
||||
export type AdminMe = S["AdminMe"];
|
||||
export type AdminUserRead = S["AdminUserRead"];
|
||||
export type AdminGroupRead = S["AdminGroupRead"];
|
||||
export type AdminMatchRead = S["AdminMatchRead"];
|
||||
export type AuditLogList = S["AuditLogList"];
|
||||
export type ParticipantInput = S["ParticipantInput"];
|
||||
export type MatchUpdate = S["MatchUpdate"];
|
||||
export type MatchCreate = S["MatchCreate"];
|
||||
export type MatchFinish = S["MatchFinish"];
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { components } from "../api/schema";
|
||||
|
||||
export type WinReason = components["schemas"]["MatchFinish"]["win_reason"];
|
||||
|
||||
// Фиксированный порядок (сверху вниз).
|
||||
export const WIN_REASONS: { code: WinReason; label: string }[] = [
|
||||
{ code: "objectives", label: "По целям" },
|
||||
{ code: "worlds", label: "По мирам" },
|
||||
{ code: "plastic", label: "По пластику" },
|
||||
{ code: "resources", label: "По ресурсам" },
|
||||
];
|
||||
|
||||
const LABELS: Record<string, string> = Object.fromEntries(
|
||||
WIN_REASONS.map((w) => [w.code, w.label]),
|
||||
);
|
||||
|
||||
export function winReasonLabel(code: string | null | undefined): string {
|
||||
return code ? LABELS[code] ?? code : "—";
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import type { AdminMe, MatchUpdate } from "../domain/types";
|
||||
|
||||
export function useAdminMe() {
|
||||
return useQuery({
|
||||
queryKey: qk.adminMe,
|
||||
queryFn: async (): Promise<AdminMe | null> => {
|
||||
const r = await api.GET("/api/admin/me");
|
||||
if (r.response.status === 401 || r.response.status === 403) return null;
|
||||
return unwrap(r);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: { username: string; password: string }) =>
|
||||
unwrap(await api.POST("/api/admin/auth/login", { body })),
|
||||
onSuccess: (me) => qc.setQueryData(qk.adminMe, me),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminLogout() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => unwrap(await api.POST("/api/admin/auth/logout")),
|
||||
onSuccess: () => qc.setQueryData(qk.adminMe, null),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUsers(query?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...qk.adminUsers, query ?? ""],
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/admin/users", {
|
||||
params: { query: query ? { query } : {} },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminGroups() {
|
||||
return useQuery({
|
||||
queryKey: qk.adminGroups,
|
||||
queryFn: async () => unwrap(await api.GET("/api/admin/groups")),
|
||||
});
|
||||
}
|
||||
|
||||
export interface AdminMatchFilters {
|
||||
group_id?: number | null;
|
||||
user_id?: number | null;
|
||||
faction_id?: number | null;
|
||||
}
|
||||
|
||||
export function useAdminMatches(filters: AdminMatchFilters = {}) {
|
||||
const query: Record<string, number> = {};
|
||||
if (filters.group_id != null) query.group_id = filters.group_id;
|
||||
if (filters.user_id != null) query.user_id = filters.user_id;
|
||||
if (filters.faction_id != null) query.faction_id = filters.faction_id;
|
||||
return useQuery({
|
||||
queryKey: [...qk.adminMatches, query],
|
||||
queryFn: async () =>
|
||||
unwrap(await api.GET("/api/admin/matches", { params: { query } })),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminMatch(matchId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ["adminMatch", matchId],
|
||||
enabled: matchId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/admin/matches/{match_id}", {
|
||||
params: { path: { match_id: matchId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUpdateMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { matchId: number; body: MatchUpdate }) =>
|
||||
unwrap(
|
||||
await api.PATCH("/api/admin/matches/{match_id}", {
|
||||
params: { path: { match_id: args.matchId } },
|
||||
body: args.body,
|
||||
}),
|
||||
),
|
||||
onSuccess: (_d, args) => {
|
||||
qc.invalidateQueries({ queryKey: qk.adminMatches });
|
||||
qc.invalidateQueries({ queryKey: ["adminMatch", args.matchId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminFactions() {
|
||||
return useQuery({
|
||||
queryKey: ["adminFactions"],
|
||||
queryFn: async () => unwrap(await api.GET("/api/admin/factions")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminRenameFaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { factionId: number; name_ru: string }) =>
|
||||
unwrap(
|
||||
await api.PATCH("/api/admin/factions/{faction_id}", {
|
||||
params: { path: { faction_id: args.factionId } },
|
||||
body: { name_ru: args.name_ru },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminFactions"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminLogs(filters?: { action?: string; entity_type?: string }) {
|
||||
return useQuery({
|
||||
queryKey: [...qk.adminLogs, filters?.action ?? "", filters?.entity_type ?? ""],
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/admin/audit-logs", {
|
||||
params: {
|
||||
query: {
|
||||
...(filters?.action ? { action: filters.action } : {}),
|
||||
...(filters?.entity_type ? { entity_type: filters.entity_type } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminDeleteMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (matchId: number) =>
|
||||
unwrap(
|
||||
await api.DELETE("/api/admin/matches/{match_id}", {
|
||||
params: { path: { match_id: matchId } },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminMatches }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUpdateUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { userId: number; nickname?: string; is_active?: boolean }) =>
|
||||
unwrap(
|
||||
await api.PATCH("/api/admin/users/{user_id}", {
|
||||
params: { path: { user_id: args.userId } },
|
||||
body: { nickname: args.nickname, is_active: args.is_active },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import type { AuthConfig, Me } from "../domain/types";
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({
|
||||
queryKey: qk.me,
|
||||
queryFn: async (): Promise<Me | null> => {
|
||||
const r = await api.GET("/api/users/me");
|
||||
if (r.response.status === 401) return null;
|
||||
return unwrap(r);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuthConfig() {
|
||||
return useQuery({
|
||||
queryKey: qk.authConfig,
|
||||
queryFn: async (): Promise<AuthConfig> => unwrap(await api.GET("/api/auth/config")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDevUsers(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: qk.devUsers,
|
||||
enabled,
|
||||
queryFn: async () => unwrap(await api.GET("/api/auth/dev/users")),
|
||||
});
|
||||
}
|
||||
|
||||
// DEV-вход по нику (только в деве; в прод-бэкенде эндпоинта нет).
|
||||
export function useStubLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (nickname: string) =>
|
||||
unwrap(await api.POST("/api/auth/dev/login", { body: { nickname } })),
|
||||
onSuccess: (me) => {
|
||||
qc.setQueryData(qk.me, me);
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
|
||||
export function useTelegramLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (payload: Record<string, unknown>) =>
|
||||
unwrap(await api.POST("/api/auth/telegram", { body: payload as never })),
|
||||
onSuccess: (me) => {
|
||||
qc.setQueryData(qk.me, me);
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateDevUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (nickname: string) =>
|
||||
unwrap(await api.POST("/api/auth/dev/users", { body: { nickname } })),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.devUsers }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => unwrap(await api.POST("/api/auth/logout")),
|
||||
onSuccess: () => {
|
||||
qc.setQueryData(qk.me, null);
|
||||
qc.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNickname() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (nickname: string) =>
|
||||
unwrap(await api.PATCH("/api/users/me", { body: { nickname } })),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.me });
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetActiveGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (group_id: number | null) =>
|
||||
unwrap(await api.PUT("/api/users/me/active-group", { body: { group_id } })),
|
||||
onSuccess: (me) => {
|
||||
qc.setQueryData(qk.me, me);
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
|
||||
export function useMyGroups() {
|
||||
return useQuery({
|
||||
queryKey: qk.groups,
|
||||
queryFn: async () => unwrap(await api.GET("/api/groups")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroup(groupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: groupId ? qk.group(groupId) : ["group", "none"],
|
||||
enabled: groupId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/groups/{group_id}", {
|
||||
params: { path: { group_id: groupId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupMembers(groupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: groupId ? qk.groupMembers(groupId) : ["group", "none", "members"],
|
||||
enabled: groupId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/groups/{group_id}/members", {
|
||||
params: { path: { group_id: groupId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupStats(groupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: groupId ? qk.groupStats(groupId) : ["group", "none", "stats"],
|
||||
enabled: groupId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/groups/{group_id}/stats", {
|
||||
params: { path: { group_id: groupId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupMatches(groupId: number | null, limit = 20, offset = 0) {
|
||||
return useQuery({
|
||||
queryKey: groupId ? [...qk.groupMatches(groupId), limit, offset] : ["group", "none", "matches"],
|
||||
enabled: groupId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/groups/{group_id}/matches", {
|
||||
params: { path: { group_id: groupId as number }, query: { limit, offset } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: { name: string; expansion_ids: number[] }) =>
|
||||
unwrap(await api.POST("/api/groups", { body })),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.groups });
|
||||
qc.invalidateQueries({ queryKey: qk.me });
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetGroupExpansions(groupId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (expansion_ids: number[]) =>
|
||||
unwrap(
|
||||
await api.PUT("/api/groups/{group_id}/expansions", {
|
||||
params: { path: { group_id: groupId } },
|
||||
body: { expansion_ids },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.group(groupId) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupFactions(groupId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddMember(groupId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (nickname: string) =>
|
||||
unwrap(
|
||||
await api.POST("/api/groups/{group_id}/members", {
|
||||
params: { path: { group_id: groupId } },
|
||||
body: { nickname },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveMember(groupId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (userId: number) =>
|
||||
unwrap(
|
||||
await api.DELETE("/api/groups/{group_id}/members/{user_id}", {
|
||||
params: { path: { group_id: groupId, user_id: userId } },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) });
|
||||
qc.invalidateQueries({ queryKey: qk.me });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types";
|
||||
|
||||
export function useMatch(matchId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: matchId ? qk.match(matchId) : ["match", "none"],
|
||||
enabled: matchId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/matches/{match_id}", {
|
||||
params: { path: { match_id: matchId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRandomizeFaction() {
|
||||
return useMutation({
|
||||
mutationFn: async (args: { group_id: number; exclude_faction_ids: number[] }): Promise<FactionRead> => {
|
||||
const res = unwrap(await api.POST("/api/matches/randomize-faction", { body: args }));
|
||||
return res.faction;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Этап 1: старт партии (игроки + фракции, без мест). */
|
||||
export function useStartMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: MatchCreate): Promise<MatchRead> =>
|
||||
unwrap(await api.POST("/api/matches", { body })),
|
||||
onSuccess: (m) => {
|
||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Этап 2: завершение партии (места, причина победы). */
|
||||
export function useFinishMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { matchId: number; body: MatchFinish }): Promise<MatchRead> =>
|
||||
unwrap(
|
||||
await api.POST("/api/matches/{match_id}/finish", {
|
||||
params: { path: { match_id: args.matchId } },
|
||||
body: args.body,
|
||||
}),
|
||||
),
|
||||
onSuccess: (m) => {
|
||||
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (matchId: number) =>
|
||||
unwrap(
|
||||
await api.DELETE("/api/matches/{match_id}", {
|
||||
params: { path: { match_id: matchId } },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
|
||||
export function useExpansions() {
|
||||
return useQuery({
|
||||
queryKey: qk.expansions,
|
||||
queryFn: async () => unwrap(await api.GET("/api/expansions")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupFactions(groupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"],
|
||||
enabled: groupId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/groups/{group_id}/factions", {
|
||||
params: { path: { group_id: groupId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
|
||||
export function useHome() {
|
||||
return useQuery({
|
||||
queryKey: qk.home,
|
||||
queryFn: async () => unwrap(await api.GET("/api/home")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLeaderboard(limit = 50) {
|
||||
return useQuery({
|
||||
queryKey: [...qk.leaderboard, limit],
|
||||
queryFn: async () =>
|
||||
unwrap(await api.GET("/api/stats/leaderboard", { params: { query: { limit } } })),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMyStats() {
|
||||
return useQuery({
|
||||
queryKey: qk.myStats,
|
||||
queryFn: async () => unwrap(await api.GET("/api/users/me/stats")),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles/global.css";
|
||||
import "./styles/layout.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { GroupSwitcher } from "../components/GroupSwitcher";
|
||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useLogout, useMe, useUpdateNickname } from "../hooks/auth";
|
||||
import { useMyStats } from "../hooks/stats";
|
||||
|
||||
export function AccountPage() {
|
||||
const { data: me, isLoading } = useMe();
|
||||
const { data: stats } = useMyStats();
|
||||
const updateNick = useUpdateNickname();
|
||||
const logout = useLogout();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [nickname, setNickname] = useState("");
|
||||
|
||||
if (isLoading || !me) return <Spinner />;
|
||||
|
||||
const save = async () => {
|
||||
const nick = nickname.trim();
|
||||
if (!nick) return;
|
||||
try {
|
||||
await updateNick.mutateAsync(nick);
|
||||
setNickname("");
|
||||
toast.show("Никнейм обновлён");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const doLogout = async () => {
|
||||
await logout.mutateAsync().catch(() => {});
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>Профиль</h3>
|
||||
<div className="row-between">
|
||||
<span className="muted">Никнейм</span>
|
||||
<b>{me.nickname}</b>
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: 10 }}>
|
||||
<label className="label">Сменить никнейм</label>
|
||||
<div className="row">
|
||||
<input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={me.nickname} />
|
||||
<button className="btn btn-primary" onClick={save} disabled={updateNick.isPending}>
|
||||
ОК
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats && <ProfileStatsCard stats={stats} />}
|
||||
|
||||
<GroupSwitcher />
|
||||
|
||||
<button className="btn btn-danger" style={{ width: "100%" }} onClick={doLogout}>
|
||||
Выйти
|
||||
</button>
|
||||
|
||||
<p className="center" style={{ marginTop: 24 }}>
|
||||
<button
|
||||
className="btn btn-ghost small muted"
|
||||
onClick={() => navigate("/admin/login")}
|
||||
>
|
||||
Вход для администратора
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Dices, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { MAX_MATCH_PLAYERS } from "../domain/constants";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import type { MemberRead } from "../domain/types";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import { useGroupFactions } from "../hooks/reference";
|
||||
import { useGroupMembers } from "../hooks/groups";
|
||||
import { useRandomizeFaction, useStartMatch } from "../hooks/matches";
|
||||
|
||||
interface Draft {
|
||||
user_id: number | "";
|
||||
faction_id: number | "";
|
||||
was_random: boolean;
|
||||
}
|
||||
|
||||
function emptyDraft(): Draft {
|
||||
return { user_id: "", faction_id: "", was_random: false };
|
||||
}
|
||||
|
||||
export function CreateMatchPage() {
|
||||
const { data: me } = useMe();
|
||||
const groupId = me?.active_group_id ?? null;
|
||||
const { data: members, isLoading: lm } = useGroupMembers(groupId);
|
||||
const { data: factions, isLoading: lf } = useGroupFactions(groupId);
|
||||
const startMatch = useStartMatch();
|
||||
const randomize = useRandomizeFaction();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rows, setRows] = useState<Draft[]>([emptyDraft(), emptyDraft()]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (lm || lf) return <Spinner />;
|
||||
if (!groupId) return <div className="muted">Нет активной группы.</div>;
|
||||
|
||||
const takenFactionIds = (exceptIdx: number) =>
|
||||
rows
|
||||
.filter((_, i) => i !== exceptIdx)
|
||||
.map((r) => r.faction_id)
|
||||
.filter((x): x is number => typeof x === "number");
|
||||
|
||||
const update = (idx: number, patch: Partial<Draft>) =>
|
||||
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
|
||||
const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
|
||||
const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx));
|
||||
|
||||
const randomizeRow = async (idx: number) => {
|
||||
try {
|
||||
const faction = await randomize.mutateAsync({
|
||||
group_id: groupId,
|
||||
exclude_faction_ids: takenFactionIds(idx),
|
||||
});
|
||||
update(idx, { faction_id: faction.id, was_random: true });
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
||||
}
|
||||
};
|
||||
|
||||
const randomizeAll = () => {
|
||||
const pool = [...(factions ?? [])];
|
||||
for (let i = pool.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||
}
|
||||
if (pool.length < rows.length) toast.show("Фракций меньше, чем игроков — возможны повторы.");
|
||||
setRows((rs) =>
|
||||
rs.map((r, i) => ({ ...r, faction_id: pool[i % pool.length]?.id ?? "", was_random: true })),
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
const filled = rows.filter((r) => r.user_id !== "" && r.faction_id !== "");
|
||||
if (filled.length < 2) {
|
||||
setError("Нужно минимум 2 участника с выбранным игроком и фракцией.");
|
||||
return;
|
||||
}
|
||||
if (filled.length > MAX_MATCH_PLAYERS) {
|
||||
setError(`Максимум ${MAX_MATCH_PLAYERS} игроков в партии.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const match = await startMatch.mutateAsync({
|
||||
group_id: groupId,
|
||||
participants: filled.map((r) => ({
|
||||
user_id: r.user_id as number,
|
||||
faction_id: r.faction_id as number,
|
||||
was_random: r.was_random,
|
||||
})),
|
||||
});
|
||||
toast.show("Партия начата");
|
||||
navigate(`/match/${match.id}`, { replace: true });
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Не удалось начать партию");
|
||||
}
|
||||
};
|
||||
|
||||
const memberOptions = (members ?? []) as MemberRead[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>Этап 1 — старт партии</h3>
|
||||
<p className="muted small">Выберите игроков и фракции. Места и итоги укажете при завершении.</p>
|
||||
<button className="btn" onClick={randomizeAll}>
|
||||
<Dices size={18} /> Рандом всем
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rows.map((row, idx) => (
|
||||
<div className="participant-row" key={idx}>
|
||||
<div className="row-between">
|
||||
<b>Игрок {idx + 1}</b>
|
||||
{rows.length > 2 && (
|
||||
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(idx)}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={row.user_id}
|
||||
onChange={(e) => update(idx, { user_id: e.target.value ? Number(e.target.value) : "" })}
|
||||
>
|
||||
<option value="">— игрок —</option>
|
||||
{memberOptions.map((m) => (
|
||||
<option key={m.user_id} value={m.user_id}>
|
||||
{m.nickname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="row">
|
||||
<select
|
||||
value={row.faction_id}
|
||||
onChange={(e) =>
|
||||
update(idx, {
|
||||
faction_id: e.target.value ? Number(e.target.value) : "",
|
||||
was_random: false,
|
||||
})
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<option value="">— фракция —</option>
|
||||
{(factions ?? []).map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name_ru}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn" onClick={() => randomizeRow(idx)} title="Случайная фракция">
|
||||
<Dices size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{row.was_random && row.faction_id !== "" && <span className="badge">🎲 случайная</span>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{rows.length < Math.min(MAX_MATCH_PLAYERS, memberOptions.length) && (
|
||||
<button className="btn btn-ghost" onClick={addRow}>
|
||||
+ Добавить игрока
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%", marginTop: 12 }}
|
||||
onClick={submit}
|
||||
disabled={startMatch.isPending}
|
||||
>
|
||||
Начать партию
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { CreateGroupForm } from "../components/CreateGroupForm";
|
||||
import { GroupSwitcher } from "../components/GroupSwitcher";
|
||||
import { Leaderboard } from "../components/Leaderboard";
|
||||
import { MatchListView } from "../components/MatchList";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import { useGroup, useGroupMatches, useGroupStats } from "../hooks/groups";
|
||||
|
||||
export function GroupPage() {
|
||||
const { data: me, isLoading } = useMe();
|
||||
const navigate = useNavigate();
|
||||
const groupId = me?.active_group_id ?? null;
|
||||
|
||||
const { data: group } = useGroup(groupId);
|
||||
const { data: stats } = useGroupStats(groupId);
|
||||
const { data: matches } = useGroupMatches(groupId);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
if (!groupId) {
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>Активная группа не выбрана</h3>
|
||||
<p className="muted small">
|
||||
Создайте группу или выберите существующую, чтобы вести учёт партий.
|
||||
</p>
|
||||
</div>
|
||||
<GroupSwitcher />
|
||||
<CreateGroupForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="row-between">
|
||||
<h2 style={{ margin: 0 }}>{group?.name ?? "Группа"}</h2>
|
||||
{group && (
|
||||
<button className="btn btn-ghost small" onClick={() => navigate("/group/settings")}>
|
||||
Настройки
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Партий: {stats?.total_matches ?? 0}
|
||||
{stats?.last_match_at ? ` · последняя: ${stats.last_match_at}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Топ в группе</h3>
|
||||
{stats && <Leaderboard entries={stats.leaderboard} provisional={stats.provisional} />}
|
||||
</div>
|
||||
|
||||
<GroupSwitcher />
|
||||
|
||||
<h3 style={{ marginTop: 8 }}>Партии</h3>
|
||||
{matches && <MatchListView items={matches.items} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user