v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика

This commit is contained in:
2026-06-16 17:41:06 +03:00
parent 6ab74f01aa
commit 56b5d09a4d
123 changed files with 13572 additions and 21 deletions
+29
View File
@@ -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/
+55
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+54 -21
View File
@@ -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`.
## Дополнения и фракции
+40
View File
@@ -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
+55
View File
@@ -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()
+25
View File
@@ -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"}
+32
View File
@@ -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
View File
View File
+43
View File
@@ -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
+23
View File
@@ -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),
)
+36
View File
@@ -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
+26
View File
@@ -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
+15
View File
@@ -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
+59
View File
@@ -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),
)
+65
View File
@@ -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()
View File
+105
View File
@@ -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()
+126
View File
@@ -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()
+120
View File
@@ -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)
+29
View File
@@ -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()
View File
+21
View File
@@ -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)
+61
View File
@@ -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
+154
View File
@@ -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()
+288
View File
@@ -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; в БД только метаданные и относительный путь.
View File
+311
View File
@@ -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
)
+45
View File
@@ -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()
+54
View File
@@ -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]
+209
View File
@@ -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)
+215
View File
@@ -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()
+37
View File
@@ -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)
]
+32
View File
@@ -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]
+94
View File
@@ -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)
View File
+406
View File
@@ -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
View File
+68
View File
@@ -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()
View File
+168
View File
@@ -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,
}
+33
View File
@@ -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,
)
)
+20
View File
@@ -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()
)
+138
View File
@@ -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]
+322
View File
@@ -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
+34
View File
@@ -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(),
)
+353
View File
@@ -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,
}
+94
View File
@@ -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
+11
View File
@@ -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
+32
View File
@@ -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 = ["."]
+9
View File
@@ -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
View File
+126
View File
@@ -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()
+139
View File
@@ -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"
+81
View File
@@ -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 # (для единообразия импорта)
+363
View File
@@ -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
+41
View File
@@ -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:
+38
View File
@@ -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:
+3
View File
@@ -0,0 +1,3 @@
# Пути в OpenAPI уже содержат префикс /api, поэтому baseUrl пустой.
# В dev Vite проксирует /api на бэкенд :8000; в prod — тот же origin.
VITE_API_BASE_URL=
+16
View File
@@ -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>
+2205
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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"
}
}
+16
View File
@@ -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>
);
}
+55
View File
@@ -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;
}
+22
View File
@@ -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,
};
+2842
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
+68
View File
@@ -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 /> },
]);
+29
View File
@@ -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}</>;
}
+39
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+19
View File
@@ -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} />;
}
+30
View File
@@ -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);
}
+3
View File
@@ -0,0 +1,3 @@
// Лимиты, зеркалят значения бэкенда (авторитетная проверка — на сервере).
export const MAX_MATCH_PLAYERS = 6;
export const MAX_GROUP_SIZE = 10;
+39
View File
@@ -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;
}
}
+31
View File
@@ -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"];
+19
View File
@@ -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 : "—";
}
+165
View File
@@ -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 }),
});
}
+101
View File
@@ -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 });
},
});
}
+123
View File
@@ -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 });
},
});
}
+75
View File
@@ -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(),
});
}
+24
View File
@@ -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 } },
}),
),
});
}
+26
View File
@@ -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")),
});
}
+12
View File
@@ -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>,
);
+77
View File
@@ -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>
);
}
+183
View File
@@ -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>
);
}
+65
View File
@@ -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