2 Commits
191 changed files with 1707 additions and 20845 deletions
+5 -14
View File
@@ -1,23 +1,13 @@
# Шаблоны отсчитываются от корня контекста: без «**/» правило ловит только корень
# (так `data/` пропускал backend/data/dev/ с dev-БД и загрузками в образ, #71).
# Python # Python
**/__pycache__/ **/__pycache__/
**/*.pyc **/*.pyc
**/*.egg-info/
backend/.venv/ backend/.venv/
backend/.pytest_cache/ backend/.pytest_cache/
backend/*.db
backend/*.db-wal
backend/*.db-shm
backend/openapi.json backend/openapi.json
# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и
# локальные копии бэкапов — ни в образ, ни в контекст сборки
backend/data/
data/
backups/
**/*.db
**/*.db-wal
**/*.db-shm
# Node / сборка фронта # Node / сборка фронта
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
@@ -33,7 +23,8 @@ backend/tests/
backend/pyproject.toml backend/pyproject.toml
# Прочее # Прочее
**/.env .env
data/
**/.DS_Store **/.DS_Store
*.md *.md
.claude/ .claude/
+16 -91
View File
@@ -1,12 +1,13 @@
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# Forbidden Stars — единый .env (dev / prod) # Forbidden Stars — единый .env (dev / test / prod)
# Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт. # Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт.
# Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC. # Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC.
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ──────────────────────────────────────────────────── # ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать: # Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, есть вход по нику (stub) # development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
# test — прод-клон в Docker локально (порт 8080), вход только TG
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d). # production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production. # Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
APP_ENV=development APP_ENV=development
@@ -14,130 +15,54 @@ APP_ENV=development
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ───────────────────────────────────── # ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost; # LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru. # vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля, # TEST и PROD выставляют себя сами через туннель-КОНТЕЙНЕР (docker-compose*.yml) — им
# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY # LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже они тоже читают.
# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69).
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
LOCAL_PUBLIC=local LOCAL_PUBLIC=local
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер прода. # Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер test/prod.
# Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS. # Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS.
VPS_TUNNEL_HOST=186.246.51.17 VPS_TUNNEL_HOST=186.246.51.17
VPS_TUNNEL_USER=tunnel VPS_TUNNEL_USER=tunnel
# VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию: VPS_TUNNEL_PORT=9001 # слот VPS для dev-туннеля (forbidden-stars.ru); контейнеры: prod=9000, test=9001
# dev (run.ps1) → 9001, прод-контейнер → 9000.
# Раскомментируй и переопредели, только если нужен нестандартный слот.
#VPS_TUNNEL_PORT=9001
# Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env
# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; временный прод
# на ПК берёт ключ из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк:
# Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy/tunnel/id_tunnel")))
# Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS.
TUNNEL_KEY_B64=
# ─── АДМИНИСТРАТОР (вход по логину/паролю, отдельно от Telegram) ────────────── # ─── АДМИНИСТРАТОР (вход по логину/паролю, отдельно от Telegram) ──────────────
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-admin-password ADMIN_PASSWORD=change-me-admin-password
ADMIN_NICKNAME=Администратор
ADMIN_BOOTSTRAP_ENABLED=true ADMIN_BOOTSTRAP_ENABLED=true
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ─────────────────────────────────────────────────── # ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
# Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля). # Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
# Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у # Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod) # BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
# и forbidden-stars.ru (dev). # и forbidden-stars.ru (dev/test).
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_BOT_USERNAME= TELEGRAM_BOT_USERNAME=
# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev https://forbidden-stars.ru. # Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev/test https://forbidden-stars.ru.
PUBLIC_BASE_URL= PUBLIC_BASE_URL=
# ─── БЕЗОПАСНОСТЬ / СЕССИИ ──────────────────────────────────────────────────── # ─── БЕЗОПАСНОСТЬ / СЕССИИ ────────────────────────────────────────────────────
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))" # Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как # ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно). # подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод.
SECRET_KEY=change-me-dev-secret-not-for-production SECRET_KEY=change-me-dev-secret-not-for-production
JWT_ALGORITHM=HS256 JWT_ALGORITHM=HS256
JWT_USER_TTL_MINUTES=10080 JWT_USER_TTL_MINUTES=10080
JWT_ADMIN_TTL_MINUTES=480 JWT_ADMIN_TTL_MINUTES=480
# COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie): # COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie):
# dev+localhost → false; dev через VPS, prod → true. Вручную задавать НЕ нужно. # dev+localhost → false; dev через VPS, test, prod → true. Вручную задавать НЕ нужно.
COOKIE_DOMAIN= COOKIE_DOMAIN=
# ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ──────────── # ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ────────────
# dev → DEV_DATABASE_URL (файл в ./data/dev/); prod → PROD_DATABASE_URL # dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL
# (том /data контейнера, см. docker-compose*.yml). # (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml).
DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db
PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db
# Каталог загрузок (фото партий), раздельно для dev и prod. # Каталог загрузок (зарезервировано под фото/видео), тоже раздельно.
DEV_UPLOAD_DIR=./data/dev/uploads DEV_UPLOAD_DIR=./data/dev/uploads
PROD_UPLOAD_DIR=/data/uploads PROD_UPLOAD_DIR=/data/uploads
# Каталог определений титулов (файлы: meta.json, condition.py, icon.*), раздельно.
DEV_ACHIEVEMENTS_DIR=./data/dev/achievements
PROD_ACHIEVEMENTS_DIR=/data/achievements
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
# Образы собираются под arm64 на ПК (scripts/build-push.ps1; в Linux — .sh) и пушатся в Gitea-реестр,
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
# Перед пушем/пуллом: docker login gitea.arseniev.info
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
IMAGE_TAG=latest
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU).
# Подними, если у Pi больше RAM/ядер. Лимит памяти работает, только если на Pi включён
# memory cgroup (cgroup_enable=memory в cmdline.txt) — см. deploy/pi/README.md, раздел 1.
#APP_MEM_LIMIT=512m
#APP_CPUS=1.5
# ─── БЭКАПЫ (контейнер backup: restic; подробно — deploy/backup/README.md) ────
# Снимки БД + uploads + achievements по расписанию: локально на Pi (том backup-data) и —
# если задан BACKUP_VPS_HOST — на VPS по SFTP. Шифрование, дедупликация, сжатие — restic.
#
# Пароль шифрования репозиториев. ПОТЕРЯЕТЕ ПАРОЛЬ — НИ ОДИН БЭКАП НЕ ПРОЧИТАТЬ.
# Сохраните его в менеджер паролей. Пусто = бэкапы отключены. Без символа '$'.
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(32))"
BACKUP_PASSWORD=
# Когда делать бэкап и проверку данных (cron: минута час день месяц день_недели; TZ ниже).
BACKUP_SCHEDULE="0 4 * * *"
BACKUP_VERIFY_SCHEDULE="30 5 * * 0"
BACKUP_TZ=Europe/Moscow
# Сколько хранить: последний снимок каждого дня / недели / месяца.
# Именованные снимки (fs-backup run --tag …) и страховочные pre-restore не удаляются.
BACKUP_KEEP_DAILY=14
BACKUP_KEEP_WEEKLY=8
BACKUP_KEEP_MONTHLY=12
# Сжатие restic: auto (быстрее) | max (плотнее) | off.
BACKUP_COMPRESSION=max
# Контейнер помечается unhealthy, если последний успешный бэкап старше стольких часов.
BACKUP_MAX_AGE_HOURS=30
# Оффсайт-копия на VPS (SFTP). Пусто = только локальная копия на Pi.
BACKUP_VPS_HOST=
BACKUP_VPS_USER=fsbackup
BACKUP_VPS_PORT=22
BACKUP_VPS_DIR=/srv/fs-backups/restic
# Приватный SSH-ключ для VPS в base64 одной строкой (как TUNNEL_KEY_B64). На ПК ключ лежит
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
BACKUP_SSH_KEY_B64=
# Отчёты о бэкапах в Telegram и команды /backups, /status (deploy/backup/README.md, раздел 10).
# id своего чата: напишите боту /start → `docker compose exec backup fs-backup telegram chats`. Несколько — через
# запятую. Пусто = отчёты выключены. Токен по умолчанию — TELEGRAM_BOT_TOKEN приложения.
BACKUP_TELEGRAM_CHAT_ID=
#BACKUP_TELEGRAM_BOT_TOKEN=
# Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит
# docker-compose.yml прода.
BACKUP_PI_SSH=pi@192.168.1.10
BACKUP_PI_DIR=~/forbidden-stars
# ─── ПРОЧЕЕ ─────────────────────────────────────────────────────────────────── # ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём # Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
# пояс устройства игрока не учитывается.
APP_TZ_OFFSET_HOURS=3 APP_TZ_OFFSET_HOURS=3
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin # CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
+16 -1
View File
@@ -1,5 +1,20 @@
# ── Нормализация переводов строк ────────────────────────────────────────────── # ── Нормализация переводов строк ──────────────────────────────────────────────
# Шелл-скрипты обязаны быть с LF: в Linux-контейнере и на Pi CRLF ломает shebang. # Шелл-скрипты обязаны быть с LF: в Linux-контейнере и на Pi CRLF ломает shebang.
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке. # entrypoint.sh контейнер чинит сам (sed в Dockerfile), но backup.sh запускается
# с хоста Pi — для него LF в репозитории критичен.
*.sh text eol=lf *.sh text eol=lf
backend/entrypoint.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
+2 -7
View File
@@ -15,7 +15,7 @@ venv/
data/ data/
backend/dev.db* backend/dev.db*
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/prod) # Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod)
.env .env
!.env.example !.env.example
@@ -23,7 +23,6 @@ backend/dev.db*
*.pem *.pem
*.key *.key
id_tunnel* id_tunnel*
id_backup*
deploy/**/certs/ deploy/**/certs/
# Node / фронт # Node / фронт
@@ -35,15 +34,11 @@ frontend/.vite/
# Сгенерированный снапшот OpenAPI (контракт фронта закоммичен в schema.d.ts) # Сгенерированный снапшот OpenAPI (контракт фронта закоммичен в schema.d.ts)
backend/openapi.json backend/openapi.json
# Бэкапы, скачанные на ПК (scripts/fs-backup.* pull) — не зашифрованы, в git не идут # Бэкапы (создаёт scripts/backup.sh на Pi)
backups/ backups/
# Фронт-макеты для проработки UI (локальные прототипы, не для репозитория)
mockups/
# AI-ассистенты (локальные, в репозиторий не идут) # AI-ассистенты (локальные, в репозиторий не идут)
CLAUDE.md CLAUDE.md
AGENTS.md
.claude/ .claude/
# Редактор / ОС # Редактор / ОС
+86 -132
View File
@@ -1,37 +1,29 @@
# Forbidden Stars — учёт партий # Forbidden Stars — учёт партий
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**: Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
профили игроков (вход по логину и паролю или через Telegram), группы с приглашениями, профили игроков (вход через Telegram, пока — dev-заглушка), группы, создание партий
создание партий с рандомом фракций и фильтром по дополнениям группы, фото партий, с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель.
уведомления, статистика и общий топ, админ-панель.
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite - **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
- **Фронтенд:** React · Vite · TypeScript (SPA; с ядром общается по REST API, живые - **Фронтенд:** React · Vite · TypeScript (SPA, общается с ядром только по REST API)
обновления приходят SSE-потоком `/api/events`) - **Хостинг:** Raspberry Pi 4 (ARM64) в Docker
- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker; наружу — через VPS-привратник (см. «Домен и публикация»)
## Структура ## Структура
``` ```
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты backend/ FastAPI: ядро, REST API, БД, миграции, seed
frontend/ React + Vite SPA frontend/ React + Vite SPA
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК)
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI) Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
docker-compose.yml прод на Pi: app + tunnel + backup docker-compose.yml
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel .env.example
run.ps1 / run.sh единый лаунчер dev
.env.example шаблон единого .env
``` ```
## Локальная разработка ## Локальная разработка
Всё проверяется в `development` — отдельного тестового контейнера нет.
### Единый лаунчер (`run.ps1` / `run.sh`) ### Единый лаунчер (`run.ps1` / `run.sh`)
После разовой настройки (ниже) dev запускается **одной командой** (лаунчер читает После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно,
`APP_ENV` в корневом `.env`): решает `APP_ENV` в корневом `.env`:
```powershell ```powershell
.\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh) .\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh)
@@ -39,9 +31,9 @@ run.ps1 / run.sh единый лаунчер dev
| `APP_ENV` в `.env` | что делает лаунчер | | `APP_ENV` в `.env` | что делает лаунчер |
|---|---| |---|---|
| `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` | | `development` | `uvicorn --reload` (бэк) + `vite` (фронт) нативно, в двух окнах |
| `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») | | `test` | `docker compose` прод-клон на :8080 (со сборкой образа) |
| другое значение | отказ: допустимы только `development` и `production` (бэкенд тоже не стартует) | | `production` | не запускает — прод деплоится отдельно (см. «Git и деплой») |
Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта
(после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд. (после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд.
@@ -55,9 +47,9 @@ python -m venv .venv
.\.venv\Scripts\Activate.ps1 # если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned .\.venv\Scripts\Activate.ps1 # если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
pip install -e ".[dev]" # .[dev] — один аргумент (пакет + dev-зависимости) pip install -e ".[dev]" # .[dev] — один аргумент (пакет + dev-зависимости)
Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория
alembic upgrade head # применит миграции и сидинг справочников alembic upgrade head # применит миграции и сидинг
python -m app.bootstrap # справочники + создаст/синхронизирует администратора из .env (опционально) python -m app.bootstrap # создаст/синхронизирует администратора из .env
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs) uvicorn app.main:app --reload # http://localhost:8000 (Swagger: /api/docs)
``` ```
**Windows cmd.exe** (здесь `&&` поддерживается): **Windows cmd.exe** (здесь `&&` поддерживается):
@@ -69,7 +61,7 @@ pip install -e ".[dev]"
copy ..\.env.example ..\.env copy ..\.env.example ..\.env
alembic upgrade head alembic upgrade head
python -m app.bootstrap python -m app.bootstrap
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 uvicorn app.main:app --reload
``` ```
**Linux / macOS / Git Bash:** **Linux / macOS / Git Bash:**
@@ -80,17 +72,9 @@ pip install -e ".[dev]"
cp ../.env.example ../.env cp ../.env.example ../.env
alembic upgrade head alembic upgrade head
python -m app.bootstrap python -m app.bootstrap
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 uvicorn app.main:app --reload
``` ```
> В dev тот же bootstrap (справочники + админ из `.env`) выполняется сам при старте uvicorn,
> поэтому `python -m app.bootstrap` вручную обычно не нужен. Dev-БД, загрузки и ачивки
> лежат в `backend/data/dev/` (пути в `.env` относительные — запускайте из `backend/`).
> **`--timeout-graceful-shutdown` обязателен.** Открытая вкладка держит SSE-поток
> `/api/events`, и без лимита `--reload` ждёт его закрытия вечно — сайт висит на загрузке,
> а в логе только `Reloading...`.
> **Единый `.env` — в корне репозитория** (`ForbidenStarsApp/.env`), рядом с `.env.example`. > **Единый `.env` — в корне репозитория** (`ForbidenStarsApp/.env`), рядом с `.env.example`.
> Его читают и бэкенд (через абсолютный путь, независимо от рабочей папки), и `docker compose`. > Его читают и бэкенд (через абсолютный путь, независимо от рабочей папки), и `docker compose`.
> Файл — локальный, на каждой машине свой (dev/prod различаются строкой `APP_ENV`). > Файл — локальный, на каждой машине свой (dev/prod различаются строкой `APP_ENV`).
@@ -103,117 +87,100 @@ npm run gen:api # сгенерирует типы из живог
npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека) npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека)
``` ```
Вход в dev-режиме — экран `/login`: логин/пароль, Telegram и вход по нику без пароля (stub); Вход в dev-режиме — экран `/login`: в деве доступны оба метода (Telegram + вход по нику),
в проде stub нет (см. раздел «Аутентификация»). в проде — только Telegram (см. раздел «Аутентификация»).
## Production (Docker на Pi) ## Production (Docker на Pi)
На Pi нужны только **два файла** — `docker-compose.yml` и `.env`: образы `app`, `tunnel` и
`backup` собираются на ПК под arm64 и пушатся в Gitea-реестр, Pi тянет их сам.
```powershell
# ПК (обычно с ветки main): собрать и опубликовать образы
docker login gitea.arseniev.info
.\scripts\build-push.ps1
```
```bash ```bash
# Pi, папка с docker-compose.yml и .env cp .env.example .env # заполните SECRET_KEY, ADMIN_PASSWORD и пр.
docker compose up -d # pull_policy: always — тянет свежие образы, без сборки docker compose build # на ARM64 собирается нативно
docker compose up -d # приложение на :8000, БД на томе
``` ```
Портов на хост нет — прод доступен только на `https://forbiddenstars.ru` через FastAPI отдаёт собранный SPA и API с одного origin. Миграции и бутстрап админа
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг выполняются автоматически при старте (`entrypoint.sh`).
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только
при первом создании админа; сменить его потом — правкой `.env`, `docker compose up -d` и
`docker compose exec app python -m app.bootstrap --reset-admin-password` (все админские
сессии завершатся; подробно — в `deploy/pi/README.md`).
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
## Test — локальный прод-клон в контейнере
Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков
только через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000).
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
Вручную (тот же эффект):
```bash
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 # остановить и стереть тестовые данные
```
- Окружение `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`).
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно), - **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
и docker compose (prod — `$` = подстановка переменной). Чтобы значение совпадало и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так: везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
`python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`). `python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`).
- **Временный прод на ПК** (вместо Pi): `docker compose -f docker-compose.temp.yml up -d --build` —
поведение production, локальная сборка x86, ключ туннеля из файла `deploy/tunnel/id_tunnel`,
тот же слот VPS 9000, что у Pi (одновременно не запускать).
## Аутентификация ## Аутентификация
Методы входа зависят от окружения (`APP_ENV`): Методы входа зависят от окружения (`APP_ENV`):
| | dev | prod | | | dev | test / prod |
|---|---|---| |---|---|---|
| Логин (= ник) и пароль | ✓ | ✓ (основной) | | Telegram Login Widget | ✓ | ✓ (единственный) |
| Telegram Login Widget | ✓ | ✓ | | Вход по нику (stub) | ✓ | ✗ (физически отсутствует) |
| Вход по нику без пароля (stub) | ✓ | ✗ (физически отсутствует) |
- **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока - **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
(смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt. файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин», 20 на IP и Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
50 на аккаунт (независимо от IP), дальше `429 TOO_MANY_ATTEMPTS`. Регистраций — не больше (`app/main.py`), а на фронте dev-блок вырезается из прод-сборки (`import.meta.env.DEV`).
10 с одного IP за окно. Игрок без пароля (из Telegram или созданный до паролей) после входа - **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`).
видит обязательное окно «Задайте пароль»: закрыть его нельзя, только задать пароль или `GET /api/auth/config` отдаёт доступные методы и `telegram_bot_username` для виджета.
выйти; в dev-сборке есть кнопка «Позже (dev)». В профиле пароль меняется (нужен текущий) и
привязывается Telegram (ник не меняется). Забытый пароль задаёт админ на вкладке
аккаунтов — почту приложение не хранит. Смена или сброс пароля завершает все прежние
сессии игрока (при смене в профиле текущее устройство остаётся в системе); «Выйти»
отзывает токен этого устройства.
- **Stub-вход (по нику)** и **жёсткое удаление аккаунтов** в админке — только для разработки.
Их код **физически не попадает в прод:** файлы `backend/app/auth/dev_stub.py`,
`backend/app/routers/dev_auth.py` и `backend/app/routers/dev_admin.py` исключены из
Docker-образа (`.dockerignore`), роутеры подключаются лишь при `APP_ENV=development`
(`app/main.py`), а на фронте dev-блоки вырезаются из прод-сборки (`import.meta.env.DEV`).
В проде аккаунт можно только отключить.
- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`) и свежесть
данных (не старше суток). Первый вход регистрирует игрока под Telegram-тегом; если такой ник
занят или некорректен, фронт просит выбрать другой. `GET /api/auth/config` отдаёт доступные
методы и `telegram_bot_username` для виджета.
**Настройка Telegram (когда будете подключать реальный вход):** **Настройка Telegram (когда будете подключать реальный вход):**
1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**. 1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**.
2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev). 2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev/test).
3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`). 3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`).
4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает. 4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает.
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях.
окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд.
## Окружения (dev / prod) ## Окружения (dev / test / prod)
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env`. Допустимы только Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер):
`development` и `production` — с любым другим значением бэкенд не стартует:
| | dev | prod (Pi) | | | dev | test (прод-клон локально) | prod (Pi) |
|---|---|---| |---|---|---|---|
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `docker compose up -d` | | Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` |
| `APP_ENV` | `development` | `production` (форсится в compose) | | `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) |
| Env-файл | единый `.env` | единый `.env` (на Pi) | | Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbiddenstars.ru` | | Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
| База данных | `backend/data/dev/…` | том `db-data` (`/data`) | | База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | | Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram |
| Swagger (`/api/docs`) | ✓ | ✗ |
| Fail-fast по дефолтным секретам | ✗ | ✓ |
- **Один `.env` на машину** в корне (рядом с `.env.example`). Прод-контейнер значение - **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
`APP_ENV` из него **игнорирует** и всегда `production`. запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
`production`. Отдельного `.env.test` больше нет.
- **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL` - **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL`
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат (`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`). РАЗНЫЕ тома). Данные дева в образ **не попадают** (`data/` в `.dockerignore`).
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически - **В Docker идёт только прод-код:** dev-вход (stub) и тесты физически исключены из образа
исключены из образа (`.dockerignore`). Данные дева (`backend/data/`), любые файлы SQLite (`.dockerignore`); `test` — тот же образ, что и прод, просто локально и с `APP_ENV=test`.
и локальные бэкапы (`backups/`) в образ тоже не попадают.
## Git и деплой ## Git и деплой
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и проверка здесь. - Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь.
- Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во - Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во
всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный; всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный;
чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов. чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов.
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит - **Деплой на Pi:** `git pull` ветки `main` → `docker compose up -d --build`.
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна - **Чистая выгрузка в папку без git** (опц.): `scripts/export-prod.sh <dir> main` (через
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL). `git archive` + `export-ignore` — в папку идёт ровно прод-набор, без лаунчера/тестов/dev-входа).
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`. Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
@@ -221,32 +188,21 @@ docker compose up -d # pull_policy: always — тянет свежие
Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а
приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT). приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT).
У **прода** туннель — **отдельный контейнер** в `docker-compose.yml`, и портов на хост он У **test/prod** туннель — **отдельный контейнер** в их `docker-compose`, и портов на хост
не публикует (доступен только через домен): они не публикуют (доступны только через домен):
| | домен | как выставляется | слот VPS | | | домен | как выставляется | слот VPS |
|---|---|---|---| |---|---|---|---|
| prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 | | prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 |
| dev | `forbidden-stars.ru` | лаунчер (`run.ps1`/`run.sh`) при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 | | test | `forbidden-stars.ru` | туннель-контейнер → `app:8000` | 9001 |
| dev | `forbidden-stars.ru` | `run.ps1` при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
- Прод (9000) и dev (9001) на **разных слотах/доменах** → работают одновременно. Временный - Прод (9000) и dev/test (9001) на **разных слотах/доменах** → прод и (dev|test) работают
прод на ПК (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать. одновременно. Dev и test делят слот 9001 → по очереди.
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен. - Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + `run.ps1` выставляет его на домен.
В dev при этом открыты stub-вход по нику и Swagger — держите туннель поднятым только на
время проверки (задача #69).
- `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет). - `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет).
- Ключ туннеля: временный прод берёт файл `deploy/tunnel/id_tunnel`, прод на Pi —
`TUNNEL_KEY_B64` (base64) в `.env`, dev-туннель — системный `ssh` с ключом по умолчанию
(`~/.ssh`). Публичные части — в `authorized_keys` пользователя `tunnel` на VPS.
- Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`), - Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`),
`pi/` (app + туннель + бэкапы в Docker), образ туннеля — `deploy/tunnel/`. `pi/` (app + туннель в Docker), образ туннеля — `deploy/tunnel/`. Ключ — `deploy/tunnel/id_tunnel`.
## Бэкапы
Контейнер `backup` (restic) в `docker-compose.yml` каждую ночь делает зашифрованный снимок
БД, `uploads` и `achievements` — на Pi (том `backup-data`) и на VPS по SFTP. С ПК снимки
скачиваются со сверкой sha256 (`scripts/fs-backup.ps1 pull`).
Настройка, восстановление и действия при гибели Pi — [`deploy/backup/README.md`](deploy/backup/README.md).
## Дополнения и фракции ## Дополнения и фракции
@@ -257,6 +213,4 @@ docker compose up -d # pull_policy: always — тянет свежие
| Forsaken Voids | Инквизиция, Сёстры битвы, Друкхари, Адептус Механикус | | Forsaken Voids | Инквизиция, Сёстры битвы, Друкхари, Адептус Механикус |
Группа отмечает имеющиеся дополнения; при создании партии доступны только фракции Группа отмечает имеющиеся дополнения; при создании партии доступны только фракции
этих дополнений (база — всегда). Справочник сидится из `backend/app/seed/reference_data.py`. этих дополнений (база — всегда).
Админ может переименовать фракцию в панели, но сейчас переименование откатывается при
каждом перезапуске приложения (задача #72).
@@ -1,60 +0,0 @@
"""Приглашения в группу: таблица group_invitations.
Идемпотентна (как 0005): на свежей БД таблицу создаёт 0001 (create_all из актуальных
моделей); на существующей — create_table здесь.
Revision ID: 0006_group_invitations
Revises: 0005_achievements
Create Date: 2026-06-17
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0006_group_invitations"
down_revision: Union[str, None] = "0005_achievements"
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)
if "group_invitations" in insp.get_table_names():
return
op.create_table(
"group_invitations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"group_id",
sa.Integer(),
sa.ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"invited_by_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_invitation"),
)
op.create_index("ix_group_invitations_group_id", "group_invitations", ["group_id"])
op.create_index("ix_group_invitations_user_id", "group_invitations", ["user_id"])
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "group_invitations" in insp.get_table_names():
op.drop_table("group_invitations")
@@ -1,56 +0,0 @@
"""Медиа партии: таблица match_attachments (фото в общем комментарии).
Идемпотентна (как 0006): на свежей БД таблицу создаёт 0001 (create_all); на существующей —
create_table здесь.
Revision ID: 0007_match_attachments
Revises: 0006_group_invitations
Create Date: 2026-06-17
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0007_match_attachments"
down_revision: Union[str, None] = "0006_group_invitations"
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)
if "match_attachments" in insp.get_table_names():
return
op.create_table(
"match_attachments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"match_id",
sa.Integer(),
sa.ForeignKey("matches.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"uploaded_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("kind", sa.String(16), nullable=False, server_default="photo"),
sa.Column("storage_path", sa.String(255), nullable=False),
sa.Column("mime_type", sa.String(64), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_match_attachments_match_id", "match_attachments", ["match_id"])
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "match_attachments" in insp.get_table_names():
op.drop_table("match_attachments")
@@ -1,55 +0,0 @@
"""Уведомления: таблица notifications (персистентные уведомления игроку).
Идемпотентна (как 0006/0007): на свежей БД таблицу создаёт 0001 (create_all); на существующей —
create_table здесь.
Revision ID: 0008_notifications
Revises: 0007_match_attachments
Create Date: 2026-06-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0008_notifications"
down_revision: Union[str, None] = "0007_match_attachments"
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)
if "notifications" in insp.get_table_names():
return
op.create_table(
"notifications",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("type", sa.String(32), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("body", sa.Text(), nullable=True),
sa.Column("link", sa.String(255), nullable=True),
sa.Column("read_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_notifications_user_id", "notifications", ["user_id"])
op.create_index("ix_notifications_created_at", "notifications", ["created_at"])
op.create_index(
"ix_notifications_user_created", "notifications", ["user_id", "created_at"]
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "notifications" in insp.get_table_names():
op.drop_table("notifications")
@@ -1,40 +0,0 @@
"""Участник партии: признак выбытия (eliminated).
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) → no-op;
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
Revision ID: 0009_participant_eliminated
Revises: 0008_notifications
Create Date: 2026-06-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0009_participant_eliminated"
down_revision: Union[str, None] = "0008_notifications"
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)
cols = {c["name"] for c in insp.get_columns("match_participants")}
if "eliminated" not in cols:
with op.batch_alter_table("match_participants") as b:
b.add_column(
sa.Column("eliminated", sa.Boolean(), nullable=False, server_default="0")
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("match_participants")}
if "eliminated" in cols:
with op.batch_alter_table("match_participants") as b:
b.drop_column("eliminated")
@@ -1,46 +0,0 @@
"""Пользователь: любимая фракция (личный выбор в профиле).
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op;
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
Revision ID: 0010_user_favorite_faction
Revises: 0009_participant_eliminated
Create Date: 2026-09-07
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0010_user_favorite_faction"
down_revision: Union[str, None] = "0009_participant_eliminated"
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)
cols = {c["name"] for c in insp.get_columns("users")}
if "favorite_faction_id" not in cols:
with op.batch_alter_table("users") as b:
b.add_column(sa.Column("favorite_faction_id", sa.Integer(), nullable=True))
b.create_foreign_key(
"fk_users_favorite_faction_id",
"factions",
["favorite_faction_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
if "favorite_faction_id" in cols:
with op.batch_alter_table("users") as b:
b.drop_constraint("fk_users_favorite_faction_id", type_="foreignkey")
b.drop_column("favorite_faction_id")
@@ -1,50 +0,0 @@
"""Пользователь: настройки витрины истории партий в профиле.
Идемпотентна: на свежей БД столбцы создаёт 0001 (create_all из актуальных моделей) -> no-op;
на существующей БД добавляет столбцы. render_as_batch включён в env.py (для SQLite).
Revision ID: 0011_user_history_prefs
Revises: 0010_user_favorite_faction
Create Date: 2026-09-07
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0011_user_history_prefs"
down_revision: Union[str, None] = "0010_user_favorite_faction"
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)
cols = {c["name"] for c in insp.get_columns("users")}
with op.batch_alter_table("users") as b:
if "history_mode" not in cols:
b.add_column(
sa.Column(
"history_mode", sa.String(8), nullable=False, server_default="all"
)
)
if "history_detail" not in cols:
b.add_column(
sa.Column(
"history_detail", sa.String(8), nullable=False, server_default="compact"
)
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
with op.batch_alter_table("users") as b:
if "history_detail" in cols:
b.drop_column("history_detail")
if "history_mode" in cols:
b.drop_column("history_mode")
@@ -1,51 +0,0 @@
"""Партия: общий черновик формы завершения (совместное заполнение результатов).
Идемпотентна: на свежей БД таблицу создаёт 0001 (create_all из актуальных моделей) -> no-op;
на существующей БД создаёт таблицу.
Revision ID: 0012_match_finish_draft
Revises: 0011_user_history_prefs
Create Date: 2026-09-09
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0012_match_finish_draft"
down_revision: Union[str, None] = "0011_user_history_prefs"
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)
if "match_finish_drafts" in insp.get_table_names():
return
op.create_table(
"match_finish_drafts",
sa.Column(
"match_id",
sa.Integer(),
sa.ForeignKey("matches.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("data", sa.JSON(), nullable=False),
sa.Column(
"updated_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "match_finish_drafts" in insp.get_table_names():
op.drop_table("match_finish_drafts")
@@ -1,44 +0,0 @@
"""Пользователь: версия сессий (token_version) для отзыва JWT.
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op;
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
Инкремент token_version отзывает все ранее выданные токены пользователя (см. auth/deps, #57).
Revision ID: 0013_user_token_version
Revises: 0012_match_finish_draft
Create Date: 2026-09-13
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0013_user_token_version"
down_revision: Union[str, None] = "0012_match_finish_draft"
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)
cols = {c["name"] for c in insp.get_columns("users")}
with op.batch_alter_table("users") as b:
if "token_version" not in cols:
b.add_column(
sa.Column(
"token_version", sa.Integer(), nullable=False, server_default="0"
)
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
with op.batch_alter_table("users") as b:
if "token_version" in cols:
b.drop_column("token_version")
@@ -1,133 +0,0 @@
"""Рейтинг (#23): раунд окончания, цели и миры участников, правило 9 раундов, last_standing.
Идемпотентна: на свежей БД столбцы и новый CHECK создаёт 0001 (create_all из актуальных
моделей) -> меняется только бэкфилл (на пустой БД он ничего не находит); на существующей
БД добавляет столбцы, расширяет CHECK причины победы (если он в БД есть) и проставляет
last_standing.
Столбцы nullable и задним числом не заполняются: NULL — «нет данных», рейтинг
подставляет вместо них типичные значения (docs/rating/rating-system.md, 4.8).
Revision ID: 0014_rating_inputs
Revises: 0013_user_token_version
Create Date: 2026-09-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0014_rating_inputs"
down_revision: Union[str, None] = "0013_user_token_version"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
CK_WIN_REASON = "ck_match_win_reason"
OLD_REASONS = "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')"
NEW_REASONS = (
"win_reason IS NULL OR win_reason IN "
"('objectives','worlds','plastic','resources','last_standing')"
)
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
# Только у партий хотя бы с двумя участниками: партия, из которой dev-удаление аккаунта
# вычеркнуло соперника, тоже имеет «одного невыбывшего», но победой выжившего не была.
BACKFILL_LAST_STANDING = """
UPDATE matches SET win_reason = 'last_standing'
WHERE status = 'finished'
AND (win_reason IS NULL OR win_reason <> 'last_standing')
AND (SELECT COUNT(*) FROM match_participants mp
WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1
AND (SELECT COUNT(*) FROM match_participants mp WHERE mp.match_id = matches.id) >= 2
"""
def _columns(insp, table: str) -> set[str]:
return {c["name"] for c in insp.get_columns(table)}
def _win_reason_check(insp) -> str | None:
for ck in insp.get_check_constraints("matches"):
if ck.get("name") == CK_WIN_REASON:
return ck["sqltext"]
return None
def _recreate_matches_with_check(bind, drop_existing: bool, sqltext: str) -> None:
"""Замена CHECK в SQLite = пересоздание таблицы matches (batch copy-and-move).
При включённых внешних ключах DROP старой таблицы выполнил бы неявный DELETE, и
ON DELETE CASCADE унёс бы участников, вложения и черновики. Alembic из CLI работает
без PRAGMA foreign_keys (её включает только движок приложения), а внутри транзакции
PRAGMA не переключить — поэтому не рискуем и отказываемся с понятной ошибкой."""
if bind.exec_driver_sql("PRAGMA foreign_keys").scalar():
raise RuntimeError(
"0014: PRAGMA foreign_keys=ON — пересоздание matches удалило бы участников "
"каскадом. Запускайте миграции через `alembic upgrade head` (CLI)."
)
with op.batch_alter_table("matches", recreate="always") as b:
if drop_existing:
b.drop_constraint(CK_WIN_REASON, type_="check")
b.create_check_constraint(CK_WIN_REASON, sqltext)
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "nine_rounds_rule" not in _columns(insp, "groups"):
with op.batch_alter_table("groups") as b:
b.add_column(
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
)
match_cols = _columns(insp, "matches")
with op.batch_alter_table("matches") as b:
if "end_round" not in match_cols:
b.add_column(sa.Column("end_round", sa.Integer(), nullable=True))
if "nine_rounds_rule" not in match_cols:
b.add_column(
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
)
participant_cols = _columns(insp, "match_participants")
with op.batch_alter_table("match_participants") as b:
if "objectives" not in participant_cols:
b.add_column(sa.Column("objectives", sa.Integer(), nullable=True))
if "worlds" not in participant_cols:
b.add_column(sa.Column("worlds", sa.Integer(), nullable=True))
# БД, созданные до появления CHECK в моделях (0001 тогда был старше), ограничения
# не имеют вовсе — last_standing им и так разрешён, пересоздавать таблицу незачем.
check = _win_reason_check(inspect(bind))
if check is not None and "last_standing" not in check:
_recreate_matches_with_check(bind, drop_existing=True, sqltext=NEW_REASONS)
op.execute(BACKFILL_LAST_STANDING)
def downgrade() -> None:
bind = op.get_bind()
# Прежний CHECK не знает last_standing: такие партии теряют признак (раньше их
# записывали «по целям»).
op.execute("UPDATE matches SET win_reason = 'objectives' WHERE win_reason = 'last_standing'")
check = _win_reason_check(inspect(bind))
if check is not None and "last_standing" in check:
_recreate_matches_with_check(bind, drop_existing=True, sqltext=OLD_REASONS)
insp = inspect(bind)
participant_cols = _columns(insp, "match_participants")
with op.batch_alter_table("match_participants") as b:
for name in ("worlds", "objectives"):
if name in participant_cols:
b.drop_column(name)
match_cols = _columns(insp, "matches")
with op.batch_alter_table("matches") as b:
for name in ("nine_rounds_rule", "end_round"):
if name in match_cols:
b.drop_column(name)
if "nine_rounds_rule" in _columns(insp, "groups"):
with op.batch_alter_table("groups") as b:
b.drop_column("nine_rounds_rule")
@@ -1,80 +0,0 @@
"""Объявления администрации (#84): сами объявления и отметки «игрок закрыл».
Идемпотентна: на свежей БД таблицы создаёт 0001 (create_all из актуальных моделей) -> no-op;
на существующей БД создаёт недостающие таблицы.
Отходит от 0013, а не от 0014: объявления выпущены в main раньше рейтинга. Ветки рейтинга
(0014) и объявлений (0015) сводит пустая миграция 0016.
Revision ID: 0015_announcements
Revises: 0013_user_token_version
Create Date: 2026-09-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0015_announcements"
down_revision: Union[str, None] = "0013_user_token_version"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
tables = set(inspect(bind).get_table_names())
if "announcements" not in tables:
op.create_table(
"announcements",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(64), nullable=False),
sa.Column("body_html", sa.Text(), nullable=False),
sa.Column("starts_at", sa.DateTime(), nullable=False),
sa.Column("ends_at", sa.DateTime(), nullable=False),
sa.Column("show_to_new_players", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("revision", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"created_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.CheckConstraint("ends_at > starts_at", name="ck_announcement_period"),
)
op.create_index("ix_announcements_period", "announcements", ["starts_at", "ends_at"])
if "announcement_views" not in tables:
op.create_table(
"announcement_views",
sa.Column(
"announcement_id",
sa.Integer(),
sa.ForeignKey("announcements.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("closed_at", sa.DateTime(), nullable=False),
)
op.create_index(
"ix_announcement_views_user_id", "announcement_views", ["user_id"]
)
def downgrade() -> None:
tables = set(inspect(op.get_bind()).get_table_names())
if "announcement_views" in tables:
op.drop_table("announcement_views")
if "announcements" in tables:
op.drop_table("announcements")
@@ -1,25 +0,0 @@
"""Слияние веток миграций: рейтинг (0014) и объявления (0015).
Объявления (#84) вышли в main раньше рейтинга, поэтому 0015 отходит от 0013, а не от 0014,
и у графа миграций две ветки. Эта миграция сводит их в одну голову и сама ничего не меняет:
прод, стоящий на 0015 (релиз без рейтинга), при переходе на эту версию получит 0014 и её;
база на 0014 (dev) — 0015 и её.
Revision ID: 0016_merge_rating_announcements
Revises: 0014_rating_inputs, 0015_announcements
Create Date: 2026-09-18
"""
from typing import Sequence, Union
revision: str = "0016_merge_rating_announcements"
down_revision: Union[str, Sequence[str], None] = ("0014_rating_inputs", "0015_announcements")
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
-50
View File
@@ -1,50 +0,0 @@
"""Вход администратора под защитой от перебора (#56).
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару,
20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики,
включая IP.
"""
from __future__ import annotations
from sqlmodel import Session
from app.core.errors import InvalidCredentialsError
from app.core.ratelimit import login_throttle
from app.models import User
from app.services import admin_service
# Неудач за окно LoginThrottle (15 минут):
_PAIR_LIMIT = 5 # на пару «IP + логин» — против перебора пароля с одного адреса
_IP_LIMIT = 20 # на IP — против перебора по разным логинам с одного адреса
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
# упирается в этот предел. Щедрее пары, чтобы случайный поток ошибок не запирал вход
# админа насовсем (лимит на аккаунт — вектор lockout-DoS, поэтому не слишком строгий).
_ACCOUNT_LIMIT = 50
def _keys(ip: str, username: str) -> dict[str, int]:
pair_key = f"admin-login:{ip}:{username.casefold()}"
return {
pair_key: _PAIR_LIMIT,
f"admin-login-ip:{ip}": _IP_LIMIT,
f"admin-login-user:{username.casefold()}": _ACCOUNT_LIMIT,
}
def login_admin(session: Session, username: str, password: str, ip: str | None) -> User:
"""authenticate_admin под защитой от перебора. Сессию открывает вызывающий."""
username = (username or "").strip()
ip = ip or "unknown"
limits = _keys(ip, username)
login_throttle.check(limits)
try:
admin = admin_service.authenticate_admin(session, username, password)
except InvalidCredentialsError:
login_throttle.fail(limits)
raise
# Успех: снимаем счётчики этого аккаунта, чтобы законный вход не копил лимит.
for key in limits:
login_throttle.reset(key)
return admin
-9
View File
@@ -21,14 +21,9 @@ def get_current_user(
payload = security.decode_token(token, security.AUDIENCE_USER) payload = security.decode_token(token, security.AUDIENCE_USER)
except jwt.PyJWTError as exc: # noqa: F841 except jwt.PyJWTError as exc: # noqa: F841
raise AuthError("Сессия недействительна.") raise AuthError("Сессия недействительна.")
if security.is_session_revoked(payload):
raise AuthError("Сессия недействительна.")
user = session.get(User, int(payload["sub"])) user = session.get(User, int(payload["sub"]))
if user is None or not user.is_active: if user is None or not user.is_active:
raise AuthError("Сессия недействительна.") raise AuthError("Сессия недействительна.")
if int(payload.get("ver", 0)) != int(user.token_version or 0):
# Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57).
raise AuthError("Сессия недействительна.")
return user return user
@@ -42,11 +37,7 @@ def get_current_admin(
payload = security.decode_token(token, security.AUDIENCE_ADMIN) payload = security.decode_token(token, security.AUDIENCE_ADMIN)
except jwt.PyJWTError: except jwt.PyJWTError:
raise AuthError("Сессия администратора недействительна.") raise AuthError("Сессия администратора недействительна.")
if security.is_session_revoked(payload):
raise AuthError("Сессия администратора недействительна.")
user = session.get(User, int(payload["sub"])) user = session.get(User, int(payload["sub"]))
if user is None or user.role != "admin" or not user.is_active: if user is None or user.role != "admin" or not user.is_active:
raise ForbiddenError("Доступ только для администратора.") raise ForbiddenError("Доступ только для администратора.")
if int(payload.get("ver", 0)) != int(user.token_version or 0):
raise AuthError("Сессия администратора недействительна.")
return user return user
+1 -1
View File
@@ -1,4 +1,4 @@
"""Dev-провайдер: вход без секрета по нику/идентификатору (только development).""" """Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
+4 -5
View File
@@ -1,7 +1,7 @@
"""Общий вход: внешняя личность → пользователь → сессия. """Общий вход: внешняя личность → пользователь → сессия.
Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
Telegram, /auth/login, /auth/register и dev-вход. и dev-входом.
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,7 +10,6 @@ from sqlmodel import Session
from app.auth.provider import ExternalIdentity from app.auth.provider import ExternalIdentity
from app.core import security from app.core import security
from app.core.security import client_ip
from app.core.errors import ForbiddenError from app.core.errors import ForbiddenError
from app.models import User from app.models import User
from app.services import audit_service, user_service from app.services import audit_service, user_service
@@ -22,7 +21,7 @@ def establish_session(
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит).""" """Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
if not user.is_active: if not user.is_active:
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED") raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
security.set_user_session(response, user.id, provider, user.token_version) # type: ignore[arg-type] security.set_user_session(response, user.id, provider) # type: ignore[arg-type]
audit_service.record( audit_service.record(
session, session,
actor_id=user.id, actor_id=user.id,
@@ -30,7 +29,7 @@ def establish_session(
entity_type="user", entity_type="user",
entity_id=user.id, entity_id=user.id,
payload={"provider": provider}, payload={"provider": provider},
ip=client_ip(request), ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
-113
View File
@@ -1,113 +0,0 @@
"""Вход игрока по логину (нику) и паролю.
Прод-модуль: основной способ входа во всех окружениях. НЕ путать с dev-stub (вход по нику
без пароля) — тот живёт в dev_stub.py и в прод-образ не попадает. Сюда dev-код не импортировать.
"""
from __future__ import annotations
from functools import lru_cache
from sqlmodel import Session, select
from app.core.errors import (
InvalidCredentialsError,
ValidationError,
WrongCurrentPasswordError,
)
from app.core.ratelimit import login_throttle
from app.core.security import hash_password, verify_password
from app.models import User
PASSWORD_MIN_CHARS = 8
# bcrypt учитывает только первые 72 байта, а bcrypt 5 на более длинном пароле бросает
# ValueError — ограничиваем явно, с понятным сообщением.
_BCRYPT_MAX_BYTES = 72
# Лимиты неудач за окно LoginThrottle (15 минут): на пару «IP + логин» — против перебора
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
_PAIR_LIMIT = 5
_IP_LIMIT = 20
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
# упирается в этот предел. Щедрее пары, чтобы поток ошибок с разных адресов не запирал
# вход настоящему владельцу (лимит на аккаунт — вектор lockout-DoS, потому не строгий);
# успешный вход его сбрасывает.
_ACCOUNT_LIMIT = 50
# Неверный текущий пароль при смене — на аккаунт.
_CURRENT_PASSWORD_LIMIT = 5
# Регистраций с одного IP за окно — против спама аккаунтов (#62).
_REGISTER_IP_LIMIT = 10
def validate_new_password(password: str) -> None:
if len(password) < PASSWORD_MIN_CHARS:
raise ValidationError(f"Пароль: не короче {PASSWORD_MIN_CHARS} символов.")
if not password.strip():
raise ValidationError("Пароль не может состоять из одних пробелов.")
if len(password.encode("utf-8")) > _BCRYPT_MAX_BYTES:
raise ValidationError(
"Пароль слишком длинный: до 72 байт (72 латинских или 36 русских букв)."
)
@lru_cache
def _dummy_hash() -> str:
return hash_password("dummy-password-for-timing")
def authenticate_player(session: Session, nickname: str, password: str) -> User:
"""Игрок по нику и паролю. Админ так не входит: у него отдельный вход и cookie.
Неизвестный логин и аккаунт без пароля сверяются с фиктивным хешем: ответ занимает
столько же, сколько неверный пароль, и по времени нельзя узнать, есть ли такой логин."""
user = session.exec(
select(User).where(User.nickname == nickname, User.role == "player")
).first()
stored = user.password_hash if user is not None else None
valid = verify_password(password, stored or _dummy_hash())
if user is None or not stored or not valid:
raise InvalidCredentialsError()
return user
def login_player(session: Session, nickname: str, password: str, ip: str | None) -> User:
"""authenticate_player под защитой от перебора. Сессию открывает вызывающий."""
nickname = (nickname or "").strip()
ip = ip or "unknown"
pair_key = f"login:{ip}:{nickname.casefold()}"
account_key = f"login-user:{nickname.casefold()}"
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT, account_key: _ACCOUNT_LIMIT}
login_throttle.check(limits)
try:
user = authenticate_player(session, nickname, password)
except InvalidCredentialsError:
login_throttle.fail(limits)
raise
# Успех снимает счётчики этого аккаунта (пара IP+логин и лимит на аккаунт); лимит по IP
# оставляем — он общий для всех логинов с адреса.
login_throttle.reset(pair_key)
login_throttle.reset(account_key)
return user
def throttle_register(ip: str | None) -> None:
"""Ограничивает частоту регистраций с одного IP (спам аккаунтов, #62).
Считаем каждую попытку (и успешную, и нет), поэтому массовое создание аккаунтов
с уникальными никами упирается в предел так же, как повторы по занятому нику.
Enumeration ников через 409 NICKNAME_TAKEN не закрываем: ники и так публичны в топе."""
ip = ip or "unknown"
limits = {f"register-ip:{ip}": _REGISTER_IP_LIMIT}
login_throttle.check(limits)
login_throttle.fail(limits)
def check_current_password(user: User, current_password: str | None) -> None:
"""Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя:
иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем."""
key = f"current-password:{user.id}"
limits = {key: _CURRENT_PASSWORD_LIMIT}
login_throttle.check(limits)
if not current_password or not verify_password(current_password, user.password_hash or ""):
login_throttle.fail(limits)
raise WrongCurrentPasswordError()
login_throttle.reset(key)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Доступные методы входа по окружению. """Доступные методы входа по окружению.
Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development. Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG).
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода. Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,7 +9,7 @@ from app.core.config import settings
def enabled_methods() -> list[str]: def enabled_methods() -> list[str]:
methods = ["password", "telegram"] methods = ["telegram"]
if settings.is_development: if settings.is_development:
methods.append("stub") methods.append("stub")
return methods return methods
+1 -1
View File
@@ -2,7 +2,7 @@
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте). auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
Доступен во всех окружениях — наряду со входом по логину и паролю. Доступен и в dev, и в prod (в prod — единственный метод входа).
""" """
from __future__ import annotations from __future__ import annotations
+2 -45
View File
@@ -1,12 +1,9 @@
"""Идемпотентный бутстрап: справочники + учётная запись администратора. """Идемпотентный бутстрап: справочники + учётная запись администратора.
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций). Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73).
""" """
from __future__ import annotations from __future__ import annotations
import argparse
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.config import settings from app.core.config import settings
@@ -14,7 +11,6 @@ from app.core.security import hash_password, verify_password
from app.db.session import engine from app.db.session import engine
from app.models import User from app.models import User
from app.seed.reference_data import seed_reference_data from app.seed.reference_data import seed_reference_data
from app.services import user_service
def _ensure_admin(session: Session) -> None: def _ensure_admin(session: Session) -> None:
@@ -42,9 +38,7 @@ def _ensure_admin(session: Session) -> None:
# Администратор уже существует. # Администратор уже существует.
if not settings.is_development: if not settings.is_development:
# В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама # В test/prod пароль НЕ перезаписываем (мог быть изменён через панель).
# по себе ничего не делает — применить её можно только осознанно, командой
# ротации (reset_admin_password), которая заодно отзывает админские сессии.
return return
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве). # DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
@@ -61,48 +55,11 @@ def _ensure_admin(session: Session) -> None:
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})") print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
def reset_admin_password(session: Session) -> User:
"""Записать существующему админу пароль из ADMIN_PASSWORD (#73) — в любом окружении.
Через панель пароль админа не меняется, а обычный старт в prod его из .env не берёт:
это единственный штатный способ, и он требует доступа к серверу. set_password
проверяет пароль и увеличивает token_version — все выданные админские сессии
(в том числе чужая, если пароль утёк) перестают действовать. Логин не трогаем."""
if not settings.admin_bootstrap_enabled:
raise RuntimeError(
"ADMIN_BOOTSTRAP_ENABLED=false — администратором управляют вручную, ротация отключена."
)
password = (settings.admin_password or "").strip()
if not password:
raise RuntimeError("ADMIN_PASSWORD пуст — нечего применять.")
admin = session.exec(select(User).where(User.role == "admin")).first()
if admin is None:
raise RuntimeError("Администратора ещё нет — запустите обычный bootstrap, он его создаст.")
return user_service.set_password(session, admin, password)
def bootstrap() -> None: def bootstrap() -> None:
with Session(engine) as session: with Session(engine) as session:
seed_reference_data(session) # идемпотентно seed_reference_data(session) # идемпотентно
_ensure_admin(session) _ensure_admin(session)
def main(argv: list[str] | None = None) -> None:
ap = argparse.ArgumentParser(prog="python -m app.bootstrap", description=__doc__)
ap.add_argument(
"--reset-admin-password",
action="store_true",
help="применить ADMIN_PASSWORD из .env к существующему админу и завершить его сессии",
)
args = ap.parse_args(argv)
if not args.reset_admin_password:
bootstrap()
return
with Session(engine) as session:
admin = reset_admin_password(session)
print(f"[bootstrap] Пароль администратора {admin.nickname} обновлён из .env; "
"все его сессии завершены.")
if __name__ == "__main__": if __name__ == "__main__":
main() bootstrap()
+19 -83
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково # Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
@@ -12,15 +11,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# (исключён из образа) — там настройки приходят переменными от docker compose. # (исключён из образа) — там настройки приходят переменными от docker compose.
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env") _ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
# Небезопасные значения по умолчанию (годятся только для dev на localhost). Опубликованное
# приложение с ними не стартует — см. валидатор _forbid_default_secrets_when_published.
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
_MIN_SECRET_KEY_LENGTH = 32
# Допустимые окружения. Отдельного test-контура нет: всё проверяется в development.
_APP_ENVS = ("development", "production")
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
@@ -30,21 +20,20 @@ class Settings(BaseSettings):
case_sensitive=False, case_sensitive=False,
) )
# ── Главный переключатель окружения: development | production ───────────── # ── Главный переключатель окружения: development | test | production ───────
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, есть stub-вход по нику. # development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
# test — прод-клон в Docker локально (порт 8080), ведёт себя как прод.
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env. # production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
app_env: str = "development" app_env: str = "development"
log_level: str = "INFO" log_level: str = "INFO"
# Публикация локального dev-окружения наружу через VPS-туннель. # Публикация локального окружения (dev/test) наружу через VPS-туннель.
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель # Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
# на forbidden-stars.ru. Приложению значение говорит, опубликовано ли оно (is_published): # на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
# от этого зависят Secure-cookie и проверка секретов при старте.
local_public: str = "local" local_public: str = "local"
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда # Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
# в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени # Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
# на фронте — оно приходит туда в GET /api/auth/config (#68).
app_tz_offset_hours: int = 3 app_tz_offset_hours: int = 3
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ───── # ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
@@ -61,15 +50,15 @@ class Settings(BaseSettings):
prod_achievements_dir: str = "/data/achievements" prod_achievements_dir: str = "/data/achievements"
# JWT / cookie # JWT / cookie
secret_key: str = _DEFAULT_SECRET_KEY secret_key: str = "change-me-dev-secret-not-for-production"
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
jwt_user_ttl_minutes: int = 60 * 24 * 7 jwt_user_ttl_minutes: int = 60 * 24 * 7
jwt_admin_ttl_minutes: int = 60 * 8 jwt_admin_ttl_minutes: int = 60 * 8
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже). # cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
cookie_domain: str | None = None cookie_domain: str | None = None
# Аутентификация. Методы входа определяются окружением (auth/registry.py): везде # Аутентификация. Методы входа определяются окружением (dev: telegram+stub,
# логин/пароль + Telegram, в development ещё stub — отдельного переключателя нет. # prod: только telegram) — отдельного переключателя провайдера нет.
telegram_bot_token: str | None = None telegram_bot_token: str | None = None
telegram_bot_username: str | None = None telegram_bot_username: str | None = None
public_base_url: str | None = None public_base_url: str | None = None
@@ -77,7 +66,7 @@ class Settings(BaseSettings):
# Бутстрап администратора # Бутстрап администратора
admin_bootstrap_enabled: bool = True admin_bootstrap_enabled: bool = True
admin_username: str = "admin" admin_username: str = "admin"
admin_password: str = _DEFAULT_ADMIN_PASSWORD admin_password: str = "change-me-admin-password"
admin_nickname: str = "Администратор" admin_nickname: str = "Администратор"
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую — # CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
@@ -95,13 +84,17 @@ class Settings(BaseSettings):
стартовый bootstrap в lifespan и синхронизацию админа из .env.""" стартовый bootstrap в lifespan и синхронизацию админа из .env."""
return self.app_env.lower() == "development" return self.app_env.lower() == "development"
@property
def is_test(self) -> bool:
return self.app_env.lower() == "test"
@property @property
def is_production(self) -> bool: def is_production(self) -> bool:
return self.app_env.lower() == "production" return self.app_env.lower() == "production"
@property @property
def database_url(self) -> str: def database_url(self) -> str:
"""БД: dev — отдельный файл дева; prod — том контейнера (/data).""" """БД: dev — отдельный файл дева; test и prod — том контейнера (/data)."""
return self.dev_database_url if self.is_development else self.prod_database_url return self.dev_database_url if self.is_development else self.prod_database_url
@property @property
@@ -112,73 +105,16 @@ class Settings(BaseSettings):
def achievements_dir(self) -> str: def achievements_dir(self) -> str:
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
@property
def is_published(self) -> bool:
"""Приложение доступно снаружи по домену: production или dev, выставленный через
VPS-туннель. Не опубликован только нативный dev на localhost
(development + local_public=local)."""
return not (self.is_development and self.local_public.lower() == "local")
@property @property
def cookie_secure(self) -> bool: def cookie_secure(self) -> bool:
"""Secure-cookie нужен везде, где снаружи HTTPS (домен), — у опубликованного """Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
приложения. На localhost по HTTP браузер Secure-cookie не вернул бы.""" нативный dev на localhost по HTTP (development + local_public=local)."""
return self.is_published return not (self.is_development and self.local_public.lower() == "local")
@property @property
def cookie_domain_value(self) -> str | None: def cookie_domain_value(self) -> str | None:
return self.cookie_domain or None return self.cookie_domain or None
@field_validator("app_env")
@classmethod
def _known_app_env(cls, value: str) -> str:
"""Неизвестное окружение — ошибка старта, а не молчаливое «почти прод»: любое
значение, кроме development, выбирает прод-пути к данным и выключает dev-вход."""
if value.lower() not in _APP_ENVS:
raise ValueError(
f"APP_ENV={value!r} не поддерживается — допустимо: {', '.join(_APP_ENVS)}"
)
return value
@field_validator("app_tz_offset_hours")
@classmethod
def _known_tz_offset(cls, value: int) -> int:
"""Реальные пояса — от −12 до +14: опечатка в .env — ошибка старта, а не время,
сдвинутое на сутки."""
if not -12 <= value <= 14:
raise ValueError(f"APP_TZ_OFFSET_HOURS={value} вне диапазона −12..14")
return value
@model_validator(mode="after")
def _forbid_default_secrets_when_published(self) -> "Settings":
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
секретами (#59, #69) — и прод, и dev, выставленный на домен (LOCAL_PUBLIC=vps).
Иначе снаружи оказались бы общеизвестный ключ подписи JWT (подделка любого токена,
включая админский) и известный пароль администратора. На localhost дефолты — норма."""
if not self.is_published:
return self
problems: list[str] = []
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
problems.append(
f"SECRET_KEY не задан, дефолтный или короче {_MIN_SECRET_KEY_LENGTH} символов"
)
if self.admin_bootstrap_enabled:
password = (self.admin_password or "").strip()
if not password or password == _DEFAULT_ADMIN_PASSWORD:
problems.append("ADMIN_PASSWORD не задан или дефолтный")
if problems:
where = (
"production"
if self.is_production
else f"dev, опубликованного наружу (LOCAL_PUBLIC={self.local_public})"
)
raise ValueError(
f"Небезопасная конфигурация {where} — задайте секреты в .env: "
+ "; ".join(problems)
)
return self
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
-36
View File
@@ -136,41 +136,5 @@ class InvalidCredentialsError(AuthError):
super().__init__("Неверный логин или пароль.") super().__init__("Неверный логин или пароль.")
class TooManyAttemptsError(AppError):
"""Слишком много неудачных попыток ввода пароля. retry_after — через сколько секунд
ближайшая попытка снова будет принята."""
status_code = 429
code = "TOO_MANY_ATTEMPTS"
def __init__(self, retry_after: int) -> None:
minutes = max(1, -(-retry_after // 60))
super().__init__(
f"Слишком много неудачных попыток. Повторите через {minutes} мин.",
details={"retry_after": retry_after},
)
class WrongCurrentPasswordError(ForbiddenError):
code = "WRONG_CURRENT_PASSWORD"
def __init__(self) -> None:
super().__init__("Текущий пароль неверен.")
class TelegramAlreadyLinkedError(ConflictError):
code = "TELEGRAM_ALREADY_LINKED"
def __init__(self) -> None:
super().__init__("К аккаунту уже привязан Telegram.")
class TelegramTakenError(ConflictError):
code = "TELEGRAM_TAKEN"
def __init__(self) -> None:
super().__init__("Этот Telegram уже привязан к другому аккаунту.")
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse: async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
return exc.to_response() return exc.to_response()
-59
View File
@@ -1,59 +0,0 @@
"""In-memory SSE-шина (pub/sub) для пушей «данные изменились».
Рассчитана на один процесс (uvicorn `--workers 1`). При переходе на несколько воркеров
шину нужно вынести во внешний брокер (например, Redis pub/sub).
Публикация вызывается из СИНХРОННЫХ роутеров (FastAPI выполняет их в threadpool), а очереди
подписчиков живут в event-loop'е — поэтому публикация перекидывается в loop через
`call_soon_threadsafe`, а весь доступ к подпискам происходит на потоке loop'а.
"""
from __future__ import annotations
import asyncio
from collections import defaultdict
from typing import Any, Iterable
class EventHub:
def __init__(self) -> None:
self._subs: dict[int, set[asyncio.Queue]] = defaultdict(set)
self._loop: asyncio.AbstractEventLoop | None = None
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop
def subscribe(self, user_id: int) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
self._subs[user_id].add(queue)
return queue
def unsubscribe(self, user_id: int, queue: asyncio.Queue) -> None:
subs = self._subs.get(user_id)
if subs is not None:
subs.discard(queue)
if not subs:
self._subs.pop(user_id, None)
def publish(self, user_ids: Iterable[int], event: dict[str, Any]) -> None:
"""Доставить событие подписчикам с указанными user_id (потокобезопасно)."""
loop = self._loop
if loop is None or loop.is_closed():
return
ids = [uid for uid in user_ids if uid is not None]
if ids:
try:
loop.call_soon_threadsafe(self._fanout, ids, event)
except RuntimeError:
pass # loop закрылся между проверкой и вызовом (напр. при shutdown)
def _fanout(self, ids: list[int], event: dict[str, Any]) -> None:
# Выполняется на потоке loop'а — доступ к _subs безопасен.
for uid in ids:
for queue in list(self._subs.get(uid, ())):
try:
queue.put_nowait(event)
except asyncio.QueueFull:
pass # медленный клиент — пропускаем (догонит при reconnect/focus)
hub = EventHub()
-79
View File
@@ -1,79 +0,0 @@
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn
(`--workers 1`, см. entrypoint.sh/run.*). Перезапуск их обнуляет; для окна в 15 минут это
приемлемо. Ограничения устойчивости (#60):
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
перезапуска, но окно короткое, а рестарты редки;
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в core/events.py
про SSE-шину), общий для всех воркеров.
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
(#58) не снимала защиту полностью.
"""
from __future__ import annotations
import threading
import time
from collections import deque
from app.core.errors import TooManyAttemptsError
_WINDOW_SECONDS = 15 * 60
# Выше этого числа ключей при записи неудачи вычищаем протухшие, чтобы поток попыток
# с разных адресов не копил память бесконечно.
_PRUNE_ABOVE = 10_000
class LoginThrottle:
"""Скользящее окно неудач по произвольным ключам (IP, пара «IP + логин», id игрока).
Роуты синхронные и выполняются в пуле потоков, поэтому доступ под замком."""
def __init__(self, window_seconds: int = _WINDOW_SECONDS) -> None:
self.window = window_seconds
self._fails: dict[str, deque[float]] = {}
self._lock = threading.Lock()
def _recent(self, key: str, now: float) -> deque[float]:
attempts = self._fails.get(key)
if attempts is None:
return deque()
while attempts and now - attempts[0] >= self.window:
attempts.popleft()
if not attempts:
del self._fails[key]
return attempts
def check(self, limits: dict[str, int]) -> None:
"""Бросает TooManyAttemptsError, если хотя бы по одному ключу лимит исчерпан."""
now = time.monotonic()
with self._lock:
for key, limit in limits.items():
attempts = self._recent(key, now)
if len(attempts) >= limit:
# Попытка снова примется, когда из окна выпадет неудача, после которой
# в нём остаётся limit-1 записей.
frees_at = attempts[len(attempts) - limit] + self.window
raise TooManyAttemptsError(retry_after=int(frees_at - now) + 1)
def fail(self, keys: dict[str, int]) -> None:
now = time.monotonic()
with self._lock:
if len(self._fails) > _PRUNE_ABOVE:
for key in list(self._fails):
self._recent(key, now)
for key in keys:
self._fails.setdefault(key, deque()).append(now)
def reset(self, key: str) -> None:
with self._lock:
self._fails.pop(key, None)
def clear(self) -> None:
with self._lock:
self._fails.clear()
login_throttle = LoginThrottle()
+10 -70
View File
@@ -6,10 +6,9 @@ from datetime import datetime, timedelta, timezone
import bcrypt import bcrypt
import jwt import jwt
from fastapi import Request, Response from fastapi import Response
from app.core.config import settings from app.core.config import settings
from app.core.token_revocation import revoked_tokens
USER_COOKIE = "fs_session" USER_COOKIE = "fs_session"
ADMIN_COOKIE = "fs_admin" ADMIN_COOKIE = "fs_admin"
@@ -38,13 +37,7 @@ def verify_password(password: str, password_hash: str) -> bool:
# ─── JWT ───────────────────────────────────────────────────────────────────── # ─── JWT ─────────────────────────────────────────────────────────────────────
def create_token( def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str:
subject: str | int,
audience: str,
ttl_minutes: int,
provider: str = "",
token_version: int = 0,
) -> str:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
payload = { payload = {
"sub": str(subject), "sub": str(subject),
@@ -53,9 +46,6 @@ def create_token(
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()), "exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
"jti": secrets.token_hex(8), "jti": secrets.token_hex(8),
"provider": provider, "provider": provider,
# Версия сессий владельца: при её росте (смена/сброс пароля) старые токены
# с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57).
"ver": token_version,
} }
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm) return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
@@ -75,19 +65,12 @@ def generate_csrf_token() -> str:
return secrets.token_urlsafe(24) return secrets.token_urlsafe(24)
def _csrf_max_age() -> int: def _set_csrf_cookie(response: Response, max_age: int) -> str:
"""Токен один на обе сессии, поэтому живёт не меньше самой долгой из них: иначе вход
в админку (8 ч) перезаписывал токен игрока (7 дней), и после его истечения все мутации
игрока падали с CSRF_FAILED. Без сессии токен бесполезен — лишний срок безопасен."""
return max(settings.jwt_user_ttl_minutes, settings.jwt_admin_ttl_minutes) * 60
def _set_csrf_cookie(response: Response) -> str:
csrf = generate_csrf_token() csrf = generate_csrf_token()
response.set_cookie( response.set_cookie(
key=CSRF_COOKIE, key=CSRF_COOKIE,
value=csrf, value=csrf,
max_age=_csrf_max_age(), max_age=max_age,
httponly=False, # должен читаться JS, чтобы продублировать в заголовок httponly=False, # должен читаться JS, чтобы продублировать в заголовок
secure=settings.cookie_secure, secure=settings.cookie_secure,
samesite="lax", samesite="lax",
@@ -97,17 +80,9 @@ def _set_csrf_cookie(response: Response) -> str:
return csrf return csrf
def fresh_csrf_set_cookie() -> tuple[bytes, bytes]: def set_user_session(response: Response, user_id: int, provider: str) -> None:
"""Готовый заголовок Set-Cookie со свежим токеном — для ASGI-middleware, где объекта
Response нет. Строится тем же _set_csrf_cookie, чтобы атрибуты не разъехались."""
carrier = Response()
_set_csrf_cookie(carrier)
return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie")
def set_user_session(response: Response, user_id: int, provider: str, token_version: int = 0) -> None:
ttl = settings.jwt_user_ttl_minutes ttl = settings.jwt_user_ttl_minutes
token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version) token = create_token(user_id, AUDIENCE_USER, ttl, provider)
response.set_cookie( response.set_cookie(
key=USER_COOKIE, key=USER_COOKIE,
value=token, value=token,
@@ -118,12 +93,12 @@ def set_user_session(response: Response, user_id: int, provider: str, token_vers
path=_USER_PATH, path=_USER_PATH,
domain=settings.cookie_domain_value, domain=settings.cookie_domain_value,
) )
_set_csrf_cookie(response) _set_csrf_cookie(response, ttl * 60)
def set_admin_session(response: Response, admin_id: int, token_version: int = 0) -> None: def set_admin_session(response: Response, admin_id: int) -> None:
ttl = settings.jwt_admin_ttl_minutes ttl = settings.jwt_admin_ttl_minutes
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version) token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
response.set_cookie( response.set_cookie(
key=ADMIN_COOKIE, key=ADMIN_COOKIE,
value=token, value=token,
@@ -134,7 +109,7 @@ def set_admin_session(response: Response, admin_id: int, token_version: int = 0)
path=_ADMIN_PATH, path=_ADMIN_PATH,
domain=settings.cookie_domain_value, domain=settings.cookie_domain_value,
) )
_set_csrf_cookie(response) _set_csrf_cookie(response, ttl * 60)
def clear_user_session(response: Response) -> None: def clear_user_session(response: Response) -> None:
@@ -143,38 +118,3 @@ def clear_user_session(response: Response) -> None:
def clear_admin_session(response: Response) -> None: def clear_admin_session(response: Response) -> None:
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value) response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
def revoke_session_token(request: Request, cookie_name: str, audience: str) -> None:
"""Отзывает предъявленный в cookie токен (по `jti`) до его `exp` — точечный logout.
Убивает именно этот токен (украденный/оставленный), не трогая другие устройства.
Некорректный/просроченный токен отзывать нечего — молча выходим."""
token = request.cookies.get(cookie_name)
if not token:
return
try:
payload = jwt.decode(
token,
settings.secret_key,
algorithms=[settings.jwt_algorithm],
audience=audience,
)
except jwt.PyJWTError:
return
exp = payload.get("exp")
if exp is not None:
revoked_tokens.revoke(payload.get("jti"), float(exp))
def is_session_revoked(payload: dict) -> bool:
"""Отозван ли этот токен точечно (через logout)."""
return revoked_tokens.is_revoked(payload.get("jti"))
def client_ip(request: Request) -> str | None:
"""IP клиента для журнала аудита.
Одна точка на всё приложение. Реальный адрес за VPS-привратником уже подставляет
uvicorn (--proxy-headers + --forwarded-allow-ips в entrypoint.sh) — отсюда он и берётся."""
return request.client.host if request.client else None
-53
View File
@@ -1,53 +0,0 @@
"""Точечный отзыв отдельных JWT по `jti` — для logout (выход именно этого токена).
Список живёт в памяти процесса, как throttle и SSE-шина: рассчитан на один воркер uvicorn.
Рестарт очищает список — это приемлемо: записи и так живут лишь до `exp` токена, а на новый
процесс приходят уже свежие cookie. Для «выйти со всех устройств» и отзыва при смене пароля
используется `token_version` у пользователя (см. models.User, auth/deps), а не этот список.
"""
from __future__ import annotations
import threading
import time
# Выше этого числа записей при отзыве вычищаем протухшие, чтобы поток logout'ов не копил память.
_PRUNE_ABOVE = 10_000
class RevokedTokens:
"""Множество отозванных `jti` с временем истечения (unix-время, как `exp` в JWT)."""
def __init__(self) -> None:
self._revoked: dict[str, float] = {}
self._lock = threading.Lock()
def revoke(self, jti: str | None, expires_at: float) -> None:
if not jti:
return
now = time.time()
with self._lock:
if len(self._revoked) > _PRUNE_ABOVE:
for key, exp in list(self._revoked.items()):
if exp <= now:
del self._revoked[key]
self._revoked[jti] = expires_at
def is_revoked(self, jti: str | None) -> bool:
if not jti:
return False
now = time.time()
with self._lock:
exp = self._revoked.get(jti)
if exp is None:
return False
if exp <= now:
del self._revoked[jti] # протухла — заодно вычищаем
return False
return True
def clear(self) -> None:
with self._lock:
self._revoked.clear()
revoked_tokens = RevokedTokens()
+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)
+21 -129
View File
@@ -10,24 +10,12 @@ from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app.core import security from app.core import security
from app.core.config import settings from app.core.config import settings
from app.core.errors import AppError, app_error_handler from app.core.errors import AppError, app_error_handler
from app.routers import ( from app.routers import achievements, admin, auth, groups, matches, reference, stats, users
achievements,
admin,
announcements,
auth,
events,
groups,
invitations,
matches,
notifications,
reference,
stats,
users,
)
# Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать). # Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать).
_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static"))) _STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static")))
@@ -35,55 +23,22 @@ _STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.p
_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} _UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
def _with_fresh_csrf_cookie(send): # noqa: ANN001, ANN202 class CSRFMiddleware(BaseHTTPMiddleware):
"""Дописывает свежий csrf_token в заголовки ответа. Трогает только """Double-submit CSRF: для аутентифицированных мутаций на /api требуем
http.response.start: тело (в т.ч. SSE-поток) проходит насквозь, чанк за чанком.""" совпадения заголовка X-CSRF-Token и cookie csrf_token."""
async def wrapped(message): # noqa: ANN001 async def dispatch(self, request: Request, call_next): # noqa: ANN001
if message["type"] == "http.response.start": path = request.url.path
headers = list(message.get("headers", [])) if request.method in _UNSAFE_METHODS and path.startswith("/api"):
already_set = any(
k.lower() == b"set-cookie" and v.startswith(security.CSRF_COOKIE.encode() + b"=")
for k, v in headers
)
if not already_set:
headers.append(security.fresh_csrf_set_cookie())
message = {**message, "headers": headers}
await send(message)
return wrapped
class CSRFMiddleware:
"""Double-submit CSRF на чистом ASGI: для аутентифицированных мутаций на /api требуем
совпадения заголовка X-CSRF-Token и cookie csrf_token.
Если сессия есть, а csrf_token в запросе нет (cookie истекла или её стёрли), любой ответ
на /api — включая отказ ниже — перевыдаёт токен. Иначе состояние не лечилось: токен
выдаётся только при входе, а войти и выйти мешала эта же проверка.
Намеренно НЕ на BaseHTTPMiddleware: тот буферизует потоковые ответы и ломает SSE
(/api/events). Чистый ASGI пропускает стримы насквозь.
"""
def __init__(self, app) -> None: # noqa: ANN001
self.app = app
async def __call__(self, scope, receive, send): # noqa: ANN001
if scope["type"] == "http":
request = Request(scope)
if request.url.path.startswith("/api"):
has_session = ( has_session = (
security.USER_COOKIE in request.cookies security.USER_COOKIE in request.cookies
or security.ADMIN_COOKIE in request.cookies or security.ADMIN_COOKIE in request.cookies
) )
if has_session:
cookie_token = request.cookies.get(security.CSRF_COOKIE) cookie_token = request.cookies.get(security.CSRF_COOKIE)
if has_session and not cookie_token:
send = _with_fresh_csrf_cookie(send)
if has_session and request.method in _UNSAFE_METHODS:
header_token = request.headers.get(security.CSRF_HEADER) header_token = request.headers.get(security.CSRF_HEADER)
if not cookie_token or cookie_token != header_token: if not cookie_token or cookie_token != header_token:
response = JSONResponse( return JSONResponse(
status_code=403, status_code=403,
content={ content={
"error": { "error": {
@@ -93,57 +48,13 @@ class CSRFMiddleware:
} }
}, },
) )
await response(scope, receive, send) return await call_next(request)
return
await self.app(scope, receive, send)
_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим давно прочитанные уведомления
async def _notifications_purge_loop() -> None:
"""Фоновая чистка давно прочитанных уведомлений (single-worker безопасно). Чтобы
удалялись «отовсюду» даже у неактивных пользователей (помимо очистки при чтении)."""
import asyncio
from app.db.session import Session, engine
from app.services import notification_service
def _purge_once() -> None:
with Session(engine) as session:
notification_service.purge_expired(session)
while True:
try:
await asyncio.sleep(_NOTIFICATIONS_PURGE_INTERVAL)
await asyncio.to_thread(_purge_once)
except asyncio.CancelledError:
break
except Exception as exc: # noqa: BLE001
logging.getLogger("fs").warning("Чистка уведомлений пропущена: %s", exc)
@asynccontextmanager @asynccontextmanager
async def _lifespan(_app: FastAPI): async def _lifespan(_app: FastAPI):
# SSE-шина публикует из sync-роутеров в этот event-loop — сохраняем ссылку (все окружения).
import asyncio
from app.core.events import hub
hub.bind_loop(asyncio.get_running_loop())
if settings.is_development and settings.is_published:
# Решение владельца (#69): dev-инструменты остаются и на опубликованном dev —
# но о том, что они открыты любому посетителю домена, нужно сказать громко.
logging.getLogger("fs").warning(
"DEV ОПУБЛИКОВАН НАРУЖУ (LOCAL_PUBLIC=%s): любому посетителю домена открыты "
"вход по нику без пароля, список и создание игроков, жёсткое удаление аккаунтов "
"и Swagger. Не держите в dev-базе копию прод-данных.",
settings.local_public,
)
# В DEV приложение само подтягивает справочники и админа из .env при старте # В DEV приложение само подтягивает справочники и админа из .env при старте
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0). # (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0": if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
try: try:
from app.bootstrap import bootstrap from app.bootstrap import bootstrap
@@ -153,30 +64,21 @@ async def _lifespan(_app: FastAPI):
logging.getLogger("fs").warning( logging.getLogger("fs").warning(
"Стартовый bootstrap пропущен (примените миграции): %s", exc "Стартовый bootstrap пропущен (примените миграции): %s", exc
) )
purge_task = asyncio.create_task(_notifications_purge_loop())
try:
yield yield
finally:
purge_task.cancel()
def create_app() -> FastAPI: def create_app() -> FastAPI:
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev: она нужна для
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
# незачем облегчать разведку поверхности API анонимам (#61).
docs_enabled = not settings.is_production
app = FastAPI( app = FastAPI(
title="Forbidden Stars API", title="Forbidden Stars API",
version="0.1.0", version="0.1.0",
openapi_url="/api/openapi.json" if docs_enabled else None, openapi_url="/api/openapi.json",
docs_url="/api/docs" if docs_enabled else None, docs_url="/api/docs",
redoc_url="/api/redoc" if docs_enabled else None, redoc_url="/api/redoc",
lifespan=_lifespan, lifespan=_lifespan,
) )
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin). # CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin).
# В prod (и в dev через VPS-туннель) всё single-origin → CORS не подключаем. # В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем.
if settings.is_development and settings.cors_origins_list: if settings.is_development and settings.cors_origins_list:
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -192,31 +94,25 @@ def create_app() -> FastAPI:
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse: async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
# Только type/loc/msg. В input лежит тело запроса: эхо паролей в ответ, а для тела
# не в JSON (text/plain от HTML-формы) — сырые bytes, которые JSON не сериализует,
# и ответ падал в 500. В ctx бывают объекты исключений — та же проблема.
details = [{"type": e["type"], "loc": e["loc"], "msg": e["msg"]} for e in exc.errors()]
return JSONResponse( return JSONResponse(
status_code=422, status_code=422,
content={ content={
"error": { "error": {
"code": "VALIDATION_ERROR", "code": "VALIDATION_ERROR",
"message": "Ошибка валидации запроса.", "message": "Ошибка валидации запроса.",
"details": details, "details": exc.errors(),
} }
}, },
) )
# API-роутеры под /api. # API-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, invitations.router, api_routers = [auth.router, users.router, groups.router, matches.router,
matches.router, reference.router, stats.router, achievements.router, reference.router, stats.router, achievements.router, admin.router]
events.router, notifications.router, announcements.router,
admin.router]
for r in api_routers: for r in api_routers:
app.include_router(r, prefix="/api") app.include_router(r, prefix="/api")
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development # DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
# и только если код физически есть (в прод-образе dev_*-файлы исключены # и только если код физически есть (в test/prod-образе dev_*-файлы исключены
# .dockerignore, импорт просто не выполнится). # .dockerignore, импорт просто не выполнится).
if settings.is_development: if settings.is_development:
for mod_name in ("dev_auth", "dev_admin"): for mod_name in ("dev_auth", "dev_admin"):
@@ -253,13 +149,9 @@ def _mount_spa(app: FastAPI) -> None:
full_path full_path
and _STATIC_DIR in candidate.parents and _STATIC_DIR in candidate.parents
and candidate.is_file() and candidate.is_file()
and candidate.name != "index.html"
): ):
# Хэшированные ассеты Vite — обычное кэширование (имя меняется при сборке).
return FileResponse(candidate) return FileResponse(candidate)
# index.html (корень и SPA-маршруты) — НЕ кэшируем: иначе браузер отдаёт старый return FileResponse(index_file)
# документ из кэша и при недоступности хоста не доходит до Caddy с заглушкой.
return FileResponse(index_file, headers={"Cache-Control": "no-store"})
app = create_app() app = create_app()
+27 -249
View File
@@ -4,7 +4,7 @@
""" """
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime, timezone
from sqlalchemy import ( from sqlalchemy import (
JSON, JSON,
@@ -23,9 +23,9 @@ from sqlalchemy import (
) )
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from app.core.timeutil import utcnow
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── Справочники: дополнения и фракции ─────────────────────────────────────── # ─── Справочники: дополнения и фракции ───────────────────────────────────────
@@ -38,7 +38,7 @@ class Expansion(SQLModel, table=True):
name_ru: str = Field(sa_column=Column(String(64), nullable=False)) name_ru: str = Field(sa_column=Column(String(64), nullable=False))
is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
sort_order: int = Field(default=0, nullable=False) sort_order: int = Field(default=0, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False) created_at: datetime = Field(default_factory=_utcnow, nullable=False)
class Faction(SQLModel, table=True): class Faction(SQLModel, table=True):
@@ -56,7 +56,7 @@ class Faction(SQLModel, table=True):
) )
) )
sort_order: int = Field(default=0, nullable=False) sort_order: int = Field(default=0, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False) created_at: datetime = Field(default_factory=_utcnow, nullable=False)
# ─── Пользователи и идентичности ───────────────────────────────────────────── # ─── Пользователи и идентичности ─────────────────────────────────────────────
@@ -73,10 +73,6 @@ class User(SQLModel, table=True):
"role <> 'admin' OR password_hash IS NOT NULL", "role <> 'admin' OR password_hash IS NOT NULL",
name="ck_users_admin_has_password", name="ck_users_admin_has_password",
), ),
CheckConstraint("history_mode IN ('all','best')", name="ck_users_history_mode"),
CheckConstraint(
"history_detail IN ('compact','full')", name="ck_users_history_detail"
),
) )
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
@@ -93,13 +89,6 @@ class User(SQLModel, table=True):
sa_column=Column(String(16), nullable=False, server_default="stub"), sa_column=Column(String(16), nullable=False, server_default="stub"),
) )
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True)) password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
# Версия сессий: инкремент отзывает все ранее выданные JWT этого пользователя
# (claim `ver` в токене сверяется с этим полем в auth/deps). Растёт при смене пароля
# и сбросе пароля админом — компрометация или утечка токена так прекращается (#57).
token_version: int = Field(
default=0,
sa_column=Column(Integer, nullable=False, server_default="0"),
)
active_group_id: int | None = Field( active_group_id: int | None = Field(
sa_column=Column( sa_column=Column(
Integer, Integer,
@@ -108,34 +97,14 @@ class User(SQLModel, table=True):
index=True, index=True,
) )
) )
# Витрина истории партий в профиле: что показывать (все / только лучшая по очкам)
# и насколько подробно. Действует и для гостей профиля, не только для владельца.
history_mode: str = Field(
default="all",
sa_column=Column(String(8), nullable=False, server_default="all"),
)
history_detail: str = Field(
default="compact",
sa_column=Column(String(8), nullable=False, server_default="compact"),
)
# Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление
# по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан.
favorite_faction_id: int | None = Field(
default=None,
sa_column=Column(
Integer,
ForeignKey("factions.id", ondelete="SET NULL"),
nullable=True,
),
)
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1")) is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL # Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
# (выдача ачивок игрокам — следующий этап). # (выдача ачивок игрокам — следующий этап).
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True)) title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False) created_at: datetime = Field(default_factory=_utcnow, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=utcnow, default_factory=_utcnow,
sa_column_kwargs={"onupdate": utcnow}, sa_column_kwargs={"onupdate": _utcnow},
nullable=False, nullable=False,
) )
@@ -157,7 +126,7 @@ class UserAchievement(SQLModel, table=True):
) )
) )
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False)) achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
earned_at: datetime = Field(default_factory=utcnow, nullable=False) earned_at: datetime = Field(default_factory=_utcnow, nullable=False)
class AuthIdentity(SQLModel, table=True): class AuthIdentity(SQLModel, table=True):
@@ -174,7 +143,7 @@ class AuthIdentity(SQLModel, table=True):
) )
provider: str = Field(sa_column=Column(String(16), nullable=False)) provider: str = Field(sa_column=Column(String(16), nullable=False))
external_id: str = Field(sa_column=Column(String(64), nullable=False)) external_id: str = Field(sa_column=Column(String(64), nullable=False))
created_at: datetime = Field(default_factory=utcnow, nullable=False) created_at: datetime = Field(default_factory=_utcnow, nullable=False)
# ─── Группы и членство ─────────────────────────────────────────────────────── # ─── Группы и членство ───────────────────────────────────────────────────────
@@ -189,18 +158,12 @@ class Group(SQLModel, table=True):
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
) )
) )
created_at: datetime = Field(default_factory=utcnow, nullable=False) created_at: datetime = Field(default_factory=_utcnow, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=utcnow, default_factory=_utcnow,
sa_column_kwargs={"onupdate": utcnow}, sa_column_kwargs={"onupdate": _utcnow},
nullable=False, nullable=False,
) )
# Домашнее правило: при 5–6 игроках играется 9 раундов вместо 8. Партия снимает
# значение при старте (Match.nine_rounds_rule), так что смена галочки историю не трогает.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
class GroupMember(SQLModel, table=True): class GroupMember(SQLModel, table=True):
@@ -222,35 +185,7 @@ class GroupMember(SQLModel, table=True):
) )
) )
role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member")) role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member"))
joined_at: datetime = Field(default_factory=utcnow, nullable=False) joined_at: datetime = Field(default_factory=_utcnow, nullable=False)
class GroupInvitation(SQLModel, table=True):
"""Ожидающее приглашение в группу. Хранятся только pending: при ответе строка
удаляется (accept → создаётся GroupMember; decline → просто удаление)."""
__tablename__ = "group_invitations"
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_invitation"),
)
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
)
)
invited_by_id: int | None = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
class GroupExpansion(SQLModel, table=True): class GroupExpansion(SQLModel, table=True):
@@ -270,7 +205,7 @@ class GroupExpansion(SQLModel, table=True):
Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False
) )
) )
created_at: datetime = Field(default_factory=utcnow, nullable=False) created_at: datetime = Field(default_factory=_utcnow, nullable=False)
# ─── Партии и участники ────────────────────────────────────────────────────── # ─── Партии и участники ──────────────────────────────────────────────────────
@@ -281,8 +216,7 @@ class Match(SQLModel, table=True):
Index("ix_matches_group_played", "group_id", "played_at"), Index("ix_matches_group_played", "group_id", "played_at"),
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"), CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
CheckConstraint( CheckConstraint(
"win_reason IS NULL OR win_reason IN " "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')",
"('objectives','worlds','plastic','resources','last_standing')",
name="ck_match_win_reason", name="ck_match_win_reason",
), ),
) )
@@ -302,14 +236,6 @@ class Match(SQLModel, table=True):
finished_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)) 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)) win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
# Раунд, в котором партия закончилась (NULL — не указан). Лимит раундов не хранится:
# он выводится из снимка nine_rounds_rule и числа участников (scoring.max_rounds).
end_round: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
# Снимок Group.nine_rounds_rule на момент старта партии.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
player_count: int = Field(sa_column=Column(Integer, nullable=False)) player_count: int = Field(sa_column=Column(Integer, nullable=False))
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True)) overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_by: int = Field( created_by: int = Field(
@@ -317,10 +243,10 @@ class Match(SQLModel, table=True):
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
) )
) )
created_at: datetime = Field(default_factory=utcnow, nullable=False) created_at: datetime = Field(default_factory=_utcnow, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=utcnow, default_factory=_utcnow,
sa_column_kwargs={"onupdate": utcnow}, sa_column_kwargs={"onupdate": _utcnow},
nullable=False, nullable=False,
) )
@@ -351,163 +277,9 @@ class MatchParticipant(SQLModel, table=True):
) )
) )
place: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) place: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
# Выбыл из партии: place авто = последнее место (отображается красным крестиком).
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
comment: str | None = Field(sa_column=Column(Text, nullable=True)) comment: str | None = Field(sa_column=Column(Text, nullable=True))
# Итог партии для рейтинга (NULL — не указан): маркеры целей и дружественные миры created_at: datetime = Field(default_factory=_utcnow, nullable=False)
# на конец партии. У выбывшего миров 0.
objectives: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
worlds: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False)
class MatchAttachment(SQLModel, table=True):
"""Медиа партии (фото в общем комментарии). Файл — на томе uploads, в БД метаданные.
kind пока всегда 'photo' (задел под видео). storage_path — относительный путь под
settings.upload_dir."""
__tablename__ = "match_attachments"
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
)
)
uploaded_by: int | None = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
)
kind: str = Field(default="photo", sa_column=Column(String(16), nullable=False, server_default="photo"))
storage_path: str = Field(sa_column=Column(String(255), nullable=False))
mime_type: str = Field(sa_column=Column(String(64), nullable=False))
size_bytes: int = Field(sa_column=Column(Integer, nullable=False))
created_at: datetime = Field(default_factory=utcnow, nullable=False)
class MatchFinishDraft(SQLModel, table=True):
"""Общий черновик формы завершения партии: раскладка мест, ничьи, выбывшие,
комментарии и причина победы, пока партию не завершили.
Отдельная таблица, а не колонки в matches, намеренно: запись в строку партии
дёргает onupdate у matches.updated_at, а это версия для оптимистичной блокировки —
«Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку.
Живёт только пока партия идёт: finish_match удаляет строку, удаление партии
уносит её каскадом."""
__tablename__ = "match_finish_drafts"
match_id: int | None = Field(
default=None,
sa_column=Column(
Integer, ForeignKey("matches.id", ondelete="CASCADE"), primary_key=True
),
)
data: dict = Field(sa_column=Column(JSON, nullable=False))
updated_by: int | None = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
)
updated_at: datetime = Field(
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
nullable=False,
)
# ─── Уведомления ─────────────────────────────────────────────────────────────
class Notification(SQLModel, table=True):
"""Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.).
Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер —
фронт лишь отображает. Непрочитанные хранятся до прочтения (кап на пользователя),
прочитанные чистятся спустя 72 часа — фоном и при чтении списка."""
__tablename__ = "notifications"
__table_args__ = (
Index("ix_notifications_user_created", "user_id", "created_at"),
)
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
)
)
type: str = Field(sa_column=Column(String(32), nullable=False))
title: str = Field(sa_column=Column(String(255), nullable=False))
body: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
link: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
read_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
# ─── Объявления администрации ────────────────────────────────────────────────
class Announcement(SQLModel, table=True):
"""Объявление администрации (#84): окно поверх приложения в период показа, каждому
игроку — пока он его не закроет.
body_html — уже очищенный сервером HTML (announcement_service.sanitize_body), фронт
вставляет его как есть. revision растёт, когда админ сохраняет правку с «показать
заново»: закрывшие прежнюю версию увидят объявление ещё раз с пометкой «обновлено».
show_to_new_players=False — не показывать зарегистрировавшимся после starts_at."""
__tablename__ = "announcements"
__table_args__ = (
CheckConstraint("ends_at > starts_at", name="ck_announcement_period"),
Index("ix_announcements_period", "starts_at", "ends_at"),
)
id: int | None = Field(default=None, primary_key=True)
title: str = Field(sa_column=Column(String(64), nullable=False))
body_html: str = Field(sa_column=Column(Text, nullable=False))
starts_at: datetime = Field(sa_column=Column(DateTime, nullable=False))
ends_at: datetime = Field(sa_column=Column(DateTime, nullable=False))
show_to_new_players: bool = Field(
default=True,
sa_column=Column(Boolean, nullable=False, server_default="1"),
)
revision: int = Field(default=1, sa_column=Column(Integer, nullable=False, server_default="1"))
created_by: int | None = Field(
default=None,
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=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 AnnouncementView(SQLModel, table=True):
"""Игрок закрыл объявление. revision — какую версию он видел: если админ выпустил
новую, объявление покажется снова."""
__tablename__ = "announcement_views"
announcement_id: int | None = Field(
default=None,
sa_column=Column(
Integer, ForeignKey("announcements.id", ondelete="CASCADE"), primary_key=True
),
)
user_id: int | None = Field(
default=None,
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, index=True
),
)
revision: int = Field(sa_column=Column(Integer, nullable=False))
closed_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Журнал аудита ─────────────────────────────────────────────────────────── # ─── Журнал аудита ───────────────────────────────────────────────────────────
@@ -531,6 +303,12 @@ class AuditLog(SQLModel, table=True):
payload: dict | None = Field(default=None, sa_column=Column(JSON, 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)) ip: str | None = Field(sa_column=Column(String(45), nullable=True))
user_agent: str | None = Field(sa_column=Column(String(256), nullable=True)) user_agent: str | None = Field(sa_column=Column(String(256), nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False) 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; в БД только метаданные и относительный путь.
+52 -268
View File
@@ -2,32 +2,25 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.admin_login import login_admin
from app.auth.deps import get_current_admin from app.auth.deps import get_current_admin
from app.core import security from app.core import security
from app.core.security import client_ip
from app.core.errors import InvalidCredentialsError, NotFoundError
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
from app.routers.matches import attachment_read, build_match_read, participant_inputs from app.routers.matches import build_match_read
from app.schemas import api as s from app.schemas import api as s
from app.services import ( from app.services import (
achievement_service, achievement_service,
admin_service, admin_service,
announcement_service,
attachment_service,
audit_service, audit_service,
faction_service, faction_service,
match_service, match_service,
notify,
user_service,
) )
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ _ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
from app.services.match_service import ParticipantInput
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -41,30 +34,15 @@ def admin_login(
response: Response, response: Response,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> s.AdminMe: ) -> s.AdminMe:
try: admin = admin_service.authenticate_admin(session, body.username, body.password)
admin = login_admin(session, body.username, body.password, client_ip(request)) security.set_admin_session(response, admin.id) # type: ignore[arg-type]
except InvalidCredentialsError:
# Неудачную попытку фиксируем в аудите (перебор пароля админа — прямой путь к
# полному контролю). Серию таких попыток ограничивает throttle в login_admin (#56).
audit_service.record(
session,
actor_id=None,
action="login_failed",
entity_type="admin",
payload={"username": (body.username or "")[:64]},
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
session.commit()
raise
security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type]
audit_service.record( audit_service.record(
session, session,
actor_id=admin.id, actor_id=admin.id,
action="login", action="login",
entity_type="admin", entity_type="admin",
entity_id=admin.id, entity_id=admin.id,
ip=client_ip(request), ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
@@ -72,8 +50,7 @@ def admin_login(
@router.post("/auth/logout", response_model=s.OkResponse) @router.post("/auth/logout", response_model=s.OkResponse)
def admin_logout(request: Request, response: Response) -> s.OkResponse: def admin_logout(response: Response) -> s.OkResponse:
security.revoke_session_token(request, security.ADMIN_COOKIE, security.AUDIENCE_ADMIN)
security.clear_admin_session(response) security.clear_admin_session(response)
return s.OkResponse() return s.OkResponse()
@@ -85,8 +62,14 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
# ─── Пользователи ──────────────────────────────────────────────────────────── # ─── Пользователи ────────────────────────────────────────────────────────────
def _admin_user_read(u: User) -> s.AdminUserRead: @router.get("/users", response_model=list[s.AdminUserRead])
return 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] id=u.id, # type: ignore[arg-type]
nickname=u.nickname, nickname=u.nickname,
role=u.role, role=u.role,
@@ -94,17 +77,9 @@ def _admin_user_read(u: User) -> s.AdminUserRead:
auth_provider=u.auth_provider, auth_provider=u.auth_provider,
telegram_id=u.telegram_id, telegram_id=u.telegram_id,
created_at=iso_utc(u.created_at), created_at=iso_utc(u.created_at),
has_password=u.password_hash is not None,
) )
for u in admin_service.list_users(session, query)
]
@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 [_admin_user_read(u) for u in admin_service.list_users(session, query)]
@router.patch("/users/{user_id}", response_model=s.AdminUserRead) @router.patch("/users/{user_id}", response_model=s.AdminUserRead)
@@ -123,37 +98,22 @@ def update_user(
entity_type="user", entity_type="user",
entity_id=user_id, entity_id=user_id,
payload=body.model_dump(exclude_none=True), payload=body.model_dump(exclude_none=True),
ip=client_ip(request), ip=request.client.host if request.client else None,
) )
session.commit() session.commit()
return _admin_user_read(u) return s.AdminUserRead(
id=u.id, # type: ignore[arg-type]
nickname=u.nickname,
@router.put("/users/{user_id}/password", response_model=s.AdminUserRead) role=u.role,
def set_user_password( is_active=u.is_active,
user_id: int, auth_provider=u.auth_provider,
body: s.AdminPasswordSet, telegram_id=u.telegram_id,
request: Request, created_at=u.created_at.isoformat(),
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminUserRead:
"""Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется."""
u = admin_service.set_player_password(session, user_id, body.new_password)
audit_service.record(
session,
actor_id=admin.id,
action="update",
entity_type="user",
entity_id=user_id,
payload={"password": "set_by_admin"},
ip=client_ip(request),
) )
session.commit()
return _admin_user_read(u)
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в # Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
# routers/dev_admin.py (исключён из прод-образа). В проде аккаунт только # routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только
# отключается (PATCH is_active), удалять нельзя. # отключается (PATCH is_active), удалять нельзя.
@@ -182,7 +142,7 @@ def delete_group(
admin_service.delete_group(session, group_id) admin_service.delete_group(session, group_id)
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id, session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id,
ip=client_ip(request), ip=request.client.host if request.client else None,
) )
session.commit() session.commit()
return s.OkResponse() return s.OkResponse()
@@ -243,6 +203,18 @@ def update_match(
admin: User = Depends(get_current_admin), admin: User = Depends(get_current_admin),
) -> s.MatchRead: ) -> s.MatchRead:
match = match_service.get_match(session, match_id) 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( match = match_service.update_match(
session, session,
match, match,
@@ -251,10 +223,7 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason, win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set), win_reason_set=("win_reason" in body.model_fields_set),
end_round=body.end_round, participants=participants,
end_round_set=("end_round" in body.model_fields_set),
participants=participant_inputs(body),
expected_version=body.expected_version,
) )
audit_service.record( audit_service.record(
session, session,
@@ -262,10 +231,9 @@ def update_match(
action="update", action="update",
entity_type="match", entity_type="match",
entity_id=match.id, entity_id=match.id,
ip=client_ip(request), ip=request.client.host if request.client else None,
) )
session.commit() session.commit()
notify.match_changed(session, match)
return build_match_read(session, match, can_modify=True) return build_match_read(session, match, can_modify=True)
@@ -300,7 +268,7 @@ def rename_faction(
entity_type="faction", entity_type="faction",
entity_id=faction_id, entity_id=faction_id,
payload={"name_ru": f.name_ru}, payload={"name_ru": f.name_ru},
ip=client_ip(request), ip=request.client.host if request.client else None,
) )
session.commit() session.commit()
return s.FactionRead( return s.FactionRead(
@@ -315,81 +283,15 @@ def delete_match(
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(get_current_admin), admin: User = Depends(get_current_admin),
) -> s.OkResponse: ) -> s.OkResponse:
match = match_service.get_match(session, match_id)
group_id, finished = match.group_id, match.status == "finished" # для уведомления
admin_service.delete_match(session, match_id) admin_service.delete_match(session, match_id)
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id, session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
ip=client_ip(request), ip=request.client.host if request.client else None,
) )
session.commit() session.commit()
notify.match_removed(session, match_id, group_id, finished=finished)
return s.OkResponse() return s.OkResponse()
# ─── Медиа партии (админ правит в любой момент) ───────────────────────────────
@router.get("/matches/{match_id}/attachments", response_model=list[s.AttachmentRead])
def admin_list_attachments(
match_id: int,
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.AttachmentRead]:
match_service.get_match(session, match_id) # 404 если партии нет
return [
attachment_read(a, f"/api/admin/matches/{match_id}")
for a in attachment_service.list_for_match(session, match_id)
]
@router.post("/matches/{match_id}/attachments", response_model=s.AttachmentRead)
def admin_add_attachment(
match_id: int,
file: UploadFile = File(...),
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AttachmentRead:
match = match_service.get_match(session, match_id)
content, ext = user_service.read_capped_image(
file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)."
)
att = attachment_service.add_photo(
session, match, admin, content, ext, user_service.avatar_media_type(ext)
)
notify.match_changed(session, match)
return attachment_read(att, f"/api/admin/matches/{match_id}")
@router.delete("/matches/{match_id}/attachments/{attachment_id}", response_model=s.OkResponse)
def admin_delete_attachment(
match_id: int,
attachment_id: int,
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> s.OkResponse:
match = match_service.get_match(session, match_id)
attachment_service.delete(session, match, attachment_id)
notify.match_changed(session, match)
return s.OkResponse()
@router.get("/matches/{match_id}/attachments/{attachment_id}")
def admin_get_attachment(
match_id: int,
attachment_id: int,
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> FileResponse:
match_service.get_match(session, match_id)
att = attachment_service.get_for_match(session, match_id, attachment_id)
path = attachment_service.file_path(att)
if not path.exists():
raise NotFoundError("Файл не найден.")
return FileResponse(
path, media_type=att.mime_type, headers={"Cache-Control": "private, max-age=3600"}
)
# ─── Ачивки (определения; выдача игрокам — на будущее) ──────────────────────── # ─── Ачивки (определения; выдача игрокам — на будущее) ────────────────────────
@router.get("/achievements", response_model=list[s.AchievementRead]) @router.get("/achievements", response_model=list[s.AchievementRead])
@@ -410,7 +312,7 @@ def create_achievement(
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="create", entity_type="achievement", session, actor_id=admin.id, action="create", entity_type="achievement",
payload={"slug": ach["slug"], "name": ach["name"]}, payload={"slug": ach["slug"], "name": ach["name"]},
ip=client_ip(request), ip=request.client.host if request.client else None,
) )
session.commit() session.commit()
return ach return ach
@@ -429,7 +331,7 @@ def update_achievement(
) )
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="update", entity_type="achievement", session, actor_id=admin.id, action="update", entity_type="achievement",
payload={"slug": slug}, ip=client_ip(request), payload={"slug": slug}, ip=request.client.host if request.client else None,
) )
session.commit() session.commit()
return ach return ach
@@ -441,9 +343,12 @@ def upload_achievement_icon(
file: UploadFile = File(...), file: UploadFile = File(...),
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> dict: ) -> dict:
content, ext = user_service.read_capped_image( from app.core.errors import ValidationError
file, _ACHIEVEMENT_ICON_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
) content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1)
if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES:
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
ext = achievement_service.validate_icon(content)
return achievement_service.set_icon(slug, content, ext) return achievement_service.set_icon(slug, content, ext)
@@ -457,133 +362,12 @@ def delete_achievement(
achievement_service.delete(slug) achievement_service.delete(slug)
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="achievement", session, actor_id=admin.id, action="delete", entity_type="achievement",
payload={"slug": slug}, ip=client_ip(request), payload={"slug": slug}, ip=request.client.host if request.client else None,
) )
session.commit() session.commit()
return s.OkResponse() return s.OkResponse()
# ─── Объявления ──────────────────────────────────────────────────────────────
def _announcement_read(item: dict) -> s.AdminAnnouncementRead:
a = item["a"]
return s.AdminAnnouncementRead(
id=a.id,
title=a.title,
body_html=a.body_html,
starts_at=iso_utc(a.starts_at), # type: ignore[arg-type]
ends_at=iso_utc(a.ends_at), # type: ignore[arg-type]
show_to_new_players=a.show_to_new_players,
revision=a.revision,
status=item["status"],
seen_count=item["seen"],
audience_count=item["audience"],
created_at=iso_utc(a.created_at), # type: ignore[arg-type]
updated_at=iso_utc(a.updated_at), # type: ignore[arg-type]
)
def _announcement_by_id(session: Session, announcement_id: int) -> s.AdminAnnouncementRead:
return _announcement_read(announcement_service.admin_item(session, announcement_id))
@router.get("/announcements", response_model=list[s.AdminAnnouncementRead])
def list_announcements(
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.AdminAnnouncementRead]:
return [_announcement_read(i) for i in announcement_service.list_admin(session)]
@router.post("/announcements", response_model=s.AdminAnnouncementRead)
def create_announcement(
body: s.AnnouncementWrite,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminAnnouncementRead:
a = announcement_service.create(
session,
title=body.title,
body_html=body.body_html,
starts_at=body.starts_at,
ends_at=body.ends_at,
show_to_new_players=body.show_to_new_players,
actor_id=admin.id,
)
audit_service.record(
session, actor_id=admin.id, action="create", entity_type="announcement",
entity_id=a.id, payload={"title": a.title}, ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return _announcement_by_id(session, a.id) # type: ignore[arg-type]
@router.put("/announcements/{announcement_id}", response_model=s.AdminAnnouncementRead)
def update_announcement(
announcement_id: int,
body: s.AnnouncementUpdate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminAnnouncementRead:
a = announcement_service.update(
session,
announcement_id,
title=body.title,
body_html=body.body_html,
starts_at=body.starts_at,
ends_at=body.ends_at,
show_to_new_players=body.show_to_new_players,
reshow=body.reshow,
)
audit_service.record(
session, actor_id=admin.id, action="update", entity_type="announcement",
entity_id=announcement_id,
payload={"title": a.title, "reshow": body.reshow, "revision": a.revision},
ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return _announcement_by_id(session, announcement_id)
@router.post("/announcements/{announcement_id}/stop", response_model=s.AdminAnnouncementRead)
def stop_announcement(
announcement_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminAnnouncementRead:
"""«Снять с показа»: период идущего объявления заканчивается сейчас."""
announcement_service.stop(session, announcement_id)
audit_service.record(
session, actor_id=admin.id, action="update", entity_type="announcement",
entity_id=announcement_id, payload={"stopped": True}, ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return _announcement_by_id(session, announcement_id)
@router.delete("/announcements/{announcement_id}", response_model=s.OkResponse)
def delete_announcement(
announcement_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
announcement_service.delete(session, announcement_id)
audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="announcement",
entity_id=announcement_id, ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return s.OkResponse()
# ─── Журнал аудита ─────────────────────────────────────────────────────────── # ─── Журнал аудита ───────────────────────────────────────────────────────────
@router.get("/audit-logs", response_model=s.AuditLogList) @router.get("/audit-logs", response_model=s.AuditLogList)
-46
View File
@@ -1,46 +0,0 @@
"""Объявления администрации для игрока: что показать сейчас и «Понятно».
Появление нового объявления у открытой вкладки обеспечивает SSE-сигнал
`{type:"announcements"}`; объявление с отложенным началом клиент подхватывает
периодическим перезапросом."""
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 announcement_service
router = APIRouter(prefix="/announcements", tags=["announcements"])
@router.get("/pending", response_model=list[s.AnnouncementRead])
def pending(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> list[s.AnnouncementRead]:
return [
s.AnnouncementRead(
id=a.id, # type: ignore[arg-type]
title=a.title,
body_html=a.body_html,
revision=a.revision,
updated=updated,
)
for a, updated in announcement_service.pending_for_user(session, user)
]
@router.post("/{announcement_id}/ack", response_model=s.OkResponse)
def acknowledge(
announcement_id: int,
body: s.AnnouncementAck,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
announcement_service.acknowledge(session, user.id, announcement_id, body.revision) # type: ignore[arg-type]
session.commit()
return s.OkResponse()
+4 -47
View File
@@ -1,6 +1,6 @@
"""Постоянный роутер аутентификации: конфиг, вход по паролю, Telegram-вход, выход. """Постоянный роутер аутентификации: конфиг, Telegram-вход, выход.
Stub-вход (по нику без пароля) физически вынесен в routers/dev_auth.py и доступен только в dev. Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev.
""" """
from __future__ import annotations from __future__ import annotations
@@ -8,17 +8,15 @@ from fastapi import APIRouter, Depends, Request, Response
from sqlmodel import Session from sqlmodel import Session
from app.auth.login import establish_session from app.auth.login import establish_session
from app.auth.password import login_player, throttle_register
from app.auth.registry import enabled_methods from app.auth.registry import enabled_methods
from app.auth.telegram import TelegramProvider from app.auth.telegram import TelegramProvider
from app.core import security from app.core import security
from app.core.config import settings from app.core.config import settings
from app.core.errors import TelegramNicknameRequiredError from app.core.errors import TelegramNicknameRequiredError
from app.core.security import client_ip
from app.db.session import get_session from app.db.session import get_session
from app.routers.users import build_me from app.routers.users import build_me
from app.schemas import api as s from app.schemas import api as s
from app.services import audit_service, user_service from app.services import user_service
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -28,47 +26,9 @@ def auth_config() -> s.AuthConfig:
return s.AuthConfig( return s.AuthConfig(
methods=enabled_methods(), methods=enabled_methods(),
telegram_bot_username=settings.telegram_bot_username, telegram_bot_username=settings.telegram_bot_username,
tz_offset_hours=settings.app_tz_offset_hours,
) )
@router.post("/register", response_model=s.MeRead)
def password_register(
body: s.PasswordRegister,
request: Request,
response: Response,
session: Session = Depends(get_session),
) -> s.MeRead:
"""Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле."""
throttle_register(client_ip(request)) # против спама аккаунтов с одного IP (#62)
user = user_service.register_local(session, body.nickname, body.password)
audit_service.record(
session,
actor_id=user.id,
action="create",
entity_type="user",
entity_id=user.id,
payload={"provider": "local"},
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
establish_session(session, response, request, user, "local")
return build_me(session, user)
@router.post("/login", response_model=s.MeRead)
def password_login(
body: s.PasswordLogin,
request: Request,
response: Response,
session: Session = Depends(get_session),
) -> s.MeRead:
"""Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429."""
user = login_player(session, body.nickname, body.password, client_ip(request))
establish_session(session, response, request, user, "local")
return build_me(session, user)
@router.post("/telegram", response_model=s.MeRead) @router.post("/telegram", response_model=s.MeRead)
def telegram_login( def telegram_login(
body: s.TelegramAuthPayload, body: s.TelegramAuthPayload,
@@ -118,9 +78,6 @@ def telegram_register(
@router.post("/logout", response_model=s.OkResponse) @router.post("/logout", response_model=s.OkResponse)
def logout(request: Request, response: Response) -> s.OkResponse: def logout(response: Response) -> s.OkResponse:
# Отзываем именно предъявленный токен (по jti) до его exp: украденная/оставленная
# cookie перестаёт работать сразу, а не живёт до конца TTL (#57).
security.revoke_session_token(request, security.USER_COOKIE, security.AUDIENCE_USER)
security.clear_user_session(response) security.clear_user_session(response)
return s.OkResponse() return s.OkResponse()
+3 -4
View File
@@ -1,9 +1,9 @@
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока. """DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
возможность удаления не попадает в прод — там аккаунт можно только возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только
отключить (PATCH is_active). отключить (PATCH is_active).
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются — Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
@@ -18,7 +18,6 @@ from fastapi import APIRouter, Depends, Request
from sqlmodel import Session, select from sqlmodel import Session, select
from app.auth.deps import get_current_admin from app.auth.deps import get_current_admin
from app.core.security import client_ip
from app.core.errors import NotFoundError, ValidationError from app.core.errors import NotFoundError, ValidationError
from app.db.session import get_session from app.db.session import get_session
from app.models import Group, Match, MatchParticipant, User from app.models import Group, Match, MatchParticipant, User
@@ -64,7 +63,7 @@ def delete_user_hard(
entity_type="user", entity_type="user",
entity_id=user_id, entity_id=user_id,
payload={"hard": True, "nickname": nickname}, payload={"hard": True, "nickname": nickname},
ip=client_ip(request), ip=request.client.host if request.client else None,
) )
session.commit() session.commit()
return s.OkResponse() return s.OkResponse()
+4 -16
View File
@@ -1,8 +1,8 @@
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи. """DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore), Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
а подключается роутер лишь при APP_ENV=development (см. app/main.py). Так вход а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код
без пароля остаётся только на деве (по логину и паролю входят везде). входа по логину остаётся только на деве.
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, Request, Response
from sqlmodel import Session, select from sqlmodel import Session, select
from app.auth.dev_stub import DevStubProvider from app.auth.dev_stub import DevStubProvider
from app.auth.login import establish_session, login_with_identity from app.auth.login import login_with_identity
from app.auth.provider import ExternalIdentity from app.auth.provider import ExternalIdentity
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
@@ -28,19 +28,7 @@ def dev_login(
response: Response, response: Response,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> s.MeRead: ) -> s.MeRead:
# Dev-вход «стать игроком с этим ником». Если такой игрок уже есть (любого провайдера — identity = DevStubProvider().authenticate({"nickname": body.nickname})
# Telegram, или stub с изменённым позже ником), входим именно в него, а не плодим дубли.
# Только если игрока с таким ником нет — создаём нового stub-пользователя.
nickname = (body.nickname or "").strip()
existing = (
session.exec(select(User).where(User.nickname == nickname)).first()
if nickname
else None
)
if existing is not None:
user = establish_session(session, response, request, existing, existing.auth_provider)
return build_me(session, user)
identity = DevStubProvider().authenticate({"nickname": nickname})
user = login_with_identity(session, response, request, identity) user = login_with_identity(session, response, request, identity)
return build_me(session, user) return build_me(session, user)
-45
View File
@@ -1,45 +0,0 @@
"""SSE: поток событий «данные изменились» для текущего пользователя.
Клиент (EventSource) держит одно соединение и по событию точечно перезапрашивает данные
(push-to-invalidate). Мутации остаются обычным REST.
"""
from __future__ import annotations
import asyncio
import json
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from app.auth.deps import get_current_user
from app.core.events import hub
from app.models import User
router = APIRouter(tags=["events"])
_HEARTBEAT_SECONDS = 25
@router.get("/events")
async def events(request: Request, user: User = Depends(get_current_user)) -> StreamingResponse:
queue = hub.subscribe(user.id) # type: ignore[arg-type]
async def stream():
try:
yield ": connected\n\n"
while True:
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(queue.get(), timeout=_HEARTBEAT_SECONDS)
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
except asyncio.TimeoutError:
yield ": ping\n\n" # heartbeat против idle-таймаутов прокси/туннеля
finally:
hub.unsubscribe(user.id, queue) # type: ignore[arg-type]
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+16 -47
View File
@@ -5,20 +5,14 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user from app.auth.deps import get_current_user
from app.core.security import client_ip
from app.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
from app.schemas import api as s from app.schemas import api as s
from app.services import ( from app.services import (
audit_service, audit_service,
group_service, group_service,
invitation_service,
membership_service, membership_service,
notification_service,
notify,
stats_service, stats_service,
user_service,
) )
router = APIRouter(prefix="/groups", tags=["groups"]) router = APIRouter(prefix="/groups", tags=["groups"])
@@ -33,7 +27,6 @@ def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail:
owner_id=group.owner_id, owner_id=group.owner_id,
my_role=member.role, my_role=member.role,
expansion_ids=group_service.group_expansion_ids(session, group_id), expansion_ids=group_service.group_expansion_ids(session, group_id),
nine_rounds_rule=group.nine_rounds_rule,
) )
@@ -63,7 +56,7 @@ def create_group(
entity_type="group", entity_type="group",
entity_id=group.id, entity_id=group.id,
payload={"name": group.name}, payload={"name": group.name},
ip=client_ip(request), ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
@@ -80,19 +73,15 @@ def get_group(
@router.patch("/{group_id}", response_model=s.GroupDetail) @router.patch("/{group_id}", response_model=s.GroupDetail)
def update_group( def rename_group(
group_id: int, group_id: int,
body: s.GroupUpdate, body: s.GroupRename,
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.GroupDetail: ) -> s.GroupDetail:
"""Название и домашние правила группы; меняются только переданные поля."""
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id) group = group_service.get_group(session, group_id)
if body.name is not None:
group_service.rename_group(session, group, body.name) group_service.rename_group(session, group, body.name)
if body.nine_rounds_rule is not None:
group_service.set_nine_rounds_rule(session, group, body.nine_rounds_rule)
return _detail(session, group_id, user.id) # type: ignore[arg-type] return _detail(session, group_id, user.id) # type: ignore[arg-type]
@@ -134,50 +123,34 @@ def list_members(
) -> list[s.MemberRead]: ) -> list[s.MemberRead]:
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
return [ return [
s.MemberRead( s.MemberRead(user_id=u.id, nickname=u.nickname, role=m.role)
user_id=u.id,
nickname=u.nickname,
role=m.role,
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
)
for m, u in membership_service.list_members(session, group_id) for m, u in membership_service.list_members(session, group_id)
] ]
@router.post("/{group_id}/invitations", response_model=s.InvitationRead) @router.post("/{group_id}/members", response_model=s.MemberRead)
def invite_member( def add_member(
group_id: int, group_id: int,
body: s.MemberAdd, body: s.MemberAdd,
request: Request, request: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.InvitationRead: ) -> s.MemberRead:
# Приглашать вправе ЛЮБОЙ участник группы (не только владелец) — assert_member.
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id) group = group_service.get_group(session, group_id)
inv, invited = invitation_service.create_invitation(session, group, user, body.nickname) member, added = membership_service.add_member_by_nickname(session, group, body.nickname)
audit_service.record( audit_service.record(
session, session,
actor_id=user.id, actor_id=user.id,
action="create", action="create",
entity_type="group_invitation", entity_type="group_member",
entity_id=group_id, entity_id=group_id,
payload={"invited_user_id": invited.id, "nickname": invited.nickname}, payload={"added_user_id": added.id, "nickname": added.nickname},
ip=client_ip(request), ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
notify.invitations_changed(invited.id) # type: ignore[arg-type] # живое появление у получателя return s.MemberRead(user_id=added.id, nickname=added.nickname, role=member.role)
notification_service.invited_to_group(
session, invited.id, group.name, user.nickname # type: ignore[arg-type]
)
return s.InvitationRead(
id=inv.id, # type: ignore[arg-type]
group_id=group_id,
group_name=group.name,
invited_by_nickname=user.nickname,
created_at=iso_utc(inv.created_at),
)
@router.delete("/{group_id}/members/{user_id}", response_model=s.OkResponse) @router.delete("/{group_id}/members/{user_id}", response_model=s.OkResponse)
@@ -192,7 +165,6 @@ def remove_member(
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id) group = group_service.get_group(session, group_id)
membership_service.remove_member(session, group, user_id) membership_service.remove_member(session, group, user_id)
notify.group_changed(session, group_id, extra_user_ids=[user_id]) # + удалённому
return s.OkResponse() return s.OkResponse()
@@ -207,13 +179,10 @@ def change_member_role(
group_service.assert_owner(session, group_id, user.id) # type: ignore[arg-type] group_service.assert_owner(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id) group = group_service.get_group(session, group_id)
member = membership_service.change_role(session, group, user_id, body.role) member = membership_service.change_role(session, group, user_id, body.role)
u = user_service.get_user(session, user_id) from app.services.user_service import get_user
return s.MemberRead(
user_id=u.id, u = get_user(session, user_id)
nickname=u.nickname, return s.MemberRead(user_id=u.id, nickname=u.nickname, role=member.role)
role=member.role,
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
)
# ─── Партии и статистика группы ────────────────────────────────────────────── # ─── Партии и статистика группы ──────────────────────────────────────────────
-44
View File
@@ -1,44 +0,0 @@
"""Входящие приглашения игрока: список, принятие, отклонение."""
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 invitation_service, notify
router = APIRouter(prefix="/invitations", tags=["invitations"])
@router.get("", response_model=list[s.InvitationRead])
def my_invitations(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> list[dict]:
return invitation_service.list_for_user(session, user.id) # type: ignore[arg-type]
@router.post("/{invitation_id}/accept", response_model=s.OkResponse)
def accept_invitation(
invitation_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
group = invitation_service.accept(session, user, invitation_id)
notify.invitations_changed(user.id) # type: ignore[arg-type]
notify.group_changed(session, group.id) # type: ignore[arg-type] # новый участник
return s.OkResponse()
@router.post("/{invitation_id}/decline", response_model=s.OkResponse)
def decline_invitation(
invitation_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
invitation_service.decline(session, user, invitation_id)
notify.invitations_changed(user.id) # type: ignore[arg-type]
return s.OkResponse()
+24 -178
View File
@@ -1,65 +1,21 @@
"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление.""" """Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление."""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user from app.auth.deps import get_current_user
from app.core.security import client_ip from app.core.errors import NoGroupError
from app.core.errors import ConflictError, NoGroupError, NotFoundError
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import Match, MatchAttachment, User from app.models import Match, User
from app.schemas import api as s from app.schemas import api as s
from app.services import ( from app.services import audit_service, group_service, match_service
attachment_service,
audit_service,
group_service,
match_service,
notification_service,
notify,
user_service,
)
from app.services.match_service import FinishInput, ParticipantInput, RosterInput from app.services.match_service import FinishInput, ParticipantInput, RosterInput
from app.services.scoring import max_rounds
router = APIRouter(prefix="/matches", tags=["matches"]) router = APIRouter(prefix="/matches", tags=["matches"])
def participant_inputs(body: s.MatchUpdate) -> list[ParticipantInput] | None:
"""Участники правки — общее для игроцкого и админского PATCH."""
if body.participants is None:
return None
return [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
objectives=p.objectives,
worlds=p.worlds,
)
for p in body.participants
]
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или
'/api/admin/matches/{id}'); cookie игрока/админа доходит до своего пути."""
return s.AttachmentRead(
id=att.id, # type: ignore[arg-type]
kind=att.kind,
url=f"{base}/attachments/{att.id}",
mime_type=att.mime_type,
size_bytes=att.size_bytes,
created_at=iso_utc(att.created_at),
)
def _ensure_has_any_group(session: Session, user: User) -> None: def _ensure_has_any_group(session: Session, user: User) -> None:
if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type] if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type]
raise NoGroupError() raise NoGroupError()
@@ -73,25 +29,11 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
faction_id=f.id, # type: ignore[arg-type] faction_id=f.id, # type: ignore[arg-type]
faction_name=f.name_ru, faction_name=f.name_ru,
place=p.place, place=p.place,
eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment, comment=p.comment,
objectives=p.objectives,
worlds=p.worlds,
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
) )
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type] for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
] ]
draft_row = match_service.get_finish_draft(session, match.id) # type: ignore[arg-type]
draft = None
if draft_row is not None:
author = session.get(User, draft_row.updated_by) if draft_row.updated_by else None
draft = s.MatchFinishDraftRead(
data=s.MatchFinishDraftData(**draft_row.data),
updated_by=draft_row.updated_by,
updated_by_nickname=author.nickname if author else None,
updated_at=iso_utc(draft_row.updated_at),
)
return s.MatchRead( return s.MatchRead(
id=match.id, # type: ignore[arg-type] id=match.id, # type: ignore[arg-type]
group_id=match.group_id, group_id=match.group_id,
@@ -101,20 +43,12 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
finished_at=iso_utc(match.finished_at), finished_at=iso_utc(match.finished_at),
duration_minutes=match.duration_minutes, duration_minutes=match.duration_minutes,
win_reason=match.win_reason, # type: ignore[arg-type] win_reason=match.win_reason, # type: ignore[arg-type]
end_round=match.end_round,
nine_rounds_rule=match.nine_rounds_rule,
max_rounds=max_rounds(match.player_count, match.nine_rounds_rule),
player_count=match.player_count, player_count=match.player_count,
overall_comment=match.overall_comment, overall_comment=match.overall_comment,
created_by=match.created_by, created_by=match.created_by,
can_modify=can_modify, can_modify=can_modify,
version=match_service.match_version(match),
participants=parts, participants=parts,
attachments=[ attachments=[],
attachment_read(a, f"/api/matches/{match.id}")
for a in attachment_service.list_for_match(session, match.id) # type: ignore[arg-type]
],
finish_draft=draft,
) )
@@ -155,12 +89,10 @@ def start_match(
entity_type="match", entity_type="match",
entity_id=match.id, entity_id=match.id,
payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"}, payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"},
ip=client_ip(request), ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
notify.match_changed(session, match)
notification_service.match_started(session, match, user.id) # type: ignore[arg-type]
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@@ -176,15 +108,7 @@ def finish_match(
match = match_service.get_match(session, match_id) match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user) match_service.assert_can_modify(session, match, user)
finish = [ finish = [
FinishInput( FinishInput(user_id=p.user_id, place=p.place, comment=p.comment, faction_id=p.faction_id)
user_id=p.user_id,
place=p.place,
eliminated=p.eliminated,
comment=p.comment,
faction_id=p.faction_id,
objectives=p.objectives,
worlds=p.worlds,
)
for p in body.participants for p in body.participants
] ]
match = match_service.finish_match( match = match_service.finish_match(
@@ -192,10 +116,8 @@ def finish_match(
match, match,
finish=finish, finish=finish,
win_reason=body.win_reason, win_reason=body.win_reason,
end_round=body.end_round,
overall_comment=body.overall_comment, overall_comment=body.overall_comment,
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
expected_version=body.expected_version,
) )
audit_service.record( audit_service.record(
session, session,
@@ -204,12 +126,10 @@ def finish_match(
entity_type="match", entity_type="match",
entity_id=match.id, entity_id=match.id,
payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes}, payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes},
ip=client_ip(request), ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
notify.match_changed(session, match)
notification_service.match_finished(session, match, user.id) # type: ignore[arg-type]
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@@ -234,6 +154,18 @@ def update_match(
) -> s.MatchRead: ) -> s.MatchRead:
match = match_service.get_match(session, match_id) match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user) 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( match = match_service.update_match(
session, session,
match, match,
@@ -242,10 +174,7 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason, win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set), win_reason_set=("win_reason" in body.model_fields_set),
end_round=body.end_round, participants=participants,
end_round_set=("end_round" in body.model_fields_set),
participants=participant_inputs(body),
expected_version=body.expected_version,
) )
audit_service.record( audit_service.record(
session, session,
@@ -253,98 +182,17 @@ def update_match(
action="update", action="update",
entity_type="match", entity_type="match",
entity_id=match.id, entity_id=match.id,
ip=client_ip(request), ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
notify.match_changed(session, match)
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@router.put("/{match_id}/finish-draft", response_model=s.MatchRead)
def save_finish_draft(
match_id: int,
body: s.MatchFinishDraftData,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MatchRead:
"""Общий черновик формы завершения: то, что видят все, кто заполняет партию.
Права те же, что у самой формы. Версию партии запись черновика не двигает —
иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку."""
match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user)
match_service.save_finish_draft(session, match, user, body.model_dump())
notify.match_draft_changed(session, match, actor_id=user.id) # type: ignore[arg-type]
return build_match_read(
session, match, can_modify=match_service.can_modify(session, match, user)
)
# ─── Медиа партии (фото) ──────────────────────────────────────────────────────
def _assert_can_attach(session: Session, match: Match, user: User) -> None:
match_service.assert_can_modify(session, match, user)
if match.status != "in_progress":
raise ConflictError("Медиа можно прикреплять только до завершения партии.")
@router.post("/{match_id}/attachments", response_model=s.AttachmentRead)
def add_attachment(
match_id: int,
file: UploadFile = File(...),
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.AttachmentRead:
match = match_service.get_match(session, match_id)
_assert_can_attach(session, match, user)
content, ext = user_service.read_capped_image(
file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)."
)
att = attachment_service.add_photo(
session, match, user, content, ext, user_service.avatar_media_type(ext)
)
notify.match_changed(session, match)
return attachment_read(att, f"/api/matches/{match_id}")
@router.delete("/{match_id}/attachments/{attachment_id}", response_model=s.OkResponse)
def delete_attachment(
match_id: int,
attachment_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
match = match_service.get_match(session, match_id)
_assert_can_attach(session, match, user)
attachment_service.delete(session, match, attachment_id)
notify.match_changed(session, match)
return s.OkResponse()
@router.get("/{match_id}/attachments/{attachment_id}")
def get_attachment(
match_id: int,
attachment_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> FileResponse:
match = match_service.get_match(session, match_id)
group_service.assert_member(session, match.group_id, user.id) # type: ignore[arg-type]
att = attachment_service.get_for_match(session, match_id, attachment_id)
path = attachment_service.file_path(att)
if not path.exists():
raise NotFoundError("Файл не найден.")
return FileResponse(
path, media_type=att.mime_type, headers={"Cache-Control": "private, max-age=3600"}
)
@router.delete("/{match_id}", response_model=s.OkResponse) @router.delete("/{match_id}", response_model=s.OkResponse)
def delete_match( def delete_match(
match_id: int, match_id: int,
request: Request, request: Request,
expected_version: str | None = Query(None),
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.OkResponse: ) -> s.OkResponse:
@@ -352,8 +200,7 @@ def delete_match(
match_service.assert_can_modify(session, match, user) match_service.assert_can_modify(session, match, user)
match_id_val = match.id match_id_val = match.id
group_id_val = match.group_id group_id_val = match.group_id
finished = match.status == "finished" # после удаления статус уже не прочитать match_service.delete_match(session, match)
match_service.delete_match(session, match, expected_version=expected_version)
audit_service.record( audit_service.record(
session, session,
actor_id=user.id, actor_id=user.id,
@@ -361,9 +208,8 @@ def delete_match(
entity_type="match", entity_type="match",
entity_id=match_id_val, entity_id=match_id_val,
payload={"group_id": group_id_val}, payload={"group_id": group_id_val},
ip=client_ip(request), ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
notify.match_removed(session, match_id_val, group_id_val, finished=finished) # type: ignore[arg-type]
return s.OkResponse() return s.OkResponse()
-35
View File
@@ -1,35 +0,0 @@
"""Уведомления игрока: список (pull) и отметка прочитанными.
Появление в реальном времени обеспечивает SSE-сигнал `{type:"notifications"}` — по нему клиент
перезапрашивает этот список. Непрочитанные хранятся до прочтения; прочитанные чистятся
спустя 72 часа (при чтении и фоном)."""
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 notification_service
router = APIRouter(prefix="/notifications", tags=["notifications"])
@router.get("", response_model=s.NotificationList)
def my_notifications(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> dict:
return notification_service.list_for_user(session, user.id) # type: ignore[arg-type]
@router.post("/read", response_model=s.OkResponse)
def mark_read(
body: s.NotificationMarkRead,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
notification_service.mark_read(session, user.id, body.ids) # type: ignore[arg-type]
return s.OkResponse()
+12 -125
View File
@@ -1,15 +1,12 @@
"""Роутер текущего пользователя.""" """Роутер текущего пользователя."""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user from app.auth.deps import get_current_user
from app.auth.password import check_current_password from app.core.errors import NotFoundError, ValidationError
from app.auth.telegram import TelegramProvider
from app.core.security import client_ip, set_user_session
from app.core.errors import NotFoundError
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
from app.schemas import api as s from app.schemas import api as s
@@ -34,11 +31,7 @@ def build_me(session: Session, user: User) -> s.MeRead:
active_group_id=user.active_group_id, active_group_id=user.active_group_id,
bio=user.bio, bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
favorite_faction_id=user.favorite_faction_id,
history_mode=user.history_mode,
history_detail=user.history_detail,
groups=groups, groups=groups,
has_password=user.password_hash is not None,
) )
@@ -65,7 +58,7 @@ def update_me(
entity_type="user", entity_type="user",
entity_id=user.id, entity_id=user.id,
payload={"nickname": user.nickname}, payload={"nickname": user.nickname},
ip=client_ip(request), ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
@@ -78,9 +71,6 @@ def update_me(
active_group_id=user.active_group_id, active_group_id=user.active_group_id,
bio=user.bio, bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
favorite_faction_id=user.favorite_faction_id,
history_mode=user.history_mode,
history_detail=user.history_detail,
) )
@@ -91,82 +81,15 @@ def update_my_profile(
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.MeRead: ) -> s.MeRead:
# Обновляем только реально переданные поля: «О себе» и любимая фракция правятся user_service.update_bio(session, user, body.bio)
# по отдельности, и частичный запрос не должен обнулять соседнее поле.
changed = body.model_dump(exclude_unset=True)
if "bio" in changed:
user_service.update_bio(session, user, changed["bio"])
if "favorite_faction_id" in changed:
user_service.update_favorite_faction(session, user, changed["favorite_faction_id"])
if "history_mode" in changed or "history_detail" in changed:
user_service.update_history_prefs(
session,
user,
mode=changed.get("history_mode"),
detail=changed.get("history_detail"),
)
audit_service.record( audit_service.record(
session, session,
actor_id=user.id, actor_id=user.id,
action="update", action="update",
entity_type="user", entity_type="user",
entity_id=user.id, entity_id=user.id,
payload={key: True for key in changed}, payload={"bio": True},
ip=client_ip(request), ip=request.client.host if request.client else None,
)
session.commit()
return build_me(session, user)
@router.put("/me/password", response_model=s.MeRead)
def change_my_password(
body: s.PasswordChange,
request: Request,
response: Response,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
"""Задать пароль (первый раз — без текущего) или сменить его (нужен текущий)."""
had_password = user.password_hash is not None
if had_password:
check_current_password(user, body.current_password)
user_service.set_password(session, user, body.new_password)
# set_password инкрементит token_version → все ранее выданные токены отозваны (#57).
# Перевыдаём cookie этому устройству со свежим ver, чтобы разлогинить только остальные.
set_user_session(response, user.id, user.auth_provider, user.token_version)
audit_service.record(
session,
actor_id=user.id,
action="update",
entity_type="user",
entity_id=user.id,
payload={"password": "changed" if had_password else "set"},
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
session.commit()
return build_me(session, user)
@router.post("/me/telegram", response_model=s.MeRead)
def link_my_telegram(
body: s.TelegramAuthPayload,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
"""Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе."""
identity = TelegramProvider().authenticate(body.model_dump())
user_service.link_telegram(session, user, identity)
audit_service.record(
session,
actor_id=user.id,
action="update",
entity_type="user",
entity_id=user.id,
payload={"telegram": "linked"},
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
return build_me(session, user) return build_me(session, user)
@@ -178,9 +101,12 @@ def upload_my_avatar(
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.MeRead: ) -> s.MeRead:
content, ext = user_service.read_capped_image( content = file.file.read(_AVATAR_MAX_BYTES + 1)
file, _AVATAR_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)." if len(content) > _AVATAR_MAX_BYTES:
) raise ValidationError("Файл слишком большой (макс. 2 МБ).")
ext = user_service.sniff_image_ext(content)
if ext is None:
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
user_service.set_avatar(session, user, content, ext) user_service.set_avatar(session, user, content, ext)
return build_me(session, user) return build_me(session, user)
@@ -221,26 +147,6 @@ def get_user_profile(
return user_service.public_profile(session, user_id) return user_service.public_profile(session, user_id)
@router.get("/{user_id}/matches", response_model=s.MatchHistory)
def user_matches(
user_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:
"""История партий игрока. Режим — витрина владельца профиля: её видят и гости."""
owner = user_service.get_user(session, user_id)
data = stats_service.user_match_list(
session,
user_id,
limit=limit,
offset=offset,
best_only=owner.history_mode == "best",
)
return {**data, "mode": owner.history_mode, "detail": owner.history_detail}
@router.get("/me/stats", response_model=s.ProfileStats) @router.get("/me/stats", response_model=s.ProfileStats)
def my_stats( def my_stats(
session: Session = Depends(get_session), session: Session = Depends(get_session),
@@ -267,22 +173,3 @@ def nickname_available(
) -> s.NicknameAvailable: ) -> s.NicknameAvailable:
available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id) available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id)
return s.NicknameAvailable(available=available) return s.NicknameAvailable(available=available)
@router.get("/search", response_model=list[s.UserSuggestion])
def search_users(
q: str = Query("", max_length=64),
limit: int = Query(3, ge=1, le=10),
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[s.UserSuggestion]:
"""Автокомплит по нику. Пустой q — пустой список (не ошибка): удобнее при наборе."""
users = user_service.search_users(session, q, limit=limit)
return [
s.UserSuggestion(
user_id=u.id, # type: ignore[arg-type]
nickname=u.nickname,
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
)
for u in users
]
+12 -248
View File
@@ -1,52 +1,20 @@
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта.""" """Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import date
from typing import Annotated, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
# last_standing — все соперники выбыли. Вручную не выбирается: сервер требует её ровно WinReason = Literal["objectives", "worlds", "plastic", "resources"]
# тогда, когда невыбывший участник один (match_service._check_last_standing).
WinReason = Literal["objectives", "worlds", "plastic", "resources", "last_standing"]
# Итоги партии для рейтинга. Верхние границы — только отсечка мусора: правила игры
# ограничивают сильнее, но их проверка — дело предупреждений в форме, а не отказа.
EndRound = Annotated[int, Field(ge=1, le=9)]
Count = Annotated[int, Field(ge=0, le=99)]
# ─── Auth ──────────────────────────────────────────────────────────────────── # ─── Auth ────────────────────────────────────────────────────────────────────
class AuthConfig(BaseModel): class AuthConfig(BaseModel):
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве. # Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве.
methods: list[str] = [] methods: list[str] = []
telegram_bot_username: str | None = None telegram_bot_username: str | None = None
# Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт
# показывает время всем игрокам — независимо от пояса устройства (#68).
tz_offset_hours: int
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
# Точное правило для нового пароля (8 символов .. 72 байта) — в app/auth/password.py.
_PASSWORD_MAX_CHARS = 128
class PasswordLogin(BaseModel):
# Логин — это ник игрока.
nickname: str
password: str = Field(max_length=_PASSWORD_MAX_CHARS)
class PasswordRegister(BaseModel):
nickname: str
password: str = Field(max_length=_PASSWORD_MAX_CHARS)
class PasswordChange(BaseModel):
# current_password нужен, только если пароль уже задан; первая установка — без него.
current_password: str | None = Field(default=None, max_length=_PASSWORD_MAX_CHARS)
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
class TelegramAuthPayload(BaseModel): class TelegramAuthPayload(BaseModel):
@@ -122,18 +90,10 @@ class UserRead(BaseModel):
active_group_id: int | None = None active_group_id: int | None = None
bio: str | None = None bio: str | None = None
avatar_url: str | None = None avatar_url: str | None = None
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
favorite_faction_id: int | None = None
# Витрина истории партий в профиле.
history_mode: str = "all"
history_detail: str = "compact"
class MeRead(UserRead): class MeRead(UserRead):
groups: list[GroupBrief] = [] groups: list[GroupBrief] = []
# False — пароль ещё не задан (аккаунт из Telegram или до появления паролей):
# фронт не пускает дальше окна установки пароля.
has_password: bool = False
class NicknameUpdate(BaseModel): class NicknameUpdate(BaseModel):
@@ -141,12 +101,7 @@ class NicknameUpdate(BaseModel):
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
# Оба поля необязательны и обновляются, только если реально переданы
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
bio: str | None = None bio: str | None = None
favorite_faction_id: int | None = None
history_mode: str | None = None
history_detail: str | None = None
class ActiveGroupUpdate(BaseModel): class ActiveGroupUpdate(BaseModel):
@@ -157,14 +112,6 @@ class NicknameAvailable(BaseModel):
available: bool available: bool
class UserSuggestion(BaseModel):
"""Подсказка автокомплита по нику (приглашение в группу и т.п.)."""
user_id: int
nickname: str
avatar_url: str | None = None
# ─── Группы и членство ─────────────────────────────────────────────────────── # ─── Группы и членство ───────────────────────────────────────────────────────
class GroupCreate(BaseModel): class GroupCreate(BaseModel):
@@ -172,10 +119,8 @@ class GroupCreate(BaseModel):
expansion_ids: list[int] = [] expansion_ids: list[int] = []
class GroupUpdate(BaseModel): class GroupRename(BaseModel):
# Частичная правка: переданные поля меняются, остальные остаются как есть. name: str
name: str | None = None
nine_rounds_rule: bool | None = None
class GroupExpansionsUpdate(BaseModel): class GroupExpansionsUpdate(BaseModel):
@@ -188,15 +133,12 @@ class GroupDetail(BaseModel):
owner_id: int owner_id: int
my_role: str my_role: str
expansion_ids: list[int] = [] expansion_ids: list[int] = []
# Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте).
nine_rounds_rule: bool = False
class MemberRead(BaseModel): class MemberRead(BaseModel):
user_id: int user_id: int
nickname: str nickname: str
role: str role: str
avatar_url: str | None = None
class MemberAdd(BaseModel): class MemberAdd(BaseModel):
@@ -207,52 +149,6 @@ class MemberRoleUpdate(BaseModel):
role: str role: str
class InvitationRead(BaseModel):
id: int
group_id: int
group_name: str
invited_by_nickname: str | None = None
created_at: str
# ─── Уведомления ───────────────────────────────────────────────────────────────
class NotificationRead(BaseModel):
id: int
type: str
title: str
body: str | None = None
link: str | None = None
read_at: str | None = None
created_at: str
class NotificationList(BaseModel):
items: list[NotificationRead]
unread_count: int
class NotificationMarkRead(BaseModel):
ids: list[int] | None = None
# ─── Объявления (игрок) ──────────────────────────────────────────────────────
class AnnouncementRead(BaseModel):
id: int
title: str
# HTML, уже очищенный сервером по белому списку — фронт вставляет как есть.
body_html: str
revision: int
# Игрок закрывал прежнюю версию — окно показывает пометку «обновлено».
updated: bool = False
class AnnouncementAck(BaseModel):
# Версия, которую игрок видел и закрыл (AnnouncementRead.revision).
revision: int = Field(ge=1)
# ─── Партии ────────────────────────────────────────────────────────────────── # ─── Партии ──────────────────────────────────────────────────────────────────
class RandomizeRequest(BaseModel): class RandomizeRequest(BaseModel):
@@ -279,42 +175,31 @@ class MatchCreate(BaseModel):
# Этап 2 (завершение): места, комментарии, причина победы. # Этап 2 (завершение): места, комментарии, причина победы.
class MatchFinishParticipant(BaseModel): class MatchFinishParticipant(BaseModel):
user_id: int user_id: int
place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated) place: int = Field(ge=1)
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
comment: str | None = None comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении faction_id: int | None = None # опц. смена фракции при завершении
objectives: Count | None = None # маркеры целей на конец партии (необязательно)
worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0
class MatchFinish(BaseModel): class MatchFinish(BaseModel):
participants: list[MatchFinishParticipant] participants: list[MatchFinishParticipant]
win_reason: WinReason win_reason: WinReason
end_round: EndRound | None = None # раунд, в котором партия закончилась
overall_comment: str | None = None overall_comment: str | None = None
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
expected_version: str | None = None
# Полный участник (правка результатов завершённой партии). # Полный участник (правка завершённой партии админом).
class ParticipantInput(BaseModel): class ParticipantInput(BaseModel):
user_id: int user_id: int
faction_id: int faction_id: int
place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated) place: int = Field(ge=1)
eliminated: bool = False
was_random: bool = False was_random: bool = False
comment: str | None = None comment: str | None = None
objectives: Count | None = None
worlds: Count | None = None
class MatchUpdate(BaseModel): class MatchUpdate(BaseModel):
played_at: date | None = None played_at: date | None = None
overall_comment: str | None = None overall_comment: str | None = None
win_reason: WinReason | None = None win_reason: WinReason | None = None
end_round: EndRound | None = None
participants: list[ParticipantInput] | None = None participants: list[ParticipantInput] | None = None
expected_version: str | None = None # оптимистичная блокировка
class MatchParticipantRead(BaseModel): class MatchParticipantRead(BaseModel):
@@ -323,44 +208,8 @@ class MatchParticipantRead(BaseModel):
faction_id: int faction_id: int
faction_name: str faction_name: str
place: int | None = None place: int | None = None
eliminated: bool = False
was_random: bool was_random: bool
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
avatar_url: str | None = None
class AttachmentRead(BaseModel):
id: int
kind: str # 'photo' (задел под видео)
url: str
mime_type: str
size_bytes: int
created_at: str
class MatchFinishDraftData(BaseModel):
"""Состояние формы завершения: блоки мест (внутри блока — ничья), выбывшие,
комментарии об игроках и причина победы. Промежуточное состояние, поэтому
места не валидируются — человек раскладывает их постепенно."""
blocks: list[list[int]] = []
eliminated: list[int] = []
comments: dict[str, str] = {}
win_reason: WinReason | None = None
overall_comment: str | None = None
end_round: EndRound | None = None
# Ключ — user_id строкой (как у comments); незаполненные поля в словарь не попадают.
objectives: dict[str, Count] = {}
worlds: dict[str, Count] = {}
class MatchFinishDraftRead(BaseModel):
data: MatchFinishDraftData
updated_by: int | None = None
updated_by_nickname: str | None = None
updated_at: str
class MatchRead(BaseModel): class MatchRead(BaseModel):
@@ -372,20 +221,12 @@ class MatchRead(BaseModel):
finished_at: str | None = None finished_at: str | None = None
duration_minutes: int | None = None duration_minutes: int | None = None
win_reason: WinReason | None = None win_reason: WinReason | None = None
end_round: int | None = None
# Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии:
# фронт берёт лимит отсюда, а не повторяет правило у себя.
nine_rounds_rule: bool = False
max_rounds: int
player_count: int player_count: int
overall_comment: str | None = None overall_comment: str | None = None
created_by: int created_by: int
can_modify: bool = False # может ли текущий зритель править/завершать партию can_modify: bool = False # может ли текущий зритель править/завершать партию
version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно
participants: list[MatchParticipantRead] = [] participants: list[MatchParticipantRead] = []
attachments: list[AttachmentRead] = [] attachments: list[Any] = [] # задел под вложения (всегда пусто в v1)
# Общий черновик формы завершения (только у незавершённой партии).
finish_draft: MatchFinishDraftRead | None = None
# ─── Статистика ────────────────────────────────────────────────────────────── # ─── Статистика ──────────────────────────────────────────────────────────────
@@ -395,8 +236,7 @@ class OverallStats(BaseModel):
wins: int wins: int
win_rate: float win_rate: float
avg_place: float | None = None avg_place: float | None = None
# Рейтинг (Elo, старт 1500) целым числом; None — игрок ещё не сыграл ни одной партии. score: float | None = None
score: int | None = None
class LeaderboardEntry(OverallStats): class LeaderboardEntry(OverallStats):
@@ -404,23 +244,6 @@ class LeaderboardEntry(OverallStats):
nickname: str nickname: str
rank: int | None = None rank: int | None = None
avatar_url: str | None = None avatar_url: str | None = None
# Рейтинг подтверждён: MIN_GAMES+ партий во всём приложении. На странице группы games —
# партии в группе, поэтому статус не выводится из них (и из блока, где стоит строка).
rating_confirmed: bool = False
class MatchHistory(BaseModel):
"""История партий игрока плюс настройки витрины его профиля.
Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так,
как выбрал её владелец, не делая второго запроса за профилем."""
items: list[MatchListItem] = []
total: int
limit: int
offset: int
mode: str
detail: str
class Leaderboard(BaseModel): class Leaderboard(BaseModel):
@@ -433,15 +256,11 @@ class FactionStat(BaseModel):
faction_id: int faction_id: int
code: str code: str
name_ru: str name_ru: str
# Название в предложном падеже — для строки «Чаще всего играет на …».
name_ru_prepositional: str
expansion_code: str expansion_code: str
games: int games: int
wins: int wins: int
win_rate: float win_rate: float
avg_place: float | None = None avg_place: float | None = None
# Средний результат относительно ожидания (S − E) × 100 — метрика лучшей/худшей
# фракции: выше нуля — игрок на ней выступает лучше своих рейтинговых шансов.
score: float | None = None score: float | None = None
@@ -457,14 +276,8 @@ class ProfileStats(BaseModel):
factions: list[FactionStat] = [] factions: list[FactionStat] = []
best_faction: FactionStat | None = None best_faction: FactionStat | None = None
worst_faction: FactionStat | None = None worst_faction: FactionStat | None = None
# Любимая — личный выбор игрока в профиле (не статистика). most_played_faction: FactionStat | None = None
favorite_faction: FactionRead | None = None
# «Чаще всего играет на» — самая игранная по всем партиям, включая рандомные раздачи.
main_faction: FactionStat | None = None
recent_form: list[RecentFormItem] = [] recent_form: list[RecentFormItem] = []
# Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал
# неподтверждённый рейтинг, не дублируя константу на фронте.
min_games: int = 0
class PublicProfile(BaseModel): class PublicProfile(BaseModel):
@@ -491,7 +304,6 @@ class GroupStats(BaseModel):
last_match_at: str | None = None last_match_at: str | None = None
leaderboard: list[LeaderboardEntry] = [] leaderboard: list[LeaderboardEntry] = []
provisional: list[LeaderboardEntry] = [] provisional: list[LeaderboardEntry] = []
inactive: list[LeaderboardEntry] = [] # участники без завершённых партий
faction_meta: list[FactionMeta] = [] faction_meta: list[FactionMeta] = []
min_games: int min_games: int
@@ -502,11 +314,8 @@ class MatchListParticipant(BaseModel):
faction_id: int faction_id: int
faction_name: str faction_name: str
place: int | None = None place: int | None = None
eliminated: bool = False
was_random: bool was_random: bool
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
class MatchListItem(BaseModel): class MatchListItem(BaseModel):
@@ -521,9 +330,6 @@ class MatchListItem(BaseModel):
overall_comment: str | None = None overall_comment: str | None = None
created_by: int created_by: int
participants: list[MatchListParticipant] = [] participants: list[MatchListParticipant] = []
# Изменение общего рейтинга владельца истории за эту партию (один знак после запятой).
# Заполняется только в истории игрока (GET /users/{id}/matches); в списке группы — None.
rating_delta: float | None = None
class MatchList(BaseModel): class MatchList(BaseModel):
@@ -577,7 +383,6 @@ class AdminUserRead(BaseModel):
auth_provider: str auth_provider: str
telegram_id: int | None = None telegram_id: int | None = None
created_at: str created_at: str
has_password: bool = False
class AdminUserUpdate(BaseModel): class AdminUserUpdate(BaseModel):
@@ -585,11 +390,6 @@ class AdminUserUpdate(BaseModel):
is_active: bool | None = None is_active: bool | None = None
class AdminPasswordSet(BaseModel):
# Новый пароль игроку от админа — способ восстановить забытый пароль.
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
class AdminGroupRead(BaseModel): class AdminGroupRead(BaseModel):
id: int id: int
name: str name: str
@@ -647,39 +447,3 @@ class AuditLogList(BaseModel):
items: list[AuditLogItem] = [] items: list[AuditLogItem] = []
limit: int limit: int
offset: int offset: int
# ─── Объявления (админ) ──────────────────────────────────────────────────────
AnnouncementStatus = Literal["planned", "live", "finished"]
class AnnouncementWrite(BaseModel):
# Точные правила (заголовок до 60 символов, текст до 600 видимых) — в
# announcement_service; здесь только отсечка мегабайтных тел.
title: str = Field(max_length=200)
body_html: str = Field(max_length=20_000)
starts_at: datetime
ends_at: datetime
show_to_new_players: bool = True
class AnnouncementUpdate(AnnouncementWrite):
# Показать заново тем, кто уже закрыл (с пометкой «обновлено»).
reshow: bool = False
class AdminAnnouncementRead(BaseModel):
id: int
title: str
body_html: str
starts_at: str
ends_at: str
show_to_new_players: bool
revision: int
status: AnnouncementStatus
# Закрыли текущую версию / сколько активных игроков в адресатах.
seen_count: int
audience_count: int
created_at: str
updated_at: str
+2 -7
View File
@@ -33,13 +33,7 @@ FACTIONS: list[tuple[str, str, str, int]] = [
def seed_reference_data(session: Session) -> None: def seed_reference_data(session: Session) -> None:
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно. """Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно."""
Идёт при каждом старте (entrypoint.sh, lifespan dev), поэтому не перетирает то, что
правят руками: имя существующей фракции меняет админка (admin_service.rename_faction),
и после создания записи источник правды для него — БД (#72). Имя из кода получает
только новая фракция; поправить имя существующей — админкой или миграцией.
Служебные поля (дополнение, порядок) и всё у дополнений синхронизируются с кодом."""
code_to_expansion: dict[str, Expansion] = {} code_to_expansion: dict[str, Expansion] = {}
for code, name_ru, is_base, order in EXPANSIONS: for code, name_ru, is_base, order in EXPANSIONS:
@@ -67,6 +61,7 @@ def seed_reference_data(session: Session) -> None:
) )
session.add(fac) session.add(fac)
else: else:
fac.name_ru = name_ru
fac.expansion_id = expansion.id # type: ignore[assignment] fac.expansion_id = expansion.id # type: ignore[assignment]
fac.sort_order = order fac.sort_order = order
@@ -42,10 +42,6 @@ def _root() -> Path:
return Path(settings.achievements_dir) return Path(settings.achievements_dir)
# Формат slug — то, что выдаёт _slugify: только латиница, цифры и дефис.
_SLUG_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,63}")
def _slugify(name: str) -> str: def _slugify(name: str) -> str:
text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower()) text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower())
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-") slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
@@ -53,11 +49,6 @@ def _slugify(name: str) -> str:
def _dir(slug: str) -> Path: def _dir(slug: str) -> Path:
"""Папка ачивки. Slug приходит из URL, поэтому формат проверяем здесь: без этого
`..` или `a/b` увели бы файловые операции (вплоть до rmtree в delete) за пределы
каталога ачивок."""
if not _SLUG_RE.fullmatch(slug or ""):
raise NotFoundError("Ачивка не найдена.")
return _root() / slug return _root() / slug
+1 -23
View File
@@ -6,7 +6,6 @@ from typing import Any
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.errors import ( from app.core.errors import (
ConflictError,
InvalidCredentialsError, InvalidCredentialsError,
NicknameTakenError, NicknameTakenError,
NotFoundError, NotFoundError,
@@ -62,20 +61,8 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
return user return user
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
"""Новый пароль игроку (восстановление забытого). Пароль админа через панель не
меняется: он задаётся ADMIN_PASSWORD в .env и применяется к существующему админу
командой `python -m app.bootstrap --reset-admin-password` (#73)."""
user = session.get(User, user_id)
if user is None:
raise NotFoundError("Пользователь не найден.")
if user.role != "player":
raise ValidationError("Пароль администратора здесь не меняется.")
return user_service.set_password(session, user, new_password)
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно: # Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
# логика вынесена в routers/dev_admin.py (файл исключён из прод-образа). # логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
# ─── Группы ────────────────────────────────────────────────────────────────── # ─── Группы ──────────────────────────────────────────────────────────────────
@@ -88,10 +75,6 @@ def delete_group(session: Session, group_id: int) -> None:
group = session.get(Group, group_id) group = session.get(Group, group_id)
if group is None: if group is None:
raise NotFoundError("Группа не найдена.") raise NotFoundError("Группа не найдена.")
# matches.group_id — ON DELETE RESTRICT, поэтому группу с партиями БД не отдаст.
# Проверяем сами, иначе IntegrityError уходит наружу голым 500 без AppError-конверта.
if session.exec(select(Match.id).where(Match.group_id == group_id)).first() is not None:
raise ConflictError("Нельзя удалить группу, в которой есть партии. Сначала удалите их.")
session.delete(group) session.delete(group)
session.commit() session.commit()
@@ -151,16 +134,11 @@ def rename_faction(session: Session, faction_id: int, name_ru: str) -> Faction:
def delete_match(session: Session, match_id: int) -> None: def delete_match(session: Session, match_id: int) -> None:
from app.services import attachment_service # избегаем цикла импорта
match = session.get(Match, match_id) match = session.get(Match, match_id)
if match is None: if match is None:
raise NotFoundError("Партия не найдена.") raise NotFoundError("Партия не найдена.")
session.delete(match) session.delete(match)
session.commit() session.commit()
# Как и в игроцком пути (match_service.delete_match): строки вложений уходят
# каскадом, а файлы с тома нужно убрать руками, иначе они остаются навсегда.
attachment_service.delete_match_files(match_id)
# ─── Журнал аудита ─────────────────────────────────────────────────────────── # ─── Журнал аудита ───────────────────────────────────────────────────────────
@@ -1,336 +0,0 @@
"""Объявления администрации (#84): очистка текста, период показа, кому и что показать.
Объявление видно игроку, пока идёт его период и игрок не закрыл текущую версию. Закрытие
(«Понятно») пишет отметку с номером версии; правка с «показать заново» поднимает версию —
и закрывшие прежнюю увидят объявление снова, с пометкой «обновлено».
Текст хранится HTML-ом из редактора админки, но только после очистки по белому списку
(sanitize_body): фронт вставляет его без экранирования, так что это единственный барьер
между полем редактора и страницей игрока.
"""
from __future__ import annotations
import html
from datetime import datetime, timezone
from html.parser import HTMLParser
from sqlalchemy import func
from sqlmodel import Session, select
from app.core.errors import NotFoundError, ValidationError
from app.core.timeutil import utcnow
from app.models import Announcement, AnnouncementView, User
TITLE_MAX = 60
TEXT_MAX = 600 # видимых символов, без разметки
# ─── Очистка HTML ────────────────────────────────────────────────────────────
# Что оставляем и во что превращаем. Редактор (contenteditable + execCommand) в разных
# браузерах пишет то <b>, то <strong>, абзацы — <div> или <p>; приводим к одному виду.
_TAGS = {
"b": "b",
"strong": "b",
"i": "em",
"em": "em",
"mark": "mark",
"p": "p",
"div": "p",
"br": "br",
}
# Теги, которые выбрасываются вместе с содержимым: их текст не предназначен для показа.
_DROP_WITH_CONTENT = {
"script", "style", "template", "noscript", "iframe", "object", "embed",
"svg", "math", "head", "title", "textarea", "select",
}
_VOID = {"br"}
class _Sanitizer(HTMLParser):
"""Пересобирает HTML из разобранных токенов: теги — только из белого списка и без
атрибутов (кроме class="red" у <mark>), весь текст экранируется заново. Всё, что
парсер не распознал как тег из списка, становится текстом или пропадает."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.out: list[str] = []
self.text: list[str] = []
self.stack: list[str] = []
self.drop_depth = 0
def _pop(self) -> str:
top = self.stack.pop()
last = self.out[-1] if self.out else ""
# Точное сравнение: startswith("<b") поймал бы и <br>.
if last == f"<{top}>" or last.startswith(f"<{top} "):
self.out.pop() # пустая пара (<p></p> от вложенных <div>) — выбрасываем
else:
self.out.append(f"</{top}>")
return top
def _close_to(self, tag: str) -> None:
while self.stack:
if self._pop() == tag:
return
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in _DROP_WITH_CONTENT:
self.drop_depth += 1
return
if self.drop_depth or tag not in _TAGS:
return
name = _TAGS[tag]
if name in _VOID:
self.out.append(f"<{name}>")
return
if name == "p" and "p" in self.stack:
# Абзац внутри абзаца (вложенные <div> из contenteditable) — закрываем прежний.
self._close_to("p")
opening = f"<{name}>"
if name == "mark":
classes = next((v or "" for k, v in attrs if k == "class"), "").split()
if "red" in classes:
opening = '<mark class="red">'
self.stack.append(name)
self.out.append(opening)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
# <br/> и прочие самозакрытые: содержимого нет, так что drop-теги тут ни при чём.
if not self.drop_depth and _TAGS.get(tag) in _VOID:
self.out.append(f"<{_TAGS[tag]}>")
def handle_endtag(self, tag: str) -> None:
if tag in _DROP_WITH_CONTENT:
self.drop_depth = max(0, self.drop_depth - 1)
return
if self.drop_depth:
return
name = _TAGS.get(tag)
if name and name not in _VOID and name in self.stack:
self._close_to(name)
def handle_data(self, data: str) -> None:
if self.drop_depth:
return
self.text.append(data)
self.out.append(html.escape(data, quote=False))
def result(self) -> tuple[str, str]:
self.close()
while self.stack:
self._pop()
return "".join(self.out), "".join(self.text)
def sanitize_body(raw: str) -> tuple[str, str]:
"""(очищенный HTML, видимый текст). Видимый текст нужен для проверки длины."""
parser = _Sanitizer()
parser.feed(raw)
return parser.result()
# ─── Проверки и время ─────────────────────────────────────────────────────────
def _now() -> datetime:
# В SQLite моменты лежат наивными (UTC) — сравниваем с наивным же «сейчас».
return utcnow().replace(tzinfo=None)
def _naive_utc(dt: datetime) -> datetime:
"""Момент из запроса → наивный UTC. Время без смещения считаем UTC."""
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt.replace(second=0, microsecond=0)
def _clean_fields(
title: str, body_html: str, starts_at: datetime, ends_at: datetime
) -> tuple[str, str, datetime, datetime]:
title = title.strip()
if not title:
raise ValidationError("Заголовок не может быть пустым.")
if len(title) > TITLE_MAX:
raise ValidationError(f"Заголовок длиннее {TITLE_MAX} символов.")
clean_html, text = sanitize_body(body_html)
text = text.strip()
if not text:
raise ValidationError("Текст объявления не может быть пустым.")
if len(text) > TEXT_MAX:
raise ValidationError(f"Текст длиннее {TEXT_MAX} символов ({len(text)}).")
start, end = _naive_utc(starts_at), _naive_utc(ends_at)
if end <= start:
raise ValidationError("Конец показа должен быть позже начала.")
return title, clean_html, start, end
def status_of(a: Announcement, now: datetime | None = None) -> str:
now = now or _now()
if now < a.starts_at:
return "planned"
if now >= a.ends_at:
return "finished"
return "live"
def get(session: Session, announcement_id: int) -> Announcement:
a = session.get(Announcement, announcement_id)
if a is None:
raise NotFoundError("Объявление не найдено.")
return a
# ─── Админка ─────────────────────────────────────────────────────────────────
def create(
session: Session,
*,
title: str,
body_html: str,
starts_at: datetime,
ends_at: datetime,
show_to_new_players: bool,
actor_id: int | None,
) -> Announcement:
title, body_html, start, end = _clean_fields(title, body_html, starts_at, ends_at)
if end <= _now():
raise ValidationError("Период показа уже закончился.")
a = Announcement(
title=title,
body_html=body_html,
starts_at=start,
ends_at=end,
show_to_new_players=show_to_new_players,
created_by=actor_id,
)
session.add(a)
session.flush()
return a
def update(
session: Session,
announcement_id: int,
*,
title: str,
body_html: str,
starts_at: datetime,
ends_at: datetime,
show_to_new_players: bool,
reshow: bool,
) -> Announcement:
a = get(session, announcement_id)
a.title, a.body_html, a.starts_at, a.ends_at = _clean_fields(
title, body_html, starts_at, ends_at
)
a.show_to_new_players = show_to_new_players
if reshow:
# Новая версия: отметки о закрытии прежней больше не прячут объявление.
a.revision += 1
a.updated_at = utcnow()
session.add(a)
session.flush()
return a
def stop(session: Session, announcement_id: int) -> Announcement:
"""«Снять с показа»: период заканчивается сейчас. Только у идущего объявления —
у запланированного конец раньше начала нарушил бы период; его просто удаляют."""
a = get(session, announcement_id)
now = _now()
if status_of(a, now) != "live" or now <= a.starts_at:
raise ValidationError("Снять с показа можно только идущее объявление.")
a.ends_at = now
a.updated_at = utcnow()
session.add(a)
session.flush()
return a
def delete(session: Session, announcement_id: int) -> None:
session.delete(get(session, announcement_id))
session.flush()
def _audience_filter(a: Announcement):
"""Условия «игрок — адресат объявления» (для счётчиков и для показа)."""
conds = [User.role == "player", User.is_active.is_(True)] # type: ignore[union-attr]
if not a.show_to_new_players:
conds.append(User.created_at <= a.starts_at)
return conds
def _admin_item(session: Session, a: Announcement, now: datetime) -> dict:
"""Объявление со статусом и счётчиком «закрыли N из M»: N — закрывшие текущую
версию, M — сколько активных игроков сейчас в адресатах."""
audience = _audience_filter(a)
audience_count = session.exec(select(func.count()).select_from(User).where(*audience)).one()
seen_count = session.exec(
select(func.count())
.select_from(AnnouncementView)
.join(User, User.id == AnnouncementView.user_id)
.where(
AnnouncementView.announcement_id == a.id,
AnnouncementView.revision >= a.revision,
*audience,
)
).one()
return {"a": a, "status": status_of(a, now), "seen": seen_count, "audience": audience_count}
def admin_item(session: Session, announcement_id: int) -> dict:
return _admin_item(session, get(session, announcement_id), _now())
def list_admin(session: Session) -> list[dict]:
"""Все объявления, новые сверху. Объявлений единицы, поэтому счётчики — по паре
запросов на объявление."""
now = _now()
rows = session.exec(
select(Announcement).order_by(Announcement.starts_at.desc(), Announcement.id.desc()) # type: ignore[union-attr]
).all()
return [_admin_item(session, a, now) for a in rows]
# ─── Игрок ───────────────────────────────────────────────────────────────────
def pending_for_user(session: Session, user: User) -> list[tuple[Announcement, bool]]:
"""Что показать игроку сейчас — от старого к новому. Второй элемент — «обновлено»:
игрок закрывал прежнюю версию этого объявления."""
now = _now()
registered = user.created_at.replace(tzinfo=None) # только что созданный — aware UTC
rows = session.exec(
select(Announcement, AnnouncementView.revision)
.join(
AnnouncementView,
(AnnouncementView.announcement_id == Announcement.id)
& (AnnouncementView.user_id == user.id),
isouter=True,
)
.where(Announcement.starts_at <= now, Announcement.ends_at > now)
.order_by(Announcement.starts_at, Announcement.id)
).all()
result = []
for a, seen_revision in rows:
if seen_revision is not None and seen_revision >= a.revision:
continue
if not a.show_to_new_players and registered > a.starts_at:
continue
result.append((a, seen_revision is not None))
return result
def acknowledge(session: Session, user_id: int, announcement_id: int, revision: int) -> None:
"""Игрок закрыл объявление в версии revision. Версию берём с клиента: если админ
выпустил новую, пока окно было открыто, игрок закрыл старую и новую ещё увидит."""
a = get(session, announcement_id)
revision = min(revision, a.revision)
view = session.get(AnnouncementView, (announcement_id, user_id))
if view is None:
session.add(
AnnouncementView(announcement_id=announcement_id, user_id=user_id, revision=revision)
)
elif revision > view.revision:
view.revision = revision
view.closed_at = utcnow()
session.add(view)
session.flush()
@@ -1,98 +0,0 @@
"""Вложения партии (фото). Файлы — на томе settings.upload_dir, в БД метаданные.
Общий сервис для игрового и админского путей (роутеры лишь строят URL с нужным префиксом).
"""
from __future__ import annotations
import shutil
from pathlib import Path
from sqlmodel import Session, select
from app.core.config import settings
from app.core.errors import ConflictError, NotFoundError
from app.models import Match, MatchAttachment, User
MAX_ATTACHMENTS = 10
MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 # 10 МБ
_SUBDIR = "matches"
def list_for_match(session: Session, match_id: int) -> list[MatchAttachment]:
return list(
session.exec(
select(MatchAttachment)
.where(MatchAttachment.match_id == match_id)
.order_by(MatchAttachment.created_at, MatchAttachment.id)
).all()
)
def count(session: Session, match_id: int) -> int:
return len(
session.exec(
select(MatchAttachment.id).where(MatchAttachment.match_id == match_id)
).all()
)
def file_path(att: MatchAttachment) -> Path:
return Path(settings.upload_dir) / att.storage_path
def add_photo(
session: Session, match: Match, user: User, content: bytes, ext: str, mime: str
) -> MatchAttachment:
from app.services import match_service # избегаем цикла импорта
if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type]
raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.")
att = MatchAttachment(
match_id=match.id, # type: ignore[arg-type]
uploaded_by=user.id,
kind="photo",
storage_path="",
mime_type=mime,
size_bytes=len(content),
)
session.add(att)
session.flush() # получаем id для имени файла
rel = f"{_SUBDIR}/{match.id}/{att.id}.{ext}"
abs_path = Path(settings.upload_dir) / rel
abs_path.parent.mkdir(parents=True, exist_ok=True)
abs_path.write_bytes(content)
att.storage_path = rel
session.add(att)
match_service.touch(session, match)
session.commit()
session.refresh(att)
return att
def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachment:
att = session.get(MatchAttachment, att_id)
if att is None or att.match_id != match_id:
raise NotFoundError("Вложение не найдено.")
return att
def delete(session: Session, match: Match, att_id: int) -> None:
from app.services import match_service # избегаем цикла импорта
att = get_for_match(session, match.id, att_id) # type: ignore[arg-type]
path = file_path(att)
if path.exists():
try:
path.unlink()
except OSError:
pass
session.delete(att)
match_service.touch(session, match)
session.commit()
def delete_match_files(match_id: int) -> None:
"""Удалить всю папку медиа партии (при удалении партии; строки БД уйдут каскадом)."""
folder = Path(settings.upload_dir) / _SUBDIR / str(match_id)
if folder.is_dir():
shutil.rmtree(folder, ignore_errors=True)
-24
View File
@@ -18,27 +18,3 @@ def list_factions(session: Session) -> list[Faction]:
select(Faction).order_by(Faction.expansion_id, Faction.sort_order) select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
).all() ).all()
) )
# Предложный падеж названий фракций для фразы «Чаще всего играет на …».
# Словарь в коде, а не колонка в БД: набор фракций задан игрой и не меняется.
# Фракция, добавленная админом мимо словаря, выводится в именительном падеже.
_PREPOSITIONAL: dict[str, str] = {
"orks": "Орках",
"ultramarines": "Ультрамаринах",
"eldar": "Эльдарах",
"chaos": "Хаоситах",
"astra_militarum": "Имперской гвардии",
"tau": "Тау",
"necrons": "Некронах",
"tyranids": "Тиранидах",
"inquisition": "Инквизиции",
"sisters_of_battle": "Сёстрах битвы",
"drukhari": "Друкхари",
"adeptus_mechanicus": "Адептус Механикус",
}
def prepositional(code: str, name_ru: str) -> str:
"""Название фракции в предложном падеже; вне словаря — именительный как есть."""
return _PREPOSITIONAL.get(code, name_ru)
-10
View File
@@ -97,16 +97,6 @@ def rename_group(session: Session, group: Group, name: str) -> Group:
return group return group
def set_nine_rounds_rule(session: Session, group: Group, enabled: bool) -> Group:
"""Хоумрул «9 раундов при 5–6 игроках». Действует на партии, начатые после смены:
уже начатые хранят свой снимок (Match.nine_rounds_rule)."""
group.nine_rounds_rule = enabled
session.add(group)
session.commit()
session.refresh(group)
return group
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group: def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
valid = set(_valid_non_base_expansion_ids(session, expansion_ids)) valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
current = session.exec( current = session.exec(
-131
View File
@@ -1,131 +0,0 @@
"""Приглашения в группу: создание (любым участником), список, принятие, отклонение.
Хранятся только ожидающие приглашения; ответ удаляет строку (accept → создаётся
GroupMember). Лимит участников — общий с membership_service.MAX_GROUP_SIZE.
"""
from __future__ import annotations
from sqlalchemy.orm import aliased
from sqlmodel import Session, select
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.core.timeutil import iso_utc
from app.models import Group, GroupInvitation, GroupMember, User
from app.services.membership_service import MAX_GROUP_SIZE
def _member_exists(session: Session, group_id: int, user_id: int) -> bool:
return (
session.exec(
select(GroupMember.id).where(
GroupMember.group_id == group_id, GroupMember.user_id == user_id
)
).first()
is not None
)
def _counts(session: Session, group_id: int) -> tuple[int, int]:
members = len(
session.exec(select(GroupMember.id).where(GroupMember.group_id == group_id)).all()
)
pending = len(
session.exec(
select(GroupInvitation.id).where(GroupInvitation.group_id == group_id)
).all()
)
return members, pending
def create_invitation(
session: Session, group: Group, inviter: User, nickname: str
) -> tuple[GroupInvitation, 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("Игрок с таким ником не найден.")
if _member_exists(session, group.id, user.id): # type: ignore[arg-type]
raise ConflictError("Игрок уже в группе.")
existing = session.exec(
select(GroupInvitation).where(
GroupInvitation.group_id == group.id, GroupInvitation.user_id == user.id
)
).first()
if existing is not None:
raise ConflictError("Игрок уже приглашён в эту группу.")
members, pending = _counts(session, group.id) # type: ignore[arg-type]
if members + pending >= MAX_GROUP_SIZE:
raise ConflictError(f"В группе нет мест (максимум {MAX_GROUP_SIZE}).")
inv = GroupInvitation(
group_id=group.id, user_id=user.id, invited_by_id=inviter.id # type: ignore[arg-type]
)
session.add(inv)
session.commit()
session.refresh(inv)
return inv, user
def list_for_user(session: Session, user_id: int) -> list[dict]:
inviter = aliased(User)
rows = session.exec(
select(GroupInvitation, Group.name, inviter.nickname)
.join(Group, Group.id == GroupInvitation.group_id)
# LEFT JOIN: пригласивший мог быть удалён (invited_by_id → SET NULL).
.join(inviter, inviter.id == GroupInvitation.invited_by_id, isouter=True)
.where(GroupInvitation.user_id == user_id)
.order_by(GroupInvitation.created_at.desc())
).all()
out = []
for inv, gname, inviter_nick in rows:
out.append(
{
"id": inv.id,
"group_id": inv.group_id,
"group_name": gname,
"invited_by_nickname": inviter_nick,
"created_at": iso_utc(inv.created_at),
}
)
return out
def _own_invitation(session: Session, user: User, invitation_id: int) -> GroupInvitation:
inv = session.get(GroupInvitation, invitation_id)
if inv is None or inv.user_id != user.id:
raise NotFoundError("Приглашение не найдено.")
return inv
def accept(session: Session, user: User, invitation_id: int) -> Group:
inv = _own_invitation(session, user, invitation_id)
group = session.get(Group, inv.group_id)
if group is None:
session.delete(inv)
session.commit()
raise NotFoundError("Группа не найдена.")
if not _member_exists(session, group.id, user.id): # type: ignore[arg-type]
members, _ = _counts(session, group.id) # type: ignore[arg-type]
if members >= MAX_GROUP_SIZE:
raise ConflictError(f"В группе нет мест (максимум {MAX_GROUP_SIZE}).")
session.add(
GroupMember(group_id=group.id, user_id=user.id, role="member") # type: ignore[arg-type]
)
if user.active_group_id is None: # первая группа — делаем активной
user.active_group_id = group.id
session.add(user)
session.delete(inv)
session.commit()
return group
def decline(session: Session, user: User, invitation_id: int) -> None:
inv = _own_invitation(session, user, invitation_id)
session.delete(inv)
session.commit()
+34 -302
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import random import random
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, datetime, timezone
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -16,15 +16,12 @@ from app.core.errors import (
NotFoundError, NotFoundError,
ValidationError, ValidationError,
) )
from app.core.timeutil import app_today, iso_utc, utcnow from app.core.timeutil import app_today
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User from app.models import Faction, GroupMember, Match, MatchParticipant, User
from app.services import group_service from app.services import group_service
from app.services.scoring import EXTENDED_ROUNDS, max_rounds
MAX_MATCH_PLAYERS = 6 MAX_MATCH_PLAYERS = 6
LAST_STANDING = "last_standing" WIN_REASONS = ("objectives", "worlds", "plastic", "resources")
WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING)
MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API)
@dataclass @dataclass
@@ -41,26 +38,24 @@ class FinishInput:
"""Результат участника на этапе завершения.""" """Результат участника на этапе завершения."""
user_id: int user_id: int
place: int | None = None # None у выбывшего (eliminated) — место проставится авто place: int
eliminated: bool = False
comment: str | None = None comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении faction_id: int | None = None # опц. смена фракции при завершении
objectives: int | None = None
worlds: int | None = None
@dataclass @dataclass
class ParticipantInput: class ParticipantInput:
"""Полный участник (для правки результатов завершённой партии).""" """Полный участник (для правки завершённой партии админом)."""
user_id: int user_id: int
faction_id: int faction_id: int
place: int | None = None place: int
eliminated: bool = False
was_random: bool = False was_random: bool = False
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def round_to_30(minutes: float) -> int: def round_to_30(minutes: float) -> int:
@@ -75,31 +70,6 @@ def get_match(session: Session, match_id: int) -> Match:
return match return match
def match_version(match: Match) -> str:
"""Версия партии для оптимистичной блокировки (меняется при любом изменении)."""
return iso_utc(match.updated_at)
def touch(session: Session, match: Match) -> None:
"""Пометить партию изменённой (без commit).
Версия для оптимистичной блокировки — это updated_at, а onupdate у SQLAlchemy
срабатывает только при реальном UPDATE строки matches. Правки, меняющие лишь
связанные сущности (участники, вложения), строку не трогают, поэтому каждая
такая точка обязана позвать touch — иначе конкурентная запись пройдёт молча."""
match.updated_at = utcnow()
session.add(match)
def assert_version(match: Match, expected: str | None) -> None:
"""Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию)."""
if expected is not None and expected != match_version(match):
raise ConflictError(
"Партия уже изменена на другом устройстве — обновите страницу.",
code="STALE_WRITE",
)
def participants_detail( def participants_detail(
session: Session, match_id: int session: Session, match_id: int
) -> list[tuple[MatchParticipant, User, Faction]]: ) -> list[tuple[MatchParticipant, User, Faction]]:
@@ -145,59 +115,6 @@ def _validate_ranking(places: list[int]) -> None:
expected = current + tie expected = current + tie
def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int, int]:
"""rows: (user_id, place, eliminated) → {user_id: место}.
Выжившим (не eliminated) валидируем competition ranking (1,2,2,4). Выбывшим
проставляем общее последнее место = len(survivors)+1 (ничья последнего ранга),
чтобы подсчёт очков считал их как последнее место без отдельных правок SQL."""
survivors = [(uid, place) for uid, place, elim in rows if not elim]
if not survivors:
raise InvalidRankingError("Хотя бы один игрок должен остаться в партии (с местом).")
if any(place is None for _uid, place in survivors):
raise InvalidRankingError("Укажите место каждому не выбывшему игроку.")
_validate_ranking([place for _uid, place in survivors]) # type: ignore[misc]
elim_place = len(survivors) + 1
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
# ─── Итоги партии для рейтинга ────────────────────────────────────────────────
# Сервер проверяет только диапазоны и явные противоречия. Согласованность итогов между
# собой (тип победы и цели лидеров и т.п.) — предупреждения формы, а не отказ.
def _check_end_round(end_round: int | None, player_count: int, nine_rounds_rule: bool) -> None:
if end_round is None:
return
rmax = max_rounds(player_count, nine_rounds_rule)
if not 1 <= end_round <= rmax:
raise ValidationError(f"Раунд окончания — от 1 до {rmax}.")
def _worlds_of(eliminated: bool, worlds: int | None) -> int | None:
"""У выбывшего миров нет: пустое поле записывается нулём, иное число — противоречие."""
if not eliminated:
return worlds
if worlds:
raise ValidationError("У выбывшего игрока не может быть миров.")
return 0
def _check_last_standing(win_reason: str | None, eliminated: list[bool]) -> None:
"""Причина «последний выживший» ⇔ невыбывший участник ровно один (решение по #22).
Выбрать её вручную нельзя, и забыть поставить тоже: форма проставляет её сама,
сервер лишь не пропускает расхождение."""
alone = sum(1 for e in eliminated if not e) == 1
if alone and win_reason != LAST_STANDING:
raise ValidationError(
"Остался один невыбывший игрок — причина победы «последний выживший»."
)
if not alone and win_reason == LAST_STANDING:
raise ValidationError(
"«Последний выживший» возможен, только когда все, кроме победителя, выбыли."
)
def _group_member_ids(session: Session, group_id: int) -> set[int]: def _group_member_ids(session: Session, group_id: int) -> set[int]:
return { return {
m.user_id m.user_id
@@ -212,28 +129,16 @@ def _validate_roster_basics(
group_id: int, group_id: int,
user_ids: list[int], user_ids: list[int],
faction_ids: list[int], faction_ids: list[int],
*,
keep_user_ids: set[int] | None = None,
keep_faction_ids: set[int] | None = None,
) -> None: ) -> None:
"""Состав партии: размер, отсутствие дублей, принадлежность группе.
keep_* — то, что уже записано в правимой партии: такие игроки и фракции проходят
независимо от текущего состава группы. Иначе отключённое дополнение или ушедший из
группы игрок делали бы старую партию неисправимой навсегда."""
if len(user_ids) < 2: if len(user_ids) < 2:
raise ValidationError("В партии должно быть не менее 2 участников.") raise ValidationError("В партии должно быть не менее 2 участников.")
if len(user_ids) > MAX_MATCH_PLAYERS: if len(user_ids) > MAX_MATCH_PLAYERS:
raise ValidationError(f"В партии не может быть больше {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): if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids):
raise DuplicateParticipantError() raise DuplicateParticipantError()
allowed_users = _group_member_ids(session, group_id) | (keep_user_ids or set()) if not set(user_ids).issubset(_group_member_ids(session, group_id)):
if not set(user_ids).issubset(allowed_users):
raise ValidationError("Все участники должны состоять в группе.") raise ValidationError("Все участники должны состоять в группе.")
allowed_factions = group_service.available_faction_ids(session, group_id) | ( if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)):
keep_faction_ids or set()
)
if not set(faction_ids).issubset(allowed_factions):
raise FactionNotAvailableError() raise FactionNotAvailableError()
@@ -251,16 +156,13 @@ def create_match(
session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster] session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster]
) )
now = utcnow() now = _utcnow()
group = group_service.get_group(session, group_id)
match = Match( match = Match(
group_id=group_id, group_id=group_id,
status="in_progress", status="in_progress",
played_at=app_today(), # дата игры — в поясе приложения (+3) played_at=app_today(), # дата игры — в поясе приложения (+3)
started_at=now, started_at=now,
player_count=len(roster), player_count=len(roster),
# Снимок: смена настройки группы потом не переписывает лимит раундов этой партии.
nine_rounds_rule=group.nine_rounds_rule,
created_by=creator.id, # type: ignore[arg-type] created_by=creator.id, # type: ignore[arg-type]
) )
session.add(match) session.add(match)
@@ -289,17 +191,13 @@ def finish_match(
*, *,
finish: list[FinishInput], finish: list[FinishInput],
win_reason: str, win_reason: str,
end_round: int | None = None,
overall_comment: str | None = None, overall_comment: str | None = None,
overall_comment_set: bool = False, overall_comment_set: bool = False,
expected_version: str | None = None,
) -> Match: ) -> Match:
assert_version(match, expected_version)
if match.status != "in_progress": if match.status != "in_progress":
raise ConflictError("Партия уже завершена.") raise ConflictError("Партия уже завершена.")
if win_reason not in WIN_REASONS: if win_reason not in WIN_REASONS:
raise ValidationError("Укажите корректную причину победы.") raise ValidationError("Укажите корректную причину победы.")
_check_end_round(end_round, match.player_count, match.nine_rounds_rule)
existing = { existing = {
p.user_id: p p.user_id: p
@@ -321,22 +219,17 @@ def finish_match(
if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)): if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)):
raise FactionNotAvailableError() raise FactionNotAvailableError()
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish]) _validate_ranking([f.place for f in finish])
_check_last_standing(win_reason, [f.eliminated for f in finish])
worlds = {f.user_id: _worlds_of(f.eliminated, f.worlds) for f in finish}
for f in finish: for f in finish:
p = existing[f.user_id] p = existing[f.user_id]
p.place = places[f.user_id] p.place = f.place
p.eliminated = f.eliminated
p.comment = f.comment or None p.comment = f.comment or None
p.objectives = f.objectives
p.worlds = worlds[f.user_id]
if f.faction_id is not None: if f.faction_id is not None:
p.faction_id = f.faction_id p.faction_id = f.faction_id
session.add(p) session.add(p)
now = utcnow() now = _utcnow()
match.finished_at = now match.finished_at = now
started = match.started_at started = match.started_at
if started is not None: if started is not None:
@@ -347,123 +240,15 @@ def finish_match(
match.duration_minutes = round_to_30(elapsed_min) match.duration_minutes = round_to_30(elapsed_min)
match.status = "finished" match.status = "finished"
match.win_reason = win_reason match.win_reason = win_reason
match.end_round = end_round
if overall_comment_set: if overall_comment_set:
match.overall_comment = overall_comment or None match.overall_comment = overall_comment or None
session.add(match) session.add(match)
# Содержимое черновика уже в результатах — второй источник правды не нужен.
clear_finish_draft(session, match.id) # type: ignore[arg-type]
session.commit() session.commit()
session.refresh(match) session.refresh(match)
return match return match
# ─── Черновик завершения (совместное заполнение формы) ────────────────────────
def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None:
return session.get(MatchFinishDraft, match_id)
def _draft_counts(value: object) -> dict[str, int]:
"""Цели/миры черновика: {user_id строкой: число}. Пустые поля в словарь не попадают."""
if not isinstance(value, dict):
raise ValidationError("Некорректный черновик.")
out: dict[str, int] = {}
for k, v in value.items():
if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= MAX_COUNT:
raise ValidationError("Некорректный черновик.")
out[str(k)] = v
return out
def _validate_draft(session: Session, match: Match, data: dict) -> dict:
"""Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками
партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек
раскладывает их постепенно, и промежуточное состояние может быть любым. По той же
причине не проверяется и правило «последнего выжившего»."""
if not isinstance(data, dict):
raise ValidationError("Некорректный черновик.")
blocks = data.get("blocks") or []
eliminated = data.get("eliminated") or []
comments = data.get("comments") or {}
win_reason = data.get("win_reason")
end_round = data.get("end_round")
if not isinstance(blocks, list) or not isinstance(eliminated, list):
raise ValidationError("Некорректный черновик.")
if not isinstance(comments, dict):
raise ValidationError("Некорректный черновик.")
if win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.")
if end_round is not None and (
isinstance(end_round, bool)
or not isinstance(end_round, int)
or not 1 <= end_round <= EXTENDED_ROUNDS
):
raise ValidationError("Некорректный черновик.")
objectives = _draft_counts(data.get("objectives") or {})
worlds = _draft_counts(data.get("worlds") or {})
participant_ids = {
p.user_id
for p in session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all()
}
listed: list[int] = []
for block in blocks:
if not isinstance(block, list):
raise ValidationError("Некорректный черновик.")
listed.extend(block)
listed.extend(eliminated)
if any(not isinstance(uid, int) for uid in listed):
raise ValidationError("Некорректный черновик.")
if set(listed) - participant_ids:
raise ValidationError("В черновике есть игроки не из этой партии.")
overall = data.get("overall_comment")
if overall is not None and not isinstance(overall, str):
raise ValidationError("Некорректный черновик.")
return {
"blocks": blocks,
"eliminated": eliminated,
"comments": {str(k): str(v) for k, v in comments.items()},
"win_reason": win_reason,
"overall_comment": overall,
"end_round": end_round,
"objectives": objectives,
"worlds": worlds,
}
def save_finish_draft(
session: Session, match: Match, user: User, data: dict
) -> MatchFinishDraft:
"""Сохранить общий черновик формы завершения (последняя запись побеждает).
Версию партии (updated_at) намеренно НЕ двигаем: иначе «Завершить» у второго
участника ловил бы STALE_WRITE на каждую чужую правку черновика."""
if match.status != "in_progress":
raise ConflictError("Партия уже завершена.")
payload = _validate_draft(session, match, data)
draft = session.get(MatchFinishDraft, match.id)
if draft is None:
draft = MatchFinishDraft(match_id=match.id, data=payload, updated_by=user.id)
else:
draft.data = payload
draft.updated_by = user.id
draft.updated_at = utcnow()
session.add(draft)
session.commit()
session.refresh(draft)
return draft
def clear_finish_draft(session: Session, match_id: int) -> None:
draft = session.get(MatchFinishDraft, match_id)
if draft is not None:
session.delete(draft)
# ─── Права / правка / удаление ──────────────────────────────────────────────── # ─── Права / правка / удаление ────────────────────────────────────────────────
def can_modify(session: Session, match: Match, user: User) -> bool: def can_modify(session: Session, match: Match, user: User) -> bool:
@@ -488,74 +273,29 @@ def update_match(
overall_comment_set: bool = False, overall_comment_set: bool = False,
win_reason: str | None = None, win_reason: str | None = None,
win_reason_set: bool = False, win_reason_set: bool = False,
end_round: int | None = None,
end_round_set: bool = False,
participants: list[ParticipantInput] | None = None, participants: list[ParticipantInput] | None = None,
expected_version: str | None = None,
) -> Match: ) -> Match:
"""Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд. """Правка завершённой партии (админ): полный список участников с местами."""
Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию:
иначе они оседали бы в партии со статусом in_progress, которая остаётся
в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает
только status='finished'). Дату и общий комментарий править можно и по ходу
партии — двойственного состояния они не создают."""
results_touched = participants is not None or win_reason_set or end_round_set
if results_touched and match.status != "finished":
raise ConflictError(
"Результаты незавершённой партии нельзя править — сначала завершите её."
)
assert_version(match, expected_version)
if win_reason_set and win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.")
saved = session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all()
# Проверки — по состоянию партии ПОСЛЕ правки: частичный запрос сверяется
# с тем, что уже записано.
new_reason = win_reason if win_reason_set else match.win_reason
new_end_round = end_round if end_round_set else match.end_round
new_count = len(participants) if participants is not None else match.player_count
if end_round_set or participants is not None:
_check_end_round(new_end_round, new_count, match.nine_rounds_rule)
places: dict[int, int] = {}
worlds: dict[int, int | None] = {}
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],
keep_user_ids={p.user_id for p in saved},
keep_faction_ids={p.faction_id for p in saved},
)
places = _resolve_finish_places(
[(p.user_id, p.place, p.eliminated) for p in participants]
)
worlds = {p.user_id: _worlds_of(p.eliminated, p.worlds) for p in participants}
if participants is not None or win_reason_set:
flags = (
[p.eliminated for p in participants]
if participants is not None
else [p.eliminated for p in saved]
)
_check_last_standing(new_reason, flags)
if played_at is not None: if played_at is not None:
match.played_at = played_at match.played_at = played_at
if overall_comment_set: if overall_comment_set:
match.overall_comment = overall_comment or None match.overall_comment = overall_comment or None
if win_reason_set: if win_reason_set:
if win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.")
match.win_reason = win_reason match.win_reason = win_reason
if end_round_set:
match.end_round = end_round
if participants is not None: if participants is not None:
for old in saved: _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.delete(old)
session.flush() session.flush()
for p in participants: for p in participants:
@@ -564,27 +304,19 @@ def update_match(
match_id=match.id, # type: ignore[arg-type] match_id=match.id, # type: ignore[arg-type]
user_id=p.user_id, user_id=p.user_id,
faction_id=p.faction_id, faction_id=p.faction_id,
place=places[p.user_id], place=p.place,
eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment or None, comment=p.comment or None,
objectives=p.objectives,
worlds=worlds[p.user_id],
) )
) )
match.player_count = len(participants) match.player_count = len(participants)
touch(session, match) session.add(match)
session.commit() session.commit()
session.refresh(match) session.refresh(match)
return match return match
def delete_match(session: Session, match: Match, expected_version: str | None = None) -> None: def delete_match(session: Session, match: Match) -> None:
from app.services import attachment_service # избегаем цикла импорта session.delete(match) # участники удалятся каскадом (FK ON DELETE CASCADE)
assert_version(match, expected_version)
match_id = match.id
session.delete(match) # участники и вложения (БД) удалятся каскадом (FK ON DELETE CASCADE)
session.commit() session.commit()
attachment_service.delete_match_files(match_id) # type: ignore[arg-type] # файлы с диска
+21 -22
View File
@@ -5,7 +5,6 @@ from sqlmodel import Session, select
from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
from app.models import Group, GroupMember, User from app.models import Group, GroupMember, User
from app.services import group_service
MAX_GROUP_SIZE = 10 MAX_GROUP_SIZE = 10
@@ -34,7 +33,11 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
if member_count >= MAX_GROUP_SIZE: if member_count >= MAX_GROUP_SIZE:
raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).") raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).")
existing = group_service.get_membership(session, group.id, user.id) existing = session.exec(
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.user_id == user.id
)
).first()
if existing is not None: if existing is not None:
raise ConflictError("Игрок уже в группе.") raise ConflictError("Игрок уже в группе.")
@@ -45,26 +48,22 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
return member, user return member, user
def _assert_not_last_owner(session: Session, group: Group, member: GroupMember, message: str) -> None: def remove_member(session: Session, group: Group, user_id: int) -> None:
"""Группа без владельца неисправима: назначить нового становится некому.""" member = session.exec(
if member.role != "owner": select(GroupMember).where(
return GroupMember.group_id == group.id, GroupMember.user_id == user_id
)
).first()
if member is None:
raise NotFoundError("Игрок не состоит в группе.")
if member.role == "owner":
owners = session.exec( owners = session.exec(
select(GroupMember.id).where( select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.role == "owner" GroupMember.group_id == group.id, GroupMember.role == "owner"
) )
).all() ).all()
if len(owners) <= 1: if len(owners) <= 1:
raise ForbiddenError(message) raise ForbiddenError("Нельзя удалить последнего владельца группы.")
def remove_member(session: Session, group: Group, user_id: int) -> None:
member = group_service.get_membership(session, group.id, user_id)
if member is None:
raise NotFoundError("Игрок не состоит в группе.")
_assert_not_last_owner(
session, group, member, "Нельзя удалить последнего владельца группы."
)
# Сбросить активную группу у тех, для кого она была активной. # Сбросить активную группу у тех, для кого она была активной.
user = session.get(User, user_id) user = session.get(User, user_id)
@@ -79,13 +78,13 @@ def remove_member(session: Session, group: Group, user_id: int) -> None:
def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember: def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember:
if role not in ("owner", "member"): if role not in ("owner", "member"):
raise ValidationError("Недопустимая роль.") raise ValidationError("Недопустимая роль.")
member = group_service.get_membership(session, group.id, user_id) member = session.exec(
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.user_id == user_id
)
).first()
if member is None: if member is None:
raise NotFoundError("Игрок не состоит в группе.") raise NotFoundError("Игрок не состоит в группе.")
if role != "owner":
_assert_not_last_owner(
session, group, member, "Нельзя снять роль с последнего владельца группы."
)
member.role = role member.role = role
session.add(member) session.add(member)
session.commit() session.commit()
@@ -1,216 +0,0 @@
"""Персистентные уведомления игрокам (приглашения, старт/финиш партии, титулы).
Запись в БД + живой сигнал по SSE (`notify.notifications_changed`) — клиент по сигналу
подтягивает список (`GET /api/notifications`). Текст (RU) и ссылку рендерим здесь, на сервере.
Хранение: непрочитанные живут до прочтения (игрок должен увидеть их, когда бы ни зашёл),
прочитанные удаляются спустя RETENTION_HOURS после прочтения — при чтении списка и фоновой
задачей. От роста у незаходящих защищает кап MAX_PER_USER при создании.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlmodel import Session, select
from app.core.timeutil import iso_utc
from app.models import Group, Match, MatchParticipant, Notification
from app.services import notify
RETENTION_HOURS = 72 # сколько живёт ПРОЧИТАННОЕ уведомление после прочтения
MAX_PER_USER = 100 # кап на пользователя: старейшие сверх него удаляются при создании
def _now() -> datetime:
return datetime.now(timezone.utc)
def _cutoff() -> datetime:
# Наивный UTC — сопоставимо с тем, как SQLite отдаёт сохранённые datetime.
return _now().replace(tzinfo=None) - timedelta(hours=RETENTION_HOURS)
# ─── Базовые операции ─────────────────────────────────────────────────────────
def create_for(
session: Session,
user_ids,
*,
type: str,
title: str,
body: str | None = None,
link: str | None = None,
) -> list[Notification]:
"""Создать одно уведомление каждому из user_ids (один commit) и пушнуть им SSE-сигнал."""
ids = [uid for uid in dict.fromkeys(user_ids) if uid is not None]
if not ids:
return []
rows = [
Notification(user_id=uid, type=type, title=title, body=body, link=link) for uid in ids
]
session.add_all(rows)
session.flush()
for uid in ids:
_cap_user(session, uid)
session.commit()
for uid in ids:
notify.notifications_changed(uid)
return rows
def create(
session: Session,
user_id: int,
*,
type: str,
title: str,
body: str | None = None,
link: str | None = None,
) -> Notification | None:
rows = create_for(session, [user_id], type=type, title=title, body=body, link=link)
return rows[0] if rows else None
def _cap_user(session: Session, user_id: int) -> None:
"""Удалить старейшие уведомления пользователя сверх MAX_PER_USER (без commit)."""
extra = session.exec(
select(Notification)
.where(Notification.user_id == user_id)
.order_by(Notification.created_at.desc(), Notification.id.desc())
.offset(MAX_PER_USER)
).all()
for row in extra:
session.delete(row)
def purge_expired(session: Session) -> int:
"""Удалить ПРОЧИТАННЫЕ уведомления спустя RETENTION_HOURS после прочтения.
Непрочитанные по времени не протухают — иначе игрок, не заходивший дольше окна
хранения, не увидел бы их вовсе. Возвращает число удалённых."""
rows = session.exec(
select(Notification).where(
Notification.read_at.is_not(None), Notification.read_at < _cutoff()
)
).all()
for row in rows:
session.delete(row)
if rows:
session.commit()
return len(rows)
def list_for_user(session: Session, user_id: int) -> dict:
"""Уведомления пользователя + число непрочитанных. Попутно чистит давно прочитанные."""
purge_expired(session)
rows = session.exec(
select(Notification)
.where(Notification.user_id == user_id)
.order_by(Notification.created_at.desc())
).all()
items = [
{
"id": n.id,
"type": n.type,
"title": n.title,
"body": n.body,
"link": n.link,
"read_at": iso_utc(n.read_at),
"created_at": iso_utc(n.created_at),
}
for n in rows
]
unread = sum(1 for n in rows if n.read_at is None)
return {"items": items, "unread_count": unread}
def mark_read(session: Session, user_id: int, ids: list[int] | None = None) -> int:
"""Отметить прочитанными все непрочитанные пользователя (или указанные по id)."""
query = select(Notification).where(
Notification.user_id == user_id, Notification.read_at.is_(None)
)
if ids:
query = query.where(Notification.id.in_(ids))
rows = session.exec(query).all()
now = _now()
for row in rows:
row.read_at = now
session.add(row)
if rows:
session.commit()
# Счётчик непрочитанных изменился — толкаем тот же сигнал, что и create_for,
# иначе вкладка на другом устройстве держит устаревший бейдж до перезагрузки.
notify.notifications_changed(user_id)
return len(rows)
# ─── Типовые хелперы под триггеры ──────────────────────────────────────────────
def _match_participant_ids(session: Session, match_id: int) -> list[int]:
return list(
session.exec(
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id)
).all()
)
def _group_name(session: Session, group_id: int) -> str | None:
group = session.get(Group, group_id)
return group.name if group else None
def invited_to_group(
session: Session, user_id: int, group_name: str, inviter_nickname: str | None = None
) -> None:
create(
session,
user_id,
type="invite",
title=f"Приглашение в группу «{group_name}»",
body=f"пригласил: {inviter_nickname}" if inviter_nickname else None,
link="/group",
)
def match_started(session: Session, match: Match, actor_id: int) -> None:
ids = [uid for uid in _match_participant_ids(session, match.id) if uid != actor_id] # type: ignore[arg-type]
name = _group_name(session, match.group_id)
create_for(
session,
ids,
type="match_started",
title="Началась партия",
body=f"Группа «{name}»" if name else None,
link=f"/match/{match.id}",
)
def match_finished(session: Session, match: Match, actor_id: int) -> None:
ids = [uid for uid in _match_participant_ids(session, match.id) if uid != actor_id] # type: ignore[arg-type]
name = _group_name(session, match.group_id)
create_for(
session,
ids,
type="match_finished",
title="Партия завершена",
body=f"Группа «{name}»" if name else None,
link=f"/match/{match.id}",
)
def title_earned(session: Session, user_id: int, achievement_slug: str) -> None:
"""ЗАДЕЛ: уведомление о полученном титуле. Пока нигде не вызывается — заработает,
когда появится механизм выдачи титулов игрокам."""
name = achievement_slug
try:
from app.services import achievement_service
name = achievement_service.get(achievement_slug).get("name", achievement_slug)
except Exception: # noqa: BLE001
pass
create(
session,
user_id,
type="title",
title=f"Вы получили титул: {name}",
link="/account",
)
-88
View File
@@ -1,88 +0,0 @@
"""Публикация SSE-событий «данные изменились» нужным пользователям.
Тонкая прослойка над core.events.hub — держит роутеры чистыми. Событие несёт лишь тип и id,
клиент по нему точечно перезапрашивает данные.
"""
from __future__ import annotations
from sqlmodel import Session, select
from app.core.events import hub
from app.models import GroupMember, Match, User
def _group_member_ids(session: Session, group_id: int) -> list[int]:
return list(
session.exec(select(GroupMember.user_id).where(GroupMember.group_id == group_id)).all()
)
def _ratings_changed(session: Session, notified: list[int]) -> None:
"""Рейтинг общий и считается по всей истории (#80): завершённая партия двигает топ,
главную, историю и профили всех, кто играл после неё, и страницы других групп.
Игрокам вне группы (notified уже знают) — событие без подробностей о партии."""
skip = set(notified)
ids = [
uid
for uid in session.exec(
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
).all()
if uid not in skip
]
hub.publish(ids, {"type": "ratings"})
def match_changed(session: Session, match: Match) -> None:
"""Партия изменилась — уведомить всех участников её группы, а если она завершена —
и остальных игроков (_ratings_changed)."""
members = _group_member_ids(session, match.group_id)
hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id})
if match.status == "finished":
_ratings_changed(session, members)
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
"""Черновик формы завершения изменился — остальным заполняющим из группы.
Отдельный тип события: черновик меняется на каждое движение тайла, и гнать по нему
полную инвалидацию (лидерборд, история, профили) было бы расточительно. Автору
правки событие не шлём — у него уже актуальное состояние."""
ids = [uid for uid in _group_member_ids(session, match.group_id) if uid != actor_id]
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
def match_removed(session: Session, match_id: int, group_id: int, *, finished: bool) -> None:
"""Партия удалена — уведомить участников группы (обновить списки), а если она была
завершена — и остальных игроков. Статус передаётся снаружи: партии уже нет."""
members = _group_member_ids(session, group_id)
hub.publish(members, {"type": "match", "match_id": match_id, "group_id": group_id})
if finished:
_ratings_changed(session, members)
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
"""Состав/данные группы изменились — участникам (+ доп. адресатам, напр. удалённому)."""
ids = _group_member_ids(session, group_id)
if extra_user_ids:
ids = ids + extra_user_ids
hub.publish(ids, {"type": "group", "group_id": group_id})
def invitations_changed(user_id: int) -> None:
"""У пользователя изменился список приглашений."""
hub.publish([user_id], {"type": "invitations"})
def notifications_changed(user_id: int) -> None:
"""У пользователя появилось/изменилось уведомление — пусть подтянет список."""
hub.publish([user_id], {"type": "notifications"})
def announcements_changed(session: Session) -> None:
"""Админ создал, поправил, снял или удалил объявление — всем активным игрокам:
открытые вкладки перезапросят, что показать. Объявление, чей период начнётся
позже, клиент подхватит сам — периодическим перезапросом."""
ids = session.exec(
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
).all()
hub.publish(ids, {"type": "announcements"})
+17 -205
View File
@@ -1,220 +1,32 @@
"""Метрика рейтинга: многопользовательский Elo с множителем отрыва (#22, #23). """Метрика рейтинга. Вынесена отдельно — легко заменить.
Полное описание, обоснование коэффициентов и примеры — docs/rating/rating-system.md; По умолчанию: League Points — нормированные очки за место с учётом размера стола
эталонная реализация тех же формул — docs/rating/simulate.py (тесты сверяют с ней). и ничьих (competition ranking). За партию из N игроков:
points = (N - place - (tie_size - 1)/2) / (N - 1)
Партия раскладывается на пары игроков. Для пары a (выше или наравне) и b: 1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну.
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии Рейтинговый счёт игрока = AVG(points) * 100.
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX
у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва
(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий
признак партии подставляется типичным и не влияет на M.
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import date, datetime
from itertools import combinations
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional). # Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
MIN_GAMES = 10 MIN_GAMES = 3
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции. # Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
FACTION_MIN_GAMES = 2 FACTION_MIN_GAMES = 2
# ─── Правила игры ──────────────────────────────────────────────────────────── # SQL-выражение очков за участие (tie-aware). Использует поля m.player_count,
# mp.place и t.tie_size (размер группы игроков с тем же местом в партии).
# Размер поля в тайлах по числу игроков (дуэль — 2×3, шестеро — 4×5). MATCH_POINTS_SQL = (
BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20} "CASE WHEN m.player_count > 1 "
WORLDS_PER_TILE = 2.2 "THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) "
BASE_ROUNDS = 8 "/ (m.player_count - 1) "
# Домашнее правило группы: при 5–6 игроках играется 9 раундов. "ELSE 1.0 END"
EXTENDED_ROUNDS = 9 )
EXTENDED_MIN_PLAYERS = 5
# ─── Коэффициенты (документ, 4.10) ───────────────────────────────────────────
R0 = 1500.0 # стартовый рейтинг
D = 400.0 # масштаб: разница 400 пунктов — шансы 10:1
K_MAX = 64.0 # K новичка (0 партий)
K_MIN = 16.0 # K опытного игрока
K_GAMES = 20 # за сколько партий K линейно спускается от K_MAX к K_MIN
W_TABLE = 0.5 # вес размера стола
W_TEMPO = 1.0 # вес темпа победы
W_OBJ = 0.5 # вес отрыва по целям
W_WORLDS = 0.5 # вес отрыва по мирам
MU_OBJ = 0.5 # типичный отрыв по целям
MU_WORLDS = 0.5 # типичный отрыв по мирам
M_MIN = 0.5 # страховка: одна партия не легче половины обычной…
M_MAX = 2.0 # …и не тяжелее двух
# Близость партии по типу победы — множитель пар с победителем.
CLOSENESS = {
"objectives": 1.0,
"worlds": 0.85,
"plastic": 0.7,
"resources": 0.6,
"last_standing": 1.0,
}
def max_rounds(player_count: int, nine_rounds_rule: bool) -> int:
"""Лимит раундов партии: 9 при хоумруле группы и 5+ игроках, иначе 8."""
if nine_rounds_rule and player_count >= EXTENDED_MIN_PLAYERS:
return EXTENDED_ROUNDS
return BASE_ROUNDS
def fair_worlds(player_count: int) -> float:
"""«Честная доля» миров на игрока — масштаб для разницы миров."""
return BOARD_TILES[player_count] * WORLDS_PER_TILE / player_count
def mu_tempo(rmax: int) -> float:
"""Типичный темп: партия закончилась в предпоследнем раунде."""
return 1.0 / (rmax - 1)
def expected(r_a: float, r_b: float) -> float:
"""Ожидаемый результат a против b (вероятность, что a окажется выше)."""
return 1.0 / (1.0 + 10.0 ** ((r_b - r_a) / D))
def k_factor(games: int) -> float:
left = max(0.0, 1.0 - games / K_GAMES)
return K_MIN + (K_MAX - K_MIN) * left
def table_weight(player_count: int) -> float:
return 1.0 + W_TABLE * (player_count - 2) / 4.0
def _clamp(x: float, lo: float, hi: float) -> float:
return max(lo, min(hi, x))
# ─── Партия как вход расчёта ─────────────────────────────────────────────────
@dataclass(frozen=True)
class RatedSeat:
user_id: int
place: int
faction_id: int = 0
eliminated: bool = False
objectives: int | None = None # маркеры целей на конец партии
worlds: int | None = None # дружественные миры на конец партии
@dataclass(frozen=True)
class RatedMatch:
seats: tuple[RatedSeat, ...]
win_reason: str | None = None
end_round: int | None = None # раунд, в котором партия закончилась
nine_rounds_rule: bool = False # снимок настройки группы на момент партии
id: int = 0
group_id: int = 0
played_at: date | None = None
finished_at: datetime | None = None
def pair_multiplier(m: RatedMatch, a: RatedSeat, b: RatedSeat) -> float:
"""Множитель отрыва пары; a — выше или наравне с b."""
n = len(m.seats)
tie = a.place == b.place
winner_pair = a.place == 1
add = 1.0
def diff(x: int, y: int) -> float:
return abs(x - y) if tie else max(0, x - y)
if winner_pair:
rmax = max_rounds(n, m.nine_rounds_rule)
mu = mu_tempo(rmax)
tempo = mu if m.end_round is None else (rmax - m.end_round) / (rmax - 1)
add += W_TEMPO * (tempo - mu)
if winner_pair and m.win_reason == "last_standing":
obj = 1.0 # все соперники устранены — отрыв максимальный, сколько бы ни было маркеров
elif a.objectives is not None and b.objectives is not None:
obj = _clamp(diff(a.objectives, b.objectives) / n, 0.0, 1.0)
else:
obj = MU_OBJ
add += W_OBJ * (obj - MU_OBJ)
if a.worlds is not None and b.worlds is not None:
wor = _clamp(diff(a.worlds, b.worlds) / fair_worlds(n), 0.0, 1.0)
else:
wor = MU_WORLDS
add += W_WORLDS * (wor - MU_WORLDS)
close = CLOSENESS.get(m.win_reason, 1.0) if winner_pair and m.win_reason else 1.0
return _clamp(add, M_MIN, M_MAX) * close
def rate_match(
ratings: dict[int, float], games: dict[int, int], m: RatedMatch
) -> tuple[dict[int, float], dict[int, float]]:
"""Изменения рейтинга участников и их результат относительно ожидания.
Возвращает (ΔR, perf): perf_i = Σ_j (S_ij − E_ij) / (N − 1) — насколько игрок
выступил выше ожидания, без множителя отрыва и K. Входные словари не мутирует."""
n = len(m.seats)
delta = {s.user_id: 0.0 for s in m.seats}
perf = {s.user_id: 0.0 for s in m.seats}
if n < 2:
return delta, perf
g = table_weight(n)
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
k = {s.user_id: k_factor(games.get(s.user_id, 0)) for s in m.seats}
for a, b in combinations(m.seats, 2):
if a.eliminated and b.eliminated:
# Выбывшие между собой не сравниваются: в этой партии все они проиграли, а миров
# у них нет (решение владельца, #91). Нормировка на N − 1 остаётся прежней.
continue
if a.place > b.place:
a, b = b, a
s_ab = 0.5 if a.place == b.place else 1.0
e_ab = expected(r[a.user_id], r[b.user_id])
x = pair_multiplier(m, a, b) * (s_ab - e_ab)
delta[a.user_id] += k[a.user_id] * g / (n - 1) * x
delta[b.user_id] -= k[b.user_id] * g / (n - 1) * x
perf[a.user_id] += (s_ab - e_ab) / (n - 1)
perf[b.user_id] -= (s_ab - e_ab) / (n - 1)
return delta, perf
@dataclass
class Replay:
"""Итог проигрывания истории: рейтинги без округления и следы каждой партии."""
ratings: dict[int, float] = field(default_factory=dict)
games: dict[int, int] = field(default_factory=dict)
delta: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
perf: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
def replay(matches: Iterable[RatedMatch]) -> Replay:
"""Проигрывает партии в переданном порядке (хронологию задаёт вызывающий)."""
out = Replay()
for m in matches:
delta, perf = rate_match(out.ratings, out.games, m)
for uid, dv in delta.items():
out.ratings[uid] = out.ratings.get(uid, R0) + dv
out.games[uid] = out.games.get(uid, 0) + 1
out.delta[(m.id, uid)] = dv
out.perf[(m.id, uid)] = perf[uid]
return out
def leaderboard_sort_key(row: dict) -> tuple: def leaderboard_sort_key(row: dict) -> tuple:
"""Ключ сортировки топа: рейтинг ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑. """Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
row["rating"] — рейтинг без округления: два игрока с одинаковым целым в топе
всё равно упорядочены по настоящему значению."""
return ( return (
-(row["rating"] or 0.0), -(row["score"] or 0.0),
-(row["win_rate"] or 0.0), -(row["win_rate"] or 0.0),
-(row["games"] or 0), -(row["games"] or 0),
(row["avg_place"] or 0.0), (row["avg_place"] or 0.0),
+218 -413
View File
@@ -1,183 +1,102 @@
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен). """Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен)."""
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
считаются игры, победы, среднее место и разбивки. Рейтинг у игрока один — по всем
партиям приложения (#80). Страница группы берёт из него только рейтинг, а игры, победы,
винрейт и среднее место считает по партиям группы."""
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from typing import Any
from sqlalchemy import func from sqlalchemy import text
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.models import Expansion, Faction, Group, GroupMember, Match, MatchParticipant, User from app.models import Group, GroupMember, Match
from app.services import faction_service, group_service, membership_service, user_service from app.services import group_service
from app.services.scoring import ( from app.services.scoring import (
FACTION_MIN_GAMES, FACTION_MIN_GAMES,
MATCH_POINTS_SQL,
MIN_GAMES, MIN_GAMES,
RatedMatch,
RatedSeat,
Replay,
leaderboard_sort_key, leaderboard_sort_key,
replay,
) )
# Базовый блок: одна строка на участие с tie-aware очками.
MIN_PARTICIPANTS = 2 # партия, где осталось меньше участников, партией не считается # Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят).
SCORED_CTE = f"""
WITH tie AS (
def _playable_match_ids(): SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size
"""Подзапрос id партий, в которых не меньше MIN_PARTICIPANTS участников. FROM match_participants mp
JOIN matches m ON m.id = mp.match_id
Партия может «опустеть» в деве: жёсткое удаление аккаунта (routers/dev_admin.py) WHERE m.status = 'finished' AND mp.place IS NOT NULL
вычёркивает игрока из партий и не пересчитывает их. Партия с одним участником — GROUP BY mp.match_id, mp.place
уже не игра: её нет ни в рейтинге, ни в историях и списках (админка её видит).""" ),
return ( scored AS (
select(MatchParticipant.match_id) SELECT mp.user_id AS user_id,
.group_by(MatchParticipant.match_id) mp.faction_id AS faction_id,
.having(func.count() >= MIN_PARTICIPANTS) 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 load_history(session: Session) -> list[RatedMatch]: def _round(value: Any, ndigits: int) -> float | None:
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id. return None if value is None else round(float(value), ndigits)
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет,
партии меньше чем с двумя участниками — тоже (_playable_match_ids). Срез группы —
_for_group по этой же истории: рейтинг считается только целиком."""
stmt = (
select(Match, MatchParticipant)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(Match.status == "finished")
.order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id)
)
history: list[RatedMatch] = []
current: Match | None = None
seats: list[RatedSeat] = []
def flush() -> None:
if current is not None and len(seats) >= MIN_PARTICIPANTS:
history.append(
RatedMatch(
seats=tuple(seats),
win_reason=current.win_reason,
end_round=current.end_round,
nine_rounds_rule=current.nine_rounds_rule,
id=current.id, # type: ignore[arg-type]
group_id=current.group_id,
played_at=current.played_at,
finished_at=current.finished_at,
)
)
for m, p in session.exec(stmt).all():
if current is None or m.id != current.id:
flush()
current, seats = m, []
if p.place is None: # у завершённой партии мест без значения не бывает
continue
seats.append(
RatedSeat(
user_id=p.user_id,
place=p.place,
faction_id=p.faction_id,
eliminated=p.eliminated,
objectives=p.objectives,
worlds=p.worlds,
)
)
flush()
return history
def _for_group(history: list[RatedMatch], group_id: int) -> list[RatedMatch]: def _normalize(row: dict) -> dict:
return [m for m in history if m.group_id == group_id] # avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL.
avatar_url = None
if row.get("avatar_path"):
def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatch, RatedSeat]]: avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}"
return [(m, s) for m in history for s in m.seats if s.user_id == user_id]
def _rating_confirmed(rep: Replay, user_id: int) -> bool:
"""Рейтинг подтверждён, когда за игроком MIN_GAMES партий во всём приложении."""
return rep.games.get(user_id, 0) >= MIN_GAMES
def _summary(seats: list[tuple[RatedMatch, RatedSeat]], rating: float | None) -> dict:
"""Итог игрока: игры, победы, винрейт, среднее место и рейтинг целым числом.
В расчёте рейтинг без округления (иначе ошибка копилась бы по цепочке партий),
показывается — целым."""
games = len(seats)
if games == 0:
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
wins = sum(1 for _m, s in seats if s.place == 1)
return { return {
"games": games, "user_id": row["user_id"],
"wins": wins, "nickname": row["nickname"],
"win_rate": round(wins / games, 4), "games": int(row["games"] or 0),
"avg_place": round(sum(s.place for _m, s in seats) / games, 2), "wins": int(row["wins"] or 0),
"score": None if rating is None else round(rating), "win_rate": _round(row["win_rate"] or 0.0, 4),
"avg_place": _round(row["avg_place"], 2),
"score": _round(row["score"], 1),
"avatar_url": avatar_url,
} }
def leaderboard( def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
session: Session, where = "WHERE s.group_id = :gid" if group_id is not None else ""
group_id: int | None = None, sql = f"""
*, {SCORED_CTE}
history: list[RatedMatch] | None = None, SELECT u.id AS user_id, u.nickname AS nickname,
rep: Replay | None = None, u.avatar_path AS avatar_path,
member_ids: set[int] | None = None, CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version,
) -> dict: COUNT(*) AS games,
"""Топ: общий или группы. history/rep — вся история и её проигрывание (home и SUM(s.is_win) AS wins,
group_stats их переиспользуют). 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, u.avatar_path, u.updated_at
"""
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]
Рейтинг и статус «Новичок» — всегда общие: статус описывает надёжность рейтинга, а он
считается по всем партиям. С group_id игры, победы, винрейт и среднее место берутся
только из партий группы (#80). member_ids — показывать только этих игроков (топ
группы — её текущий состав, #76); места нумеруются уже после фильтра."""
if history is None:
history = load_history(session)
if rep is None:
rep = replay(history)
shown = history if group_id is None else _for_group(history, group_id)
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list) def leaderboard(session: Session, group_id: int | None = None) -> dict:
for m in shown: rows = _leaderboard_rows(session, group_id)
for s in m.seats: qualified = [r for r in rows if r["games"] >= MIN_GAMES]
if member_ids is None or s.user_id in member_ids: provisional = [r for r in rows if r["games"] < MIN_GAMES]
by_user[s.user_id].append((m, s)) qualified.sort(key=leaderboard_sort_key)
users = ( provisional.sort(key=leaderboard_sort_key)
{u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()}
if by_user
else {}
)
rows = []
for uid, seats in by_user.items():
u = users[uid]
rows.append(
{
"user_id": uid,
"nickname": u.nickname,
**_summary(seats, rep.ratings[uid]),
"rating": rep.ratings[uid], # только для сортировки
"rating_confirmed": _rating_confirmed(rep, uid),
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
}
)
qualified = sorted((r for r in rows if r["rating_confirmed"]), key=leaderboard_sort_key)
provisional = sorted((r for r in rows if not r["rating_confirmed"]), key=leaderboard_sort_key)
for i, r in enumerate(qualified, start=1): for i, r in enumerate(qualified, start=1):
r["rank"] = i r["rank"] = i
for r in provisional: for r in provisional:
r["rank"] = None r["rank"] = None
for r in rows:
del r["rating"]
return { return {
"entries": qualified, "entries": qualified,
"provisional": provisional, "provisional": provisional,
@@ -185,229 +104,180 @@ def leaderboard(
} }
def _faction_breakdown( def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> dict:
session: Session, seats: list[tuple[RatedMatch, RatedSeat]], rep: Replay, user_id: int cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
) -> list[dict]: sql = f"""
by_faction: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list) {SCORED_CTE}
for m, s in seats: SELECT COUNT(*) AS games,
by_faction[s.faction_id].append((m, s)) SUM(s.is_win) AS wins,
if not by_faction: AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
return [] AVG(s.place) AS avg_place,
meta = { AVG(s.points) * 100 AS score
f.id: (f, code) FROM scored s
for f, code in session.exec( {cond}
select(Faction, Expansion.code) """
.join(Expansion, Expansion.id == Faction.expansion_id) params: dict[str, Any] = {"uid": user_id}
.where(Faction.id.in_(list(by_faction))) if group_id is not None:
).all() 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 = [] out = []
for fid, group in by_faction.items(): for r in result:
faction, expansion_code = meta[fid]
perf = sum(rep.perf[(m.id, user_id)] for m, _s in group) / len(group)
out.append( out.append(
{ {
"faction_id": fid, "faction_id": r["faction_id"],
"code": faction.code, "code": r["code"],
"name_ru": faction.name_ru, "name_ru": r["name_ru"],
"expansion_code": expansion_code, "expansion_code": r["expansion_code"],
**{k: v for k, v in _summary(group, None).items() if k != "score"}, "games": int(r["games"] or 0),
# Средний результат относительно ожидания (S − E) × 100: насколько игрок "wins": int(r["wins"] or 0),
# на фракции выступает выше рейтинговых шансов — без привязки к рейтингу. "win_rate": _round(r["win_rate"] or 0.0, 4),
"score": round(perf * 100, 1), "avg_place": _round(r["avg_place"], 2),
"name_ru_prepositional": faction_service.prepositional( "score": _round(r["score"], 1),
faction.code, faction.name_ru
),
} }
) )
out.sort(key=lambda f: (-f["games"], -(f["score"] or 0.0)))
return out return out
def _recent_form(seats: list[tuple[RatedMatch, RatedSeat]], limit: int = 5) -> list[dict]: def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]:
recent = sorted(seats, key=lambda ms: (str(ms[0].played_at), ms[0].id), reverse=True) 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 [ return [
{ {"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])}
"place": s.place, for r in result
"player_count": len(m.seats),
"played_at": str(m.played_at),
}
for m, s in recent[:limit]
] ]
def _favorite_faction(session: Session, user_id: int) -> dict | None: def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
"""Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики.""" overall = _overall_for_user(session, user_id, group_id)
user = session.get(User, user_id) factions = _faction_breakdown(session, user_id, group_id)
if user is None or user.favorite_faction_id is None:
return None
faction = session.get(Faction, user.favorite_faction_id)
if faction is None:
return None
return {
"id": faction.id,
"code": faction.code,
"name_ru": faction.name_ru,
"expansion_id": faction.expansion_id,
}
def _overall(history: list[RatedMatch], rep: Replay, user_id: int) -> dict:
return _summary(_user_seats(history, user_id), rep.ratings.get(user_id))
def profile_stats(
session: Session,
user_id: int,
*,
history: list[RatedMatch] | None = None,
rep: Replay | None = None,
) -> dict:
"""Витрина профиля — общие показатели. history/rep — уже посчитанные (home их
переиспользует)."""
if history is None:
history = load_history(session)
if rep is None:
rep = replay(history)
seats = _user_seats(history, user_id)
factions = _faction_breakdown(session, seats, rep, user_id)
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES] qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
best = max(qualified, key=lambda f: f["score"]) if qualified else None best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
worst = min(qualified, key=lambda f: f["score"]) 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
# рандомные раздачи.
main = max(factions, key=lambda f: f["games"]) if factions else None
return { return {
"user_id": user_id, "user_id": user_id,
"overall": _summary(seats, rep.ratings.get(user_id)), "overall": overall,
"factions": factions, "factions": factions,
"best_faction": best, "best_faction": best,
"worst_faction": worst, "worst_faction": worst,
"favorite_faction": _favorite_faction(session, user_id), "most_played_faction": most_played,
"main_faction": main, "recent_form": _recent_form(session, user_id, group_id),
"recent_form": _recent_form(seats),
"min_games": MIN_GAMES,
} }
def group_stats(session: Session, group_id: int) -> dict: def group_stats(session: Session, group_id: int) -> dict:
history = load_history(session) board = leaderboard(session, group_id=group_id)
rep = replay(history) total_matches = session.exec(
group_history = _for_group(history, group_id) select(Match).where(Match.group_id == group_id, Match.status == "finished")
members = membership_service.list_members(session, group_id) ).all()
# Список игроков группы — только её текущий состав: удалённый из группы в нём не висит. last_at = None
board = leaderboard( if total_matches:
session, last_at = str(max(m.played_at for m in total_matches))
group_id,
history=history,
rep=rep,
member_ids={u.id for _m, u in members}, # type: ignore[misc]
)
last_played = max((m.played_at for m in group_history), default=None)
games: dict[int, int] = defaultdict(int)
wins: dict[int, int] = defaultdict(int)
for m in group_history:
for s in m.seats:
games[s.faction_id] += 1
wins[s.faction_id] += s.place == 1
available_ids = group_service.available_faction_ids(session, group_id) available_ids = group_service.available_faction_ids(session, group_id)
factions = sorted( faction_meta = []
session.exec(select(Faction)).all(), key=lambda f: (-games[f.id], f.sort_order) # type: ignore[index] sql = f"""
) {SCORED_CTE}
faction_meta = [ 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": f.id, "faction_id": r["faction_id"],
"code": f.code, "code": r["code"],
"name_ru": f.name_ru, "name_ru": r["name_ru"],
"games": games[f.id], # type: ignore[index] "games": int(r["games"] or 0),
"wins": wins[f.id], # type: ignore[index] "wins": int(r["wins"] or 0),
"available": f.id in available_ids, "available": r["faction_id"] in available_ids,
}
for f in factions
]
# Участники без завершённых партий в группе — отдельным блоком (нули, rank=null).
# Рейтинг у них общий: если игрок играл в других группах, он виден и здесь.
played_ids = {e["user_id"] for e in board["entries"]} | {
e["user_id"] for e in board["provisional"]
}
inactive = []
for _m, u in members:
if u.id in played_ids:
continue
rating = rep.ratings.get(u.id) # type: ignore[arg-type]
inactive.append(
{
"user_id": u.id,
"nickname": u.nickname,
"games": 0,
"wins": 0,
"win_rate": 0.0,
"avg_place": None,
"score": None if rating is None else round(rating),
"rating_confirmed": _rating_confirmed(rep, u.id), # type: ignore[arg-type]
"rank": None,
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
} }
) )
return { return {
"group_id": group_id, "group_id": group_id,
"total_matches": len(group_history), "total_matches": len(total_matches),
"last_match_at": str(last_played) if last_played else None, "last_match_at": last_at,
"leaderboard": board["entries"], "leaderboard": board["entries"],
"provisional": board["provisional"], "provisional": board["provisional"],
"inactive": inactive,
"faction_meta": faction_meta, "faction_meta": faction_meta,
"min_games": MIN_GAMES, "min_games": MIN_GAMES,
} }
def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict: def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
return { 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, "user_id": u.id,
"nickname": u.nickname, "nickname": u.nickname,
"faction_id": f.id, "faction_id": f.id,
"faction_name": f.name_ru, "faction_name": f.name_ru,
"place": p.place, "place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random, "was_random": p.was_random,
"comment": p.comment, "comment": p.comment,
"objectives": p.objectives,
"worlds": p.worlds,
} }
)
def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int, list[dict]]:
"""Участники сразу всей страницы партий: иначе запрос на каждую партию (N+1)."""
if not match_ids:
return {}
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.in_(match_ids))
# place может быть NULL (партия идёт) — NULL уходит в конец сортировки.
.order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname)
).all()
out: dict[int, list[dict]] = {}
for p, u, f in rows:
out.setdefault(p.match_id, []).append(_participant_row(p, u, f))
return out
def _match_items(
session: Session, matches, rating_deltas: dict[int, float] | None = None
) -> list[dict]:
"""Элементы списка партий (общее для списка группы и истории игрока).
rating_deltas — {match_id: ΔR} владельца истории; у списка группы их нет."""
by_match = _participants_by_match(session, [m.id for m in matches])
items = []
for m in matches:
parts = by_match.get(m.id, [])
items.append( items.append(
{ {
"id": m.id, "id": m.id,
@@ -421,86 +291,15 @@ def _match_items(
"overall_comment": m.overall_comment, "overall_comment": m.overall_comment,
"created_by": m.created_by, "created_by": m.created_by,
"participants": parts, "participants": parts,
"rating_delta": None if rating_deltas is None else rating_deltas.get(m.id),
} }
) )
return items return {"items": items, "total": total, "limit": limit, "offset": offset}
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
where = (Match.group_id == group_id, Match.id.in_(_playable_match_ids()))
total = session.exec(select(func.count()).select_from(Match).where(*where)).one()
matches = session.exec(
select(Match)
.where(*where)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
).all()
return {
"items": _match_items(session, matches),
"total": total,
"limit": limit,
"offset": offset,
}
def user_match_list(
session: Session,
user_id: int,
limit: int = 20,
offset: int = 0,
best_only: bool = False,
) -> dict:
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
У каждой партии — изменение общего рейтинга игрока за неё (rating_delta, один знак
после запятой). best_only — одна лучшая партия: наибольший прирост рейтинга
(учитывает и соперников, и ход партии); при равенстве берём более свежую."""
history = load_history(session)
rep = replay(history)
mine = _user_seats(history, user_id)
# + 0.0 превращает −0.0 (мелкий минус, округлённый до нуля) в обычный ноль.
deltas = {m.id: round(rep.delta[(m.id, user_id)], 1) + 0.0 for m, _s in mine}
if best_only:
candidates = [(rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in mine]
matches = [session.get(Match, max(candidates)[2])] if candidates else []
return {
"items": _match_items(session, matches, deltas),
"total": len(matches),
"limit": 1,
"offset": 0,
}
where = (
MatchParticipant.user_id == user_id,
Match.status == "finished",
Match.id.in_(_playable_match_ids()),
)
total = session.exec(
select(func.count())
.select_from(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(*where)
).one()
matches = session.exec(
select(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(*where)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
).all()
return {
"items": _match_items(session, matches, deltas),
"total": total,
"limit": limit,
"offset": offset,
}
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]: def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху).""" """Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
from app.services.match_service import participants_detail # избегаем цикла импорта
group_ids = list( group_ids = list(
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all() session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
) )
@@ -512,10 +311,20 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
.where(Match.status == "in_progress", Match.group_id.in_(group_ids)) .where(Match.status == "in_progress", Match.group_id.in_(group_ids))
.order_by(Match.started_at.desc(), Match.id.desc()) .order_by(Match.started_at.desc(), Match.id.desc())
).all() ).all()
by_match = _participants_by_match(session, [m.id for m, _ in rows])
out = [] out = []
for m, gname in rows: for m, gname in rows:
parts = by_match.get(m.id, []) 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( out.append(
{ {
"id": m.id, "id": m.id,
@@ -530,12 +339,8 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict: 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)
history = load_history(session)
rep = replay(history)
board = leaderboard(session, history=history, rep=rep)
profile = profile_stats(session, user_id, history=history, rep=rep)
active_group_brief = None active_group_brief = None
if active_group_id is not None: if active_group_id is not None:
group = session.get(Group, active_group_id) group = session.get(Group, active_group_id)
@@ -543,7 +348,7 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
active_group_brief = { active_group_brief = {
"id": group.id, "id": group.id,
"name": group.name, "name": group.name,
**_overall(_for_group(history, active_group_id), rep, user_id), **_overall_for_user(session, user_id, active_group_id),
} }
return { return {
"leaderboard": board["entries"][:leaderboard_limit], "leaderboard": board["entries"][:leaderboard_limit],
+13 -163
View File
@@ -1,25 +1,17 @@
"""Пользователи: создание из внешней личности, ник, активная группа, профиль.""" """Пользователи: создание из внешней личности, ник, активная группа, профиль."""
from __future__ import annotations from __future__ import annotations
import os
import re import re
from datetime import datetime, timezone from datetime import datetime
from pathlib import Path from pathlib import Path
from sqlmodel import Session, select from sqlmodel import Session, select
from app.auth.password import validate_new_password
from app.auth.provider import ExternalIdentity from app.auth.provider import ExternalIdentity
from app.core.config import settings from app.core.config import settings
from app.core.errors import ( from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
NicknameTakenError, from app.models import AuthIdentity, GroupMember, User
NotFoundError,
TelegramAlreadyLinkedError,
TelegramTakenError,
ValidationError,
)
from app.core.security import hash_password
from app.core.timeutil import utcnow
from app.models import AuthIdentity, Faction, User
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
_BIO_MAX = 500 _BIO_MAX = 500
@@ -42,28 +34,6 @@ def nickname_available(session: Session, nickname: str, exclude_user_id: int | N
return existing is None or existing.id == exclude_user_id return existing is None or existing.id == exclude_user_id
def search_users(session: Session, query: str, limit: int = 3) -> list[User]:
"""Подсказки по нику: активные игроки (role='player'), без учёта регистра.
SQLite LIKE/lower() сворачивают регистр только для ASCII, а ники бывают
кириллицей — поэтому выбираем активных игроков и фильтруем/ранжируем в
Python (casefold). Пользователей десятки, полный проход дёшев; при росте
до тысяч — заменить на индексированную колонку nickname_lower.
Сначала префиксные совпадения, затем подстрочные, внутри — по алфавиту.
"""
q = (query or "").strip().casefold()
if not q:
return []
users = session.exec(
select(User).where(User.role == "player", User.is_active == True) # noqa: E712
).all()
matched = [u for u in users if q in u.nickname.casefold()]
matched.sort(
key=lambda u: (0 if u.nickname.casefold().startswith(q) else 1, u.nickname.casefold())
)
return matched[:limit]
def nickname_format_ok(nickname: str) -> bool: def nickname_format_ok(nickname: str) -> bool:
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_).""" """Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
return bool(_NICK_RE.match((nickname or "").strip())) return bool(_NICK_RE.match((nickname or "").strip()))
@@ -140,73 +110,9 @@ def register_from_identity(
return _create_from_identity(session, identity, nickname) return _create_from_identity(session, identity, nickname)
def register_local(session: Session, nickname: str, password: str) -> User:
"""Регистрация по логину и паролю. Логин — это ник; Telegram можно привязать позже."""
nickname = (nickname or "").strip()
if not nickname_format_ok(nickname):
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
if not nickname_available(session, nickname):
raise NicknameTakenError()
validate_new_password(password)
user = User(
nickname=nickname,
role="player",
auth_provider="local",
password_hash=hash_password(password),
)
session.add(user)
session.commit()
session.refresh(user)
return user
def set_password(session: Session, user: User, new_password: str) -> User:
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет).
Инкремент token_version отзывает все ранее выданные токены (#57): при смене пароля
игроком — все прочие сессии, при сбросе админом — все сессии игрока (в т.ч. злоумышленника).
Своё устройство остаётся в сессии, только если вызывающий перевыдаст cookie со свежим ver."""
validate_new_password(new_password)
user.password_hash = hash_password(new_password)
user.token_version = (user.token_version or 0) + 1
session.add(user)
session.commit()
session.refresh(user)
return user
def link_telegram(session: Session, user: User, identity: ExternalIdentity) -> User:
"""Привязать Telegram к существующему аккаунту; ник не меняется.
После привязки вход через Telegram попадает в этот аккаунт: find_by_identity находит
его по той же связке provider+external_id, что создаёт регистрация через Telegram."""
already = session.exec(
select(AuthIdentity).where(
AuthIdentity.user_id == user.id, AuthIdentity.provider == identity.provider
)
).first()
if user.telegram_id is not None or already is not None:
raise TelegramAlreadyLinkedError()
taken_by_id = session.exec(select(User).where(User.telegram_id == identity.telegram_id)).first()
if find_by_identity(session, identity) is not None or taken_by_id is not None:
raise TelegramTakenError()
user.telegram_id = identity.telegram_id
session.add(user)
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: def update_nickname(session: Session, user: User, new_nickname: str) -> User:
new_nickname = (new_nickname or "").strip() new_nickname = (new_nickname or "").strip()
if not nickname_format_ok(new_nickname): if not _NICK_RE.match(new_nickname):
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
if not nickname_available(session, new_nickname, exclude_user_id=user.id): if not nickname_available(session, new_nickname, exclude_user_id=user.id):
raise NicknameTakenError() raise NicknameTakenError()
@@ -219,9 +125,12 @@ def update_nickname(session: Session, user: User, new_nickname: str) -> User:
def set_active_group(session: Session, user: User, group_id: int | None) -> User: def set_active_group(session: Session, user: User, group_id: int | None) -> User:
if group_id is not None: if group_id is not None:
from app.services import group_service # избегаем цикла импорта member = session.exec(
select(GroupMember).where(
if group_service.get_membership(session, group_id, user.id) is None: GroupMember.group_id == group_id, GroupMember.user_id == user.id
)
).first()
if member is None:
raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.") raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.")
user.active_group_id = group_id user.active_group_id = group_id
session.add(user) session.add(user)
@@ -239,13 +148,7 @@ def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime |
подтягивал новую картинку после смены (файл перезаписывается по тому же пути).""" подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
if not avatar_path: if not avatar_path:
return None return None
# В БД время наивное и хранится в UTC. .timestamp() у наивного значения считает version = int(updated_at.timestamp()) if updated_at else 0
# его локальным, и версия разъезжалась с лидербордом, где то же поле считает SQL
# (strftime('%s') читает его как UTC) — один аватар качался браузером дважды.
version = 0
if updated_at is not None:
aware = updated_at if updated_at.tzinfo else updated_at.replace(tzinfo=timezone.utc)
version = int(aware.timestamp())
return f"/api/users/{user_id}/avatar?v={version}" return f"/api/users/{user_id}/avatar?v={version}"
@@ -260,54 +163,6 @@ def update_bio(session: Session, user: User, bio: str | None) -> User:
return user return user
_HISTORY_MODES = {"all", "best"}
_HISTORY_DETAILS = {"compact", "full"}
def update_history_prefs(
session: Session, user: User, *, mode: str | None = None, detail: str | None = None
) -> User:
"""Витрина истории партий: что показывать и насколько подробно. None — не менять."""
if mode is not None:
if mode not in _HISTORY_MODES:
raise ValidationError("Неизвестный режим истории партий.")
user.history_mode = mode
if detail is not None:
if detail not in _HISTORY_DETAILS:
raise ValidationError("Неизвестная подробность истории партий.")
user.history_detail = detail
session.add(user)
session.commit()
session.refresh(user)
return user
def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User:
"""Любимая фракция — личный выбор игрока; None очищает выбор."""
if faction_id is not None and session.get(Faction, faction_id) is None:
raise ValidationError("Такой фракции не существует.")
user.favorite_faction_id = faction_id
session.add(user)
session.commit()
session.refresh(user)
return user
def read_capped_image(file, max_bytes: int, limit_message: str) -> tuple[bytes, str]:
"""Прочитать загруженный файл с ограничением размера и убедиться, что это картинка.
Читаем на байт больше лимита: так превышение видно, не загружая файл целиком.
Один хелпер на все загрузки (аватар, фото партии, иконка ачивки) — иначе
правка лимита или списка форматов расходится по четырём роутерам."""
content = file.file.read(max_bytes + 1)
if len(content) > max_bytes:
raise ValidationError(limit_message)
ext = sniff_image_ext(content)
if ext is None:
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
return content, ext
def sniff_image_ext(content: bytes) -> str | None: def sniff_image_ext(content: bytes) -> str | None:
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка.""" """Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
if content.startswith(b"\x89PNG\r\n\x1a\n"): if content.startswith(b"\x89PNG\r\n\x1a\n"):
@@ -343,10 +198,6 @@ def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}" rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
(Path(settings.upload_dir) / rel).write_bytes(content) (Path(settings.upload_dir) / rel).write_bytes(content)
user.avatar_path = rel user.avatar_path = rel
# Файл перезаписывается по тому же пути, поэтому при том же расширении avatar_path
# не меняется, UPDATE не эмитится и onupdate не срабатывает. Без явного бампа
# кэш-бастер остаётся прежним, и браузер час показывает старую картинку.
user.updated_at = utcnow()
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)
@@ -361,7 +212,6 @@ def clear_avatar(session: Session, user: User) -> User:
except OSError: except OSError:
pass pass
user.avatar_path = None user.avatar_path = None
user.updated_at = utcnow()
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)
@@ -380,5 +230,5 @@ def public_profile(session: Session, user_id: int) -> dict:
"nickname": user.nickname, "nickname": user.nickname,
"bio": user.bio, "bio": user.bio,
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] "avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
"stats": stats_service.profile_stats(session, user_id), "stats": stats_service.profile_stats(session, user_id, group_id=None),
} }
+2 -12
View File
@@ -9,16 +9,6 @@ python -m app.bootstrap
echo "[entrypoint] Запуск сервера…" echo "[entrypoint] Запуск сервера…"
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS): # --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS):
# чтобы приложение знало, что снаружи запрос пришёл по HTTPS и кто реальный клиент. # чтобы приложение знало, что снаружи запрос пришёл по HTTPS.
#
# forwarded-allow-ips НЕ "*" (#58): при "*" uvicorn брал ЛЕВОЕ значение X-Forwarded-For,
# и клиент мог подставить произвольный IP (снятие throttle, порча аудита). Доверяем только
# апстримам из приватной сети compose (туннель к Caddy ходит на app:8000) и loopback
# (healthcheck) — тогда uvicorn сканирует XFF справа и берёт первый недоверенный адрес,
# т.е. реальный, добавленный Caddy. Портов на хост нет, снаружи к :8000 никто не ходит.
# Переопределяемо через FORWARDED_ALLOW_IPS, если сеть отличается.
# --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита
# остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown.
FORWARDED_ALLOW_IPS="${FORWARDED_ALLOW_IPS:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \ exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \
--proxy-headers --forwarded-allow-ips="$FORWARDED_ALLOW_IPS" --timeout-graceful-shutdown 10 --proxy-headers --forwarded-allow-ips="*"
+4 -41
View File
@@ -15,15 +15,13 @@ os.environ["FS_STARTUP_BOOTSTRAP"] = "0" # тесты сами поднимаю
import pytest # noqa: E402 import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402 from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402 from sqlalchemy.pool import StaticPool # noqa: E402
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402 from sqlmodel import Session, SQLModel, create_engine # noqa: E402
import app.models # noqa: F401,E402 (регистрация моделей) import app.models # noqa: F401,E402 (регистрация моделей)
from app.core.ratelimit import login_throttle # noqa: E402
from app.core.security import hash_password # noqa: E402 from app.core.security import hash_password # noqa: E402
from app.core.token_revocation import revoked_tokens # noqa: E402
from app.db.session import get_session # noqa: E402 from app.db.session import get_session # noqa: E402
from app.main import app # noqa: E402 from app.main import app # noqa: E402
from app.models import AuthIdentity, GroupMember, User # noqa: E402 from app.models import User # noqa: E402
from app.seed.reference_data import seed_reference_data # noqa: E402 from app.seed.reference_data import seed_reference_data # noqa: E402
@@ -50,8 +48,6 @@ def client(engine):
yield s yield s
app.dependency_overrides[get_session] = _get_session app.dependency_overrides[get_session] = _get_session
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса
with TestClient(app) as c: with TestClient(app) as c:
yield c yield c
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -90,32 +86,6 @@ def login(client: TestClient, nickname: str) -> dict:
return r.json() return r.json()
def add_group_member(engine, group_id: int, nickname: str, role: str = "member") -> int:
"""Подготовка данных: добавить игрока в группу напрямую (в обход приглашений).
Создаёт пользователя при отсутствии. Возвращает user_id. Нужен, т.к. через HTTP
прямого добавления больше нет — только приглашения (см. invitation_service)."""
with Session(engine) as s:
user = s.exec(select(User).where(User.nickname == nickname)).first()
if user is None:
user = User(nickname=nickname, role="player", auth_provider="stub")
s.add(user)
s.commit()
s.refresh(user)
# AuthIdentity нужен, чтобы dev-вход (login) нашёл того же игрока, а не создал дубль.
s.add(AuthIdentity(user_id=user.id, provider="stub", external_id=nickname))
s.commit()
exists = s.exec(
select(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id == user.id
)
).first()
if exists is None:
s.add(GroupMember(group_id=group_id, user_id=user.id, role=role))
s.commit()
return user.id # type: ignore[return-value]
def start_match(client: TestClient, group_id: int, roster: list[dict]): def start_match(client: TestClient, group_id: int, roster: list[dict]):
"""roster: [{user_id, faction_id, was_random?}] → ответ старта (in_progress).""" """roster: [{user_id, faction_id, was_random?}] → ответ старта (in_progress)."""
return client.post( return client.post(
@@ -144,15 +114,8 @@ def finish_match(
def create_finished_match( def create_finished_match(
client: TestClient, group_id: int, players: list[dict], win_reason: str = "objectives" client: TestClient, group_id: int, players: list[dict], win_reason: str = "objectives"
) -> dict: ) -> dict:
"""players: [{user_id, faction_id, place, comment?, was_random?}] → завершённая партия.""" """players: [{user_id, faction_id, place, comment?}] → завершённая партия (для статистики)."""
roster = [ roster = [{"user_id": p["user_id"], "faction_id": p["faction_id"]} for p in players]
{
"user_id": p["user_id"],
"faction_id": p["faction_id"],
"was_random": p.get("was_random", False),
}
for p in players
]
started = start_match(client, group_id, roster) started = start_match(client, group_id, roster)
assert started.status_code == 200, started.text assert started.status_code == 200, started.text
results = [ results = [
-21
View File
@@ -111,24 +111,3 @@ def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path
f"/api/admin/achievements/{slug}", headers=csrf_headers(client) f"/api/admin/achievements/{slug}", headers=csrf_headers(client)
).status_code == 200 ).status_code == 200
assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json()) assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json())
def test_delete_rejects_traversal_slug(client: TestClient, make_admin, monkeypatch, tmp_path):
"""Slug из URL не должен уводить файловые операции за каталог ачивок.
Регрессия: `DELETE /api/admin/achievements/%2E%2E` снимал rmtree'ом родительскую
папку каталога (в проде это /data — БД, uploads и ачивки разом)."""
root = _use_tmp_achievements(monkeypatch, tmp_path / "achievements")
root.mkdir(parents=True, exist_ok=True)
sibling = tmp_path / "db.sqlite3"
sibling.write_bytes(b"data")
_admin(client, make_admin)
# Именно percent-кодированная форма: обычные точки httpx нормализует ещё до
# отправки, запрос уходит на /api/admin/ и до обработчика вовсе не доходит.
r = client.request(
"DELETE", "/api/admin/achievements/%2E%2E", headers=csrf_headers(client)
)
assert r.status_code == 404, r.text
assert r.json()["error"]["code"] == "NOT_FOUND" # ответ обработчика, а не промах роутинга
assert sibling.exists() and root.is_dir()
+27 -119
View File
@@ -2,12 +2,8 @@
from __future__ import annotations from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine, select
from app.models import Faction from tests.conftest import create_finished_match, csrf_headers, login, start_match
from app.seed.reference_data import FACTIONS, seed_reference_data
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
def _exp_id(client: TestClient, code: str) -> int: def _exp_id(client: TestClient, code: str) -> int:
@@ -24,25 +20,26 @@ def _admin_login(client: TestClient, make_admin) -> None:
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
def test_group_invite_capacity_limit(client: TestClient): def test_group_size_limit(client: TestClient):
login(client, "Owner") login(client, "Owner")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Большая", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Большая", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
# owner = 1; добавляем до 10, 10-й участник сверх лимита → 409.
for i in range(1, 11): for i in range(1, 11):
client.post("/api/auth/dev/users", json={"nickname": f"U{i}"}, headers=csrf_headers(client)) client.post("/api/auth/dev/users", json={"nickname": f"U{i}"}, headers=csrf_headers(client))
statuses = [] statuses = []
for i in range(1, 11): for i in range(1, 11):
r = client.post( r = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": f"U{i}"}, headers=csrf_headers(client) f"/api/groups/{gid}/members", json={"nickname": f"U{i}"}, headers=csrf_headers(client)
) )
statuses.append(r.status_code) statuses.append(r.status_code)
# owner(1) + 9 приглашений = 10 (лимит мест); 10-е приглашение сверх лимита → 409. # 9 успешных (итого 10 с владельцем), 10-й — отказ.
assert statuses[:9] == [200] * 9 assert statuses[:9] == [200] * 9
assert statuses[9] == 409 assert statuses[9] == 409
def test_match_player_limit(client: TestClient, engine): def test_match_player_limit(client: TestClient):
me = login(client, "Owner") me = login(client, "Owner")
fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids") fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids")
gid = client.post( gid = client.post(
@@ -50,7 +47,12 @@ def test_match_player_limit(client: TestClient, engine):
).json()["id"] ).json()["id"]
ids = [me["id"]] ids = [me["id"]]
for i in range(1, 7): for i in range(1, 7):
ids.append(add_group_member(engine, gid, f"P{i}")) 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() factions = client.get(f"/api/groups/{gid}/factions").json()
fids = [f["id"] for f in factions] fids = [f["id"] for f in factions]
# 7 игроков на старте → 422 (максимум 6). # 7 игроков на старте → 422 (максимум 6).
@@ -60,12 +62,15 @@ def test_match_player_limit(client: TestClient, engine):
assert "6" in r.json()["error"]["message"] assert "6" in r.json()["error"]["message"]
def test_admin_edit_match_and_filters(client: TestClient, make_admin, engine): def test_admin_edit_match_and_filters(client: TestClient, make_admin):
me = login(client, "Аня") me = login(client, "Аня")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
b = add_group_member(engine, gid, "Боря") 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()} factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
mid = create_finished_match( mid = create_finished_match(
client, client,
@@ -124,14 +129,17 @@ def test_admin_rename_account(client: TestClient, make_admin):
assert r.status_code == 422, r.text assert r.status_code == 422, r.text
def test_dev_delete_account_keeps_matches(client: TestClient, make_admin, engine): def test_dev_delete_account_keeps_matches(client: TestClient, make_admin):
"""DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается), """DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается),
его созданные партии и группы переназначаются на админа.""" его созданные партии и группы переназначаются на админа."""
me = login(client, "Аня") # владелец группы и создатель партии me = login(client, "Аня") # владелец группы и создатель партии
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
b = add_group_member(engine, gid, "Боря") 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()} factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
mid = create_finished_match( mid = create_finished_match(
client, client,
@@ -179,12 +187,15 @@ def test_dev_delete_account_rejects_admin(client: TestClient, make_admin):
assert r.status_code == 422, r.text assert r.status_code == 422, r.text
def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine): def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
me = login(client, "Кто-то") me = login(client, "Кто-то")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
b = add_group_member(engine, gid, "Друг") 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()} factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
mid = create_finished_match( mid = create_finished_match(
client, client,
@@ -207,106 +218,3 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine
detail = client.get(f"/api/admin/matches/{mid}").json() detail = client.get(f"/api/admin/matches/{mid}").json()
ap = next(p for p in detail["participants"] if p["user_id"] == me["id"]) ap = next(p for p in detail["participants"] if p["user_id"] == me["id"])
assert ap["faction_name"] == "Орки WAAAGH" assert ap["faction_name"] == "Орки WAAAGH"
def test_faction_rename_survives_restart_seeding(client: TestClient, make_admin, engine):
"""Сидинг идёт при каждом старте (entrypoint.sh, lifespan) и не должен откатывать
имя, заданное админом (#72)."""
login(client, "Кто-то")
orks = next(f for f in client.get("/api/factions").json() if f["code"] == "orks")
_admin_login(client, make_admin)
r = client.patch(
f"/api/admin/factions/{orks['id']}",
json={"name_ru": "Орки WAAAGH"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
with Session(engine) as s:
seed_reference_data(s) # то же, что делает рестарт
assert s.get(Faction, orks["id"]).name_ru == "Орки WAAAGH"
def test_seeding_is_complete_and_idempotent():
"""Пустая БД: фракции из кода создаются со своими именами, повтор ничего не ломает."""
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
SQLModel.metadata.create_all(engine)
with Session(engine) as s:
seed_reference_data(s)
seed_reference_data(s)
names = {f.code: f.name_ru for f in s.exec(select(Faction)).all()}
assert names == {code: name for code, name, _exp, _order in FACTIONS}
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
Регрессия: matches.group_id — ON DELETE RESTRICT, и голый session.delete ронял
IntegrityError наружу пятисоткой без конверта ошибки."""
me = login(client, "Owner")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
p2 = add_group_member(engine, gid, "Игрок2")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
create_finished_match(
client, gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": p2, "faction_id": fids[1], "place": 2},
],
)
_admin_login(client, make_admin)
r = client.delete(f"/api/admin/groups/{gid}", headers=csrf_headers(client))
assert r.status_code == 409, r.text
# Важен не только код ответа: группа и её партии должны пережить отказ.
assert any(g["id"] == gid for g in client.get("/api/admin/groups").json())
assert any(m["group_id"] == gid for m in client.get("/api/admin/matches").json())
def test_last_owner_cannot_demote_self(client: TestClient, engine):
"""Единственный владелец не может разжаловать сам себя.
Регрессия: change_role не проверял последнего владельца (в отличие от удаления),
и группа оставалась без владельца навсегда — назначить нового было некому."""
me = login(client, "Owner")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
add_group_member(engine, gid, "Игрок2")
r = client.patch(
f"/api/groups/{gid}/members/{me['id']}",
json={"role": "member"},
headers=csrf_headers(client),
)
assert r.status_code == 403, r.text
members = client.get(f"/api/groups/{gid}/members").json()
assert any(m["user_id"] == me["id"] and m["role"] == "owner" for m in members)
def test_ownership_transfer_still_works(client: TestClient, engine):
"""Обратная сторона защиты последнего владельца: передать роль по-прежнему можно."""
me = login(client, "Owner")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
p2 = add_group_member(engine, gid, "Игрок2")
promote = client.patch(
f"/api/groups/{gid}/members/{p2}", json={"role": "owner"}, headers=csrf_headers(client)
)
assert promote.status_code == 200, promote.text
# Владельцев теперь двое — прежний может сложить полномочия.
demote = client.patch(
f"/api/groups/{gid}/members/{me['id']}",
json={"role": "member"},
headers=csrf_headers(client),
)
assert demote.status_code == 200, demote.text
members = client.get(f"/api/groups/{gid}/members").json()
assert [m["user_id"] for m in members if m["role"] == "owner"] == [p2]
@@ -1,77 +0,0 @@
"""Ротация пароля администратора из .env (#73): `python -m app.bootstrap --reset-admin-password`."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from app import bootstrap
from app.core.security import verify_password
from app.models import User
from tests.conftest import csrf_headers
OLD, NEW = "secret123", "brand-new-admin-pw"
def _admin(engine) -> User:
with Session(engine) as s:
return s.exec(select(User).where(User.role == "admin")).one()
def _login(client: TestClient, password: str):
return client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": password},
headers=csrf_headers(client),
)
def test_reset_applies_env_password_and_revokes_sessions(
client: TestClient, engine, make_admin, monkeypatch
):
make_admin("admin", OLD)
assert _login(client, OLD).status_code == 200
assert client.get("/api/admin/me").status_code == 200
version = _admin(engine).token_version
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
with Session(engine) as s:
bootstrap.reset_admin_password(s)
admin = _admin(engine)
assert verify_password(NEW, admin.password_hash)
assert admin.token_version == version + 1
# Прежняя админская cookie больше не действует (в т.ч. у того, кто украл пароль).
assert client.get("/api/admin/me").status_code == 401
assert _login(client, OLD).status_code == 401
assert _login(client, NEW).status_code == 200
def test_regular_bootstrap_keeps_admin_password_outside_dev(engine, make_admin, monkeypatch):
"""Обычный старт в prod по-прежнему не берёт пароль из .env — только ротация."""
make_admin("admin", OLD)
monkeypatch.setattr(bootstrap.settings, "app_env", "production")
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
with Session(engine) as s:
bootstrap._ensure_admin(s)
assert verify_password(OLD, _admin(engine).password_hash)
def test_reset_refuses_without_admin_or_password(engine, make_admin, monkeypatch):
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
with Session(engine) as s, pytest.raises(RuntimeError, match="Администратора ещё нет"):
bootstrap.reset_admin_password(s)
make_admin("admin", OLD)
monkeypatch.setattr(bootstrap.settings, "admin_password", " ")
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_PASSWORD пуст"):
bootstrap.reset_admin_password(s)
assert verify_password(OLD, _admin(engine).password_hash)
def test_reset_refuses_when_bootstrap_disabled(engine, make_admin, monkeypatch):
make_admin("admin", OLD)
monkeypatch.setattr(bootstrap.settings, "admin_bootstrap_enabled", False)
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_BOOTSTRAP_ENABLED"):
bootstrap.reset_admin_password(s)
-286
View File
@@ -1,286 +0,0 @@
"""Объявления администрации (#84): очистка HTML, права, период показа, «новые игроки»,
повторный показ с пометкой «обновлено», снятие с показа и удаление."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from app.models import AnnouncementView, User
from app.services import notify
from app.services.announcement_service import sanitize_body
from tests.conftest import csrf_headers, login
def _iso(dt: datetime) -> str:
return dt.astimezone(timezone.utc).isoformat()
def _now() -> datetime:
return datetime.now(timezone.utc)
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 _create(client: TestClient, **over) -> dict:
body = {
"title": "Турнир",
"body_html": "<p>Суббота, <b>11:00</b></p>",
"starts_at": _iso(_now() - timedelta(hours=1)),
"ends_at": _iso(_now() + timedelta(days=1)),
"show_to_new_players": True,
}
body.update(over)
r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client))
assert r.status_code == 200, r.text
return r.json()
def _update(client: TestClient, ann: dict, **over) -> dict:
body = {
"title": ann["title"],
"body_html": ann["body_html"],
"starts_at": ann["starts_at"],
"ends_at": ann["ends_at"],
"show_to_new_players": ann["show_to_new_players"],
"reshow": False,
}
body.update(over)
r = client.put(
f"/api/admin/announcements/{ann['id']}", json=body, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
return r.json()
def _pending(client: TestClient) -> list[dict]:
r = client.get("/api/announcements/pending")
assert r.status_code == 200, r.text
return r.json()
def _ack(client: TestClient, ann_id: int, revision: int):
return client.post(
f"/api/announcements/{ann_id}/ack",
json={"revision": revision},
headers=csrf_headers(client),
)
def _admin_item(client: TestClient, ann_id: int) -> dict:
return next(a for a in client.get("/api/admin/announcements").json() if a["id"] == ann_id)
# ─── Очистка HTML ────────────────────────────────────────────────────────────
def test_sanitize_keeps_allowed_and_strips_everything_else():
raw = (
'<div>Привет <strong onclick="steal()">мир</strong></div>'
'<p style="color:red"><i>курсив</i> <mark class="red big">красный</mark> '
'<mark class="gold" onmouseover="x()">золотой</mark></p>'
"<script>alert(1)</script><style>p{display:none}</style>"
'<img src=x onerror="alert(1)"><a href="javascript:alert(1)">ссылка</a>'
"<svg><text>svg-текст</text></svg>&lt;b&gt;"
)
html, text = sanitize_body(raw)
assert html == (
"<p>Привет <b>мир</b></p>"
'<p><em>курсив</em> <mark class="red">красный</mark> <mark>золотой</mark></p>'
"ссылка&lt;b&gt;"
)
# Содержимое script/style/svg в видимый текст (и в счётчик длины) не попадает.
assert text == "Привет миркурсив красный золотойссылка<b>"
def test_sanitize_fixes_nesting_and_keeps_line_breaks():
html, _ = sanitize_body("<div><div>один</div><div>два<br></div></div><b>жирный<br/>хвост")
# Вложенные <div> из contenteditable → плоские абзацы без пустых <p></p>;
# <br> внутри <b> не путается с самим <b>; незакрытое закрывается.
assert html == "<p>один</p><p>два<br></p><b>жирный<br>хвост</b>"
# ─── Права ───────────────────────────────────────────────────────────────────
def test_admin_endpoints_are_closed_to_players(client: TestClient):
assert client.get("/api/admin/announcements").status_code == 401
login(client, "Игрок")
assert client.get("/api/admin/announcements").status_code == 401
r = client.post(
"/api/admin/announcements",
json={
"title": "x",
"body_html": "y",
"starts_at": _iso(_now()),
"ends_at": _iso(_now() + timedelta(days=1)),
},
headers=csrf_headers(client),
)
assert r.status_code == 401
def test_pending_requires_player_session(client: TestClient):
assert client.get("/api/announcements/pending").status_code == 401
# ─── Показ ───────────────────────────────────────────────────────────────────
def test_created_announcement_is_shown_once(client: TestClient, make_admin, monkeypatch):
events: list[dict] = []
monkeypatch.setattr(notify.hub, "publish", lambda ids, ev: events.append(ev))
login(client, "Игрок")
_admin_login(client, make_admin)
ann = _create(client, body_html="<p>Сбор <b>в 11:00</b></p><script>alert(1)</script>")
assert ann["body_html"] == "<p>Сбор <b>в 11:00</b></p>"
assert ann["status"] == "live"
assert (ann["seen_count"], ann["audience_count"]) == (0, 1)
assert {"type": "announcements"} in events
items = _pending(client)
assert [(a["id"], a["revision"], a["updated"]) for a in items] == [(ann["id"], 1, False)]
assert _ack(client, ann["id"], 1).status_code == 200
assert _pending(client) == []
assert _admin_item(client, ann["id"])["seen_count"] == 1
logs = client.get("/api/admin/audit-logs?entity_type=announcement").json()["items"]
assert [(l["action"], l["entity_id"]) for l in logs] == [("create", ann["id"])]
def test_pending_respects_period_and_order(client: TestClient, make_admin):
login(client, "Игрок")
_admin_login(client, make_admin)
later = _create(client, title="Позже", starts_at=_iso(_now() - timedelta(minutes=30)))
earlier = _create(client, title="Раньше", starts_at=_iso(_now() - timedelta(hours=3)))
planned = _create(
client,
title="Завтра",
starts_at=_iso(_now() + timedelta(days=1)),
ends_at=_iso(_now() + timedelta(days=2)),
)
assert planned["status"] == "planned"
# Пересекающиеся периоды — от старого к новому; запланированного пока нет.
assert [a["title"] for a in _pending(client)] == ["Раньше", "Позже"]
assert earlier["id"] != later["id"]
# Период, который уже закончился, создать нельзя.
r = client.post(
"/api/admin/announcements",
json={
"title": "Прошлое",
"body_html": "текст",
"starts_at": _iso(_now() - timedelta(days=2)),
"ends_at": _iso(_now() - timedelta(days=1)),
},
headers=csrf_headers(client),
)
assert r.status_code == 422
def test_hidden_from_players_registered_after_start(client: TestClient, make_admin, engine):
login(client, "Старожил")
with Session(engine) as s:
old = s.exec(select(User).where(User.nickname == "Старожил")).one()
old.created_at = datetime.now(timezone.utc) - timedelta(days=2)
s.add(old)
s.commit()
_admin_login(client, make_admin)
ann = _create(client, show_to_new_players=False)
assert ann["audience_count"] == 1
assert [a["id"] for a in _pending(client)] == [ann["id"]]
login(client, "Новичок") # зарегистрирован уже после начала показа
assert _pending(client) == []
# Адресаты — по-прежнему только старожил.
assert _admin_item(client, ann["id"])["audience_count"] == 1
def test_reshow_brings_announcement_back_marked_updated(client: TestClient, make_admin):
login(client, "Игрок")
_admin_login(client, make_admin)
ann = _create(client)
assert _ack(client, ann["id"], 1).status_code == 200
# Правка без «показать заново» — закрывшие её не видят.
ann = _update(client, ann, body_html="<p>Сбор в 12:00</p>")
assert ann["revision"] == 1
assert _pending(client) == []
ann = _update(client, ann, body_html="<p>Сбор в 13:00</p>", reshow=True)
assert ann["revision"] == 2
assert ann["seen_count"] == 0 # закрывших новую версию ещё нет
items = _pending(client)
assert [(a["revision"], a["updated"], a["body_html"]) for a in items] == [
(2, True, "<p>Сбор в 13:00</p>")
]
# Пока окно висело, админ выпустил третью версию: закрыв вторую, игрок увидит третью.
ann = _update(client, ann, reshow=True)
assert _ack(client, ann["id"], 2).status_code == 200
assert [(a["revision"], a["updated"]) for a in _pending(client)] == [(3, True)]
assert _ack(client, ann["id"], 3).status_code == 200
assert _pending(client) == []
def test_stop_and_delete(client: TestClient, make_admin, engine):
login(client, "Игрок")
_admin_login(client, make_admin)
live = _create(client)
planned = _create(
client,
starts_at=_iso(_now() + timedelta(days=1)),
ends_at=_iso(_now() + timedelta(days=2)),
)
r = client.post(f"/api/admin/announcements/{planned['id']}/stop", headers=csrf_headers(client))
assert r.status_code == 422 # снять можно только идущее
r = client.post(f"/api/admin/announcements/{live['id']}/stop", headers=csrf_headers(client))
assert r.status_code == 200, r.text
assert r.json()["status"] == "finished"
assert _pending(client) == []
assert _ack(client, live["id"], 1).status_code == 200 # закрыть можно и снятое
r = client.delete(f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client))
assert r.status_code == 200
with Session(engine) as s:
assert s.exec(select(AnnouncementView)).all() == [] # отметки ушли каскадом
assert client.delete(
f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client)
).status_code == 404
assert _ack(client, live["id"], 1).status_code == 404
@pytest.mark.parametrize(
("over", "message_part"),
[
({"title": " "}, "Заголовок"),
({"title": "Я" * 61}, "Заголовок"),
({"body_html": "<p> </p><script>текст</script>"}, "пустым"),
({"body_html": "<p>" + "а" * 601 + "</p>"}, "600"),
({"ends_at": _iso(_now() - timedelta(hours=2))}, "позже начала"),
],
)
def test_validation(client: TestClient, make_admin, over: dict, message_part: str):
_admin_login(client, make_admin)
body = {
"title": "Заголовок",
"body_html": "<p>текст</p>",
"starts_at": _iso(_now() - timedelta(hours=1)),
"ends_at": _iso(_now() + timedelta(days=1)),
}
body.update(over)
r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client))
assert r.status_code == 422, r.text
err = r.json()["error"]
assert err["code"] == "VALIDATION_ERROR"
assert message_part in err["message"]
-22
View File
@@ -1,22 +0,0 @@
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev (#61, F6)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.core import config
from app.main import create_app
def test_openapi_open_in_development(client: TestClient):
# Тесты идут в development (conftest) — схема доступна: нужна для `npm run gen:api`.
assert client.get("/api/openapi.json").status_code == 200
assert client.get("/api/docs").status_code == 200
def test_openapi_closed_in_production(monkeypatch):
monkeypatch.setattr(config.settings, "app_env", "production")
prod_app = create_app()
c = TestClient(prod_app)
assert c.get("/api/openapi.json").status_code == 404
assert c.get("/api/docs").status_code == 404
assert c.get("/api/redoc").status_code == 404
-160
View File
@@ -1,160 +0,0 @@
"""Медиа партии (фото): загрузка/просмотр/удаление, валидация, лимит, запрет после финиша."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
def _use_tmp_uploads(monkeypatch, tmp_path) -> None:
from app.core.config import settings
monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path))
def _start(client: TestClient, engine) -> tuple[dict, int, int, int]:
me = login(client, "Хост")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
p2 = add_group_member(engine, gid, "Игрок2")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
started = start_match(
client,
gid,
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
)
assert started.status_code == 200, started.text
return me, gid, p2, started.json()["id"]
def _upload(client: TestClient, mid: int, name: str = "a.png", data: bytes = PNG, mime: str = "image/png"):
return client.post(
f"/api/matches/{mid}/attachments",
files={"file": (name, data, mime)},
headers=csrf_headers(client),
)
def test_upload_view_delete(client: TestClient, engine, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path)
me, gid, p2, mid = _start(client, engine)
r = _upload(client, mid)
assert r.status_code == 200, r.text
aid = r.json()["id"]
assert r.json()["url"] == f"/api/matches/{mid}/attachments/{aid}"
detail = client.get(f"/api/matches/{mid}").json()
assert [a["id"] for a in detail["attachments"]] == [aid]
g = client.get(f"/api/matches/{mid}/attachments/{aid}")
assert g.status_code == 200 and g.content == PNG
assert g.headers["content-type"] == "image/png"
d = client.delete(f"/api/matches/{mid}/attachments/{aid}", headers=csrf_headers(client))
assert d.status_code == 200, d.text
assert client.get(f"/api/matches/{mid}").json()["attachments"] == []
def test_reject_non_image_and_limit(client: TestClient, engine, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path)
_me, _gid, _p2, mid = _start(client, engine)
assert _upload(client, mid, "x.txt", b"nope", "text/plain").status_code == 422
for i in range(10):
assert _upload(client, mid, f"{i}.png").status_code == 200
assert _upload(client, mid, "over.png").status_code == 409
def test_no_changes_after_finish(client: TestClient, engine, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path)
me, gid, p2, mid = _start(client, engine)
aid = _upload(client, mid).json()["id"]
fin = finish_match(
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}],
win_reason="objectives",
)
assert fin.status_code == 200, fin.text
# После завершения игрок не может ни добавлять, ни удалять.
assert _upload(client, mid).status_code == 409
assert client.delete(
f"/api/matches/{mid}/attachments/{aid}", headers=csrf_headers(client)
).status_code == 409
# Но просмотр сохраняется.
assert client.get(f"/api/matches/{mid}").json()["attachments"][0]["id"] == aid
def test_admin_manage_attachments_on_finished(
client: TestClient, engine, make_admin, monkeypatch, tmp_path
):
_use_tmp_uploads(monkeypatch, tmp_path)
me, gid, p2, mid = _start(client, engine)
fin = finish_match(
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}],
win_reason="objectives",
)
assert fin.status_code == 200, fin.text
make_admin("admin", "secret123")
assert client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
).status_code == 200
# Админ добавляет медиа к ЗАВЕРШЁННОЙ партии (игроку это запрещено).
up = client.post(
f"/api/admin/matches/{mid}/attachments",
files={"file": ("a.png", PNG, "image/png")},
headers=csrf_headers(client),
)
assert up.status_code == 200, up.text
aid = up.json()["id"]
assert up.json()["url"] == f"/api/admin/matches/{mid}/attachments/{aid}"
assert any(a["id"] == aid for a in client.get(f"/api/admin/matches/{mid}/attachments").json())
g = client.get(f"/api/admin/matches/{mid}/attachments/{aid}")
assert g.status_code == 200 and g.content == PNG
d = client.delete(
f"/api/admin/matches/{mid}/attachments/{aid}", headers=csrf_headers(client)
)
assert d.status_code == 200, d.text
assert client.get(f"/api/admin/matches/{mid}/attachments").json() == []
def test_non_member_cannot_view(client: TestClient, engine, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path)
_me, _gid, _p2, mid = _start(client, engine)
aid = _upload(client, mid).json()["id"]
login(client, "Чужак") # не состоит в группе
g = client.get(f"/api/matches/{mid}/attachments/{aid}")
assert g.status_code in (401, 403)
def test_attachment_upload_moves_version(client: TestClient, engine, monkeypatch, tmp_path):
"""Вложения видны в MatchRead, но строку matches не трогают.
Регрессия: из-за этого версия партии не двигалась, и правка со старой версией
проходила мимо оптимистичной блокировки."""
_use_tmp_uploads(monkeypatch, tmp_path)
me, gid, p2, mid = _start(client, engine)
v1 = client.get(f"/api/matches/{mid}").json()["version"]
assert _upload(client, mid).status_code == 200
v2 = client.get(f"/api/matches/{mid}").json()["version"]
assert v2 != v1
stale = client.delete(
f"/api/matches/{mid}", params={"expected_version": v1}, headers=csrf_headers(client)
)
assert stale.status_code == 409, stale.text
assert stale.json()["error"]["code"] == "STALE_WRITE"
+8 -46
View File
@@ -10,9 +10,8 @@ from fastapi.testclient import TestClient
from tests.conftest import csrf_headers from tests.conftest import csrf_headers
def test_auth_config_dev_has_all_methods(client: TestClient): def test_auth_config_dev_has_both_methods(client: TestClient):
cfg = client.get("/api/auth/config").json() cfg = client.get("/api/auth/config").json()
assert "password" in cfg["methods"]
assert "telegram" in cfg["methods"] assert "telegram" in cfg["methods"]
assert "stub" in cfg["methods"] # dev → доступен вход по нику assert "stub" in cfg["methods"] # dev → доступен вход по нику
@@ -22,13 +21,15 @@ def test_enabled_methods_by_env(monkeypatch):
from app.core.config import settings from app.core.config import settings
monkeypatch.setattr(settings, "app_env", "development") monkeypatch.setattr(settings, "app_env", "development")
assert set(enabled_methods()) == {"password", "telegram", "stub"} assert set(enabled_methods()) == {"telegram", "stub"}
monkeypatch.setattr(settings, "app_env", "test")
assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram
monkeypatch.setattr(settings, "app_env", "production") monkeypatch.setattr(settings, "app_env", "production")
assert enabled_methods() == ["password", "telegram"] # prod → без stub assert enabled_methods() == ["telegram"] # prod → только Telegram
def test_env_flags_and_db_path(monkeypatch): def test_env_flags_and_db_path(monkeypatch):
"""dev → файл дева; prod → том /data.""" """dev → файл дева; test и prod → том /data (общая ветвь is_development)."""
from app.core.config import settings from app.core.config import settings
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db") monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db")
@@ -36,6 +37,8 @@ def test_env_flags_and_db_path(monkeypatch):
monkeypatch.setattr(settings, "app_env", "development") monkeypatch.setattr(settings, "app_env", "development")
assert settings.is_development and settings.database_url == "sqlite:///dev.db" 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") monkeypatch.setattr(settings, "app_env", "production")
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db" assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
@@ -55,47 +58,6 @@ def _telegram_payload(token: str, **fields) -> dict:
return {**data, "hash": sig} return {**data, "hash": sig}
def test_dev_login_into_telegram_user_no_duplicate(client: TestClient, engine, monkeypatch):
"""Dev-вход по нику Telegram-аккаунта входит в него, а не создаёт дубль «ник 2»."""
from sqlmodel import Session, select
from app.core.config import settings
from app.models import User
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
r = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN"))
assert r.status_code == 200, r.text
tg_id, nick = r.json()["id"], r.json()["nickname"]
client.cookies.clear() # «другой вход» — без сессии
r2 = client.post("/api/auth/dev/login", json={"nickname": nick})
assert r2.status_code == 200, r2.text
assert r2.json()["id"] == tg_id # тот же аккаунт, не новый
with Session(engine) as s:
users = s.exec(select(User).where(User.nickname == nick)).all()
assert len(users) == 1 # дубликат не создан
def test_dev_login_after_rename_no_duplicate(client: TestClient, engine):
"""После смены ника dev-вход по новому нику входит в того же игрока (без дубля)."""
from sqlmodel import Session, select
from app.models import User
uid = client.post("/api/auth/dev/login", json={"nickname": "Старый"}).json()["id"]
pr = client.patch("/api/users/me", json={"nickname": "Новый"}, headers=csrf_headers(client))
assert pr.status_code == 200, pr.text
client.cookies.clear()
r2 = client.post("/api/auth/dev/login", json={"nickname": "Новый"})
assert r2.status_code == 200, r2.text
assert r2.json()["id"] == uid
with Session(engine) as s:
assert len(s.exec(select(User).where(User.nickname == "Новый")).all()) == 1
def test_telegram_login_valid_signature(client: TestClient, monkeypatch): def test_telegram_login_valid_signature(client: TestClient, monkeypatch):
from app.core.config import settings from app.core.config import settings
-113
View File
@@ -1,113 +0,0 @@
"""Оптимистичная блокировка партии: устаревшие правки/удаление отклоняются (STALE_WRITE)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
def _start(client: TestClient, engine) -> tuple[dict, int, int]:
me = login(client, "Хост")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
p2 = add_group_member(engine, gid, "Игрок2")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
started = start_match(
client, gid,
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
)
assert started.status_code == 200, started.text
return me, p2, started.json()["id"]
def test_stale_delete_rejected(client: TestClient, engine):
"""Сценарий бага: ПК завершил, телефон со старой версией жмёт «Отменить»."""
me, p2, mid = _start(client, engine)
v1 = client.get(f"/api/matches/{mid}").json()["version"]
# «ПК» завершает партию — версия меняется.
fin = finish_match(
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}],
win_reason="objectives",
)
assert fin.status_code == 200, fin.text
# «Телефон» со старой версией пытается отменить → 409 STALE_WRITE, партия НЕ удаляется.
stale = client.delete(
f"/api/matches/{mid}", params={"expected_version": v1}, headers=csrf_headers(client)
)
assert stale.status_code == 409, stale.text
assert stale.json()["error"]["code"] == "STALE_WRITE"
assert client.get(f"/api/matches/{mid}").status_code == 200 # жива
# С актуальной версией удаление проходит.
v2 = client.get(f"/api/matches/{mid}").json()["version"]
ok = client.delete(
f"/api/matches/{mid}", params={"expected_version": v2}, headers=csrf_headers(client)
)
assert ok.status_code == 200, ok.text
assert client.get(f"/api/matches/{mid}").status_code == 404
def test_stale_finish_rejected(client: TestClient, engine):
me, p2, mid = _start(client, engine)
v1 = client.get(f"/api/matches/{mid}").json()["version"]
# Партию изменили (правка комментария) — версия устарела.
bump = client.patch(
f"/api/matches/{mid}", json={"overall_comment": "правка"}, headers=csrf_headers(client)
)
assert bump.status_code == 200, bump.text
# Завершение со старой версией → 409 STALE_WRITE.
r = client.post(
f"/api/matches/{mid}/finish",
json={
"participants": [
{"user_id": me["id"], "place": 1},
{"user_id": p2, "place": 2},
],
"win_reason": "objectives",
"expected_version": v1,
},
headers=csrf_headers(client),
)
assert r.status_code == 409 and r.json()["error"]["code"] == "STALE_WRITE", r.text
def test_participant_edit_moves_version(client: TestClient, engine):
"""Правка одних участников тоже двигает версию партии.
Регрессия: updated_at менялся только при UPDATE строки matches, поэтому после
правки участников версия оставалась прежней и вторая правка со старой версией
проходила вместо 409 — ровно то, от чего защищает блокировка."""
me, p2, mid = _start(client, engine)
fin = finish_match(
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}],
win_reason="objectives",
)
assert fin.status_code == 200, fin.text
v1 = client.get(f"/api/matches/{mid}").json()["version"]
parts = {p["user_id"]: p["faction_id"] for p in client.get(f"/api/matches/{mid}").json()["participants"]}
swap = [
{"user_id": me["id"], "faction_id": parts[me["id"]], "place": 2},
{"user_id": p2, "faction_id": parts[p2], "place": 1},
]
r = client.patch(
f"/api/matches/{mid}",
json={"participants": swap, "expected_version": v1},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
assert client.get(f"/api/matches/{mid}").json()["version"] != v1
stale = client.patch(
f"/api/matches/{mid}",
json={"participants": swap, "expected_version": v1},
headers=csrf_headers(client),
)
assert stale.status_code == 409, stale.text
assert stale.json()["error"]["code"] == "STALE_WRITE"
-159
View File
@@ -1,159 +0,0 @@
"""Fail-fast конфигурации: опубликованное приложение (production и dev на домене) не
стартует с дефолтными секретами (#59, F4, #69)."""
from __future__ import annotations
import logging
import pytest
from pydantic import ValidationError
from app.core import config
_STRONG_SECRET = "k" * 40
_STRONG_ADMIN_PW = "a-strong-admin-password"
def test_production_rejects_default_secret_key():
with pytest.raises(ValidationError):
config.Settings(
app_env="production",
secret_key=config._DEFAULT_SECRET_KEY,
admin_password=_STRONG_ADMIN_PW,
)
def test_production_rejects_short_secret_key():
with pytest.raises(ValidationError):
config.Settings(
app_env="production",
secret_key="too-short",
admin_password=_STRONG_ADMIN_PW,
)
def test_production_rejects_default_admin_password():
with pytest.raises(ValidationError):
config.Settings(
app_env="production",
secret_key=_STRONG_SECRET,
admin_bootstrap_enabled=True,
admin_password=config._DEFAULT_ADMIN_PASSWORD,
)
def test_production_accepts_strong_secrets():
s = config.Settings(
app_env="production",
secret_key=_STRONG_SECRET,
admin_password=_STRONG_ADMIN_PW,
)
assert s.is_production
def test_production_skips_admin_check_when_bootstrap_disabled():
# Админ управляется вручную (bootstrap off) — дефолтный ADMIN_PASSWORD не блокирует старт.
s = config.Settings(
app_env="production",
secret_key=_STRONG_SECRET,
admin_bootstrap_enabled=False,
admin_password=config._DEFAULT_ADMIN_PASSWORD,
)
assert s.is_production
def test_development_allows_defaults():
s = config.Settings(
app_env="development",
local_public="local",
secret_key=config._DEFAULT_SECRET_KEY,
admin_password=config._DEFAULT_ADMIN_PASSWORD,
)
assert s.is_development
assert not s.is_published and not s.cookie_secure
# ─── Dev, опубликованный на домен (LOCAL_PUBLIC=vps, #69) ─────────────────────
# Снаружи он так же доступен, как прод: общеизвестный ключ JWT и пароль админа там
# открывают админку и подделку любого токена.
@pytest.mark.parametrize(
"secret_key,admin_password",
[
(config._DEFAULT_SECRET_KEY, _STRONG_ADMIN_PW),
("too-short", _STRONG_ADMIN_PW),
(_STRONG_SECRET, config._DEFAULT_ADMIN_PASSWORD),
],
ids=["default-secret", "short-secret", "default-admin-password"],
)
def test_published_dev_rejects_weak_secrets(secret_key, admin_password):
with pytest.raises(ValidationError, match="LOCAL_PUBLIC=vps"):
config.Settings(
app_env="development",
local_public="vps",
secret_key=secret_key,
admin_password=admin_password,
)
def test_published_dev_accepts_strong_secrets():
s = config.Settings(
app_env="development",
local_public="vps",
secret_key=_STRONG_SECRET,
admin_password=_STRONG_ADMIN_PW,
)
assert s.is_development and s.is_published and s.cookie_secure
def test_published_dev_warns_on_startup(monkeypatch, caplog):
"""Dev-инструменты на опубликованном dev остаются (решение владельца) — но старт
громко перечисляет, что открыто любому посетителю домена."""
from fastapi.testclient import TestClient
from app import main
monkeypatch.setattr(main.settings, "local_public", "vps")
with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()):
pass
assert "DEV ОПУБЛИКОВАН НАРУЖУ" in caplog.text
assert "вход по нику без пароля" in caplog.text
def test_local_dev_starts_quietly(caplog):
from fastapi.testclient import TestClient
from app import main
with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()):
pass
assert "DEV ОПУБЛИКОВАН НАРУЖУ" not in caplog.text
@pytest.mark.parametrize("app_env", ["test", "staging", ""])
def test_unknown_app_env_rejected(app_env):
# Отдельного test-контура больше нет: такое значение не должно молча включать прод-пути.
with pytest.raises(ValidationError):
config.Settings(app_env=app_env)
def test_app_env_case_insensitive():
s = config.Settings(app_env="Development")
assert s.is_development
# ─── Пояс приложения (#68) ────────────────────────────────────────────────────
@pytest.mark.parametrize("offset", [-13, 15])
def test_tz_offset_out_of_range_rejected(offset):
with pytest.raises(ValidationError, match="APP_TZ_OFFSET_HOURS"):
config.Settings(app_env="development", app_tz_offset_hours=offset)
def test_auth_config_exposes_app_tz_offset(client, monkeypatch):
"""Фронт показывает время в поясе приложения — смещение приходит из настроек."""
from app.routers import auth
monkeypatch.setattr(auth.settings, "app_tz_offset_hours", 5)
assert client.get("/api/auth/config").json()["tz_offset_hours"] == 5
+36 -47
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match from tests.conftest import csrf_headers, finish_match, login, start_match
def _expansion_id(client: TestClient, code: str) -> int: def _expansion_id(client: TestClient, code: str) -> int:
@@ -12,7 +12,7 @@ def _expansion_id(client: TestClient, code: str) -> int:
return next(e["id"] for e in r.json() if e["code"] == code) return next(e["id"] for e in r.json() if e["code"] == code)
def test_full_flow(client: TestClient, make_admin, engine): def test_full_flow(client: TestClient, make_admin):
# 1) Вход игрока A — групп ещё нет. # 1) Вход игрока A — групп ещё нет.
me = login(client, "Иван") me = login(client, "Иван")
assert me["groups"] == [] assert me["groups"] == []
@@ -44,8 +44,18 @@ def test_full_flow(client: TestClient, make_admin, engine):
me = client.get("/api/users/me").json() me = client.get("/api/users/me").json()
assert me["active_group_id"] == gid assert me["active_group_id"] == gid
# 4) Игрок B в группе (прямое добавление — подготовка данных; UX-путь теперь приглашения). # 4) Создать игрока B и добавить в группу по нику.
b_id = add_group_member(engine, gid, "Олег") 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. # 5) Доступные фракции = база (4) + Forgotten Worlds (4) = 8.
factions = client.get(f"/api/groups/{gid}/factions").json() factions = client.get(f"/api/groups/{gid}/factions").json()
@@ -140,12 +150,15 @@ def test_full_flow(client: TestClient, make_admin, engine):
assert {"Иван", "Олег"}.issubset(nicks) assert {"Иван", "Олег"}.issubset(nicks)
def test_in_progress_excluded_and_win_reason_required(client: TestClient, engine): def test_in_progress_excluded_and_win_reason_required(client: TestClient):
me = login(client, "Хост") me = login(client, "Хост")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
b = add_group_member(engine, gid, "Гость2") 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() factions = client.get(f"/api/groups/{gid}/factions").json()
f1, f2 = factions[0]["id"], factions[1]["id"] f1, f2 = factions[0]["id"], factions[1]["id"]
@@ -189,13 +202,16 @@ def test_in_progress_excluded_and_win_reason_required(client: TestClient, engine
assert all(x["id"] != mid for x in client.get("/api/home").json()["in_progress"]) 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, engine): def test_owner_can_finish_member_started_match(client: TestClient):
"""Владелец группы может завершить партию, начатую другим участником (can_modify с бэкенда).""" """Владелец группы может завершить партию, начатую другим участником (can_modify с бэкенда)."""
a = login(client, "Хозяин") a = login(client, "Хозяин")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
b = add_group_member(engine, gid, "Согрупник") 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() factions = client.get(f"/api/groups/{gid}/factions").json()
f1, f2 = factions[0]["id"], factions[1]["id"] f1, f2 = factions[0]["id"], factions[1]["id"]
@@ -220,14 +236,16 @@ def test_owner_can_finish_member_started_match(client: TestClient, engine):
assert fin.status_code == 200, fin.text assert fin.status_code == 200, fin.text
def test_any_member_can_finish_and_manage(client: TestClient, engine): def test_any_member_can_finish_and_manage(client: TestClient):
"""Рядовой участник (не создатель, не владелец) может завершать партии и менять дополнения.""" """Рядовой участник (не создатель, не владелец) может завершать партии и менять дополнения."""
a = login(client, "Овнер") a = login(client, "Овнер")
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Компания", "expansion_ids": []}, headers=csrf_headers(client) "/api/groups", json={"name": "Компания", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
b = add_group_member(engine, gid, "Бэ") for nick in ("Бэ", "Цэ"):
c = add_group_member(engine, gid, "Цэ") 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()] fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
# Владелец стартует партию A+B+C. # Владелец стартует партию A+B+C.
@@ -287,47 +305,18 @@ def test_disabled_account_cannot_login(client: TestClient, make_admin):
assert guest_dev["is_active"] is False assert guest_dev["is_active"] is False
def test_csrf_required_for_session_mutations(client: TestClient): def test_tie_ranking_and_points(client: TestClient):
"""После рефактора CSRF на ASGI защита сохраняется: мутация с сессией без X-CSRF-Token → 403."""
login(client, "Аня") # появились cookie сессии и csrf_token
no_header = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []})
assert no_header.status_code == 403
assert no_header.json()["error"]["code"] == "CSRF_FAILED"
# С корректным заголовком — проходит.
ok = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
)
assert ok.status_code == 200, ok.text
def test_group_stats_includes_inactive_members(client: TestClient, engine):
"""Участники без завершённых партий попадают в отдельный блок inactive (не в provisional)."""
me = login(client, "Капитан")
gid = client.post(
"/api/groups", json={"name": "Эскадра", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
rookie = add_group_member(engine, gid, "Новобранец")
stats = client.get(f"/api/groups/{gid}/stats").json()
inactive_ids = {e["user_id"] for e in stats["inactive"]}
prov_ids = {e["user_id"] for e in stats["provisional"]}
lb_ids = {e["user_id"] for e in stats["leaderboard"]}
# Никто ещё не играл → и владелец, и новобранец в inactive, и нигде больше.
assert {me["id"], rookie}.issubset(inactive_ids)
assert not ({me["id"], rookie} & (prov_ids | lb_ids))
entry = next(e for e in stats["inactive"] if e["user_id"] == rookie)
assert entry["games"] == 0 and entry["rank"] is None
def test_tie_ranking_and_points(client: TestClient, engine):
"""Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется.""" """Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется."""
me = login(client, "A") me = login(client, "A")
r = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []}, r = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []},
headers=csrf_headers(client)) headers=csrf_headers(client))
gid = r.json()["id"] gid = r.json()["id"]
b = add_group_member(engine, gid, "B") client.post("/api/auth/dev/users", json={"nickname": "B"}, headers=csrf_headers(client))
c = add_group_member(engine, gid, "C") 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() factions = client.get(f"/api/groups/{gid}/factions").json()
f1, f2, f3 = (factions[0]["id"], factions[1]["id"], factions[2]["id"]) f1, f2, f3 = (factions[0]["id"], factions[1]["id"], factions[2]["id"])
a = me["id"] a = me["id"]
-150
View File
@@ -1,150 +0,0 @@
"""CSRF double-submit: токен восстанавливается, если сессия пережила cookie csrf_token,
а сама проверка мутаций остаётся такой же строгой."""
from __future__ import annotations
import asyncio
from fastapi.testclient import TestClient
from tests.conftest import csrf_headers, login
def _set_cookie(resp, name: str) -> str | None:
"""Заголовок Set-Cookie для cookie name (или None, если ответ её не ставит)."""
for header in resp.headers.get_list("set-cookie"):
if header.startswith(f"{name}="):
return header
return None
def _max_age(set_cookie: str) -> int:
for part in set_cookie.split(";"):
key, _, value = part.strip().partition("=")
if key.lower() == "max-age":
return int(value)
raise AssertionError(f"нет Max-Age: {set_cookie}")
def test_missing_token_reissued_on_safe_request(client: TestClient):
"""Сессия жива, csrf_token истёк → первый же GET отдаёт новый токен, мутации проходят."""
login(client, "Игрок")
client.cookies.delete("csrf_token")
me = client.get("/api/users/me")
assert me.status_code == 200, me.text
assert _set_cookie(me, "csrf_token") is not None
assert client.cookies.get("csrf_token")
r = client.patch(
"/api/users/me/profile", json={"favorite_faction_id": None}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
def test_rejected_mutation_reissues_token(client: TestClient):
"""Отказ CSRF без cookie сам выдаёт токен: иначе не выйти и не перезайти."""
login(client, "Игрок")
client.cookies.delete("csrf_token")
r = client.post("/api/auth/logout")
assert r.status_code == 403
assert r.json()["error"]["code"] == "CSRF_FAILED"
assert _set_cookie(r, "csrf_token") is not None
r2 = client.post("/api/auth/logout", headers=csrf_headers(client))
assert r2.status_code == 200, r2.text
def test_admin_login_does_not_shorten_token(client: TestClient, make_admin):
"""Вход в админку перезаписывает общий csrf_token — срок не короче сессии игрока."""
r_user = client.post("/api/auth/dev/login", json={"nickname": "Игрок"})
session_age = _max_age(_set_cookie(r_user, "fs_session"))
make_admin("boss", "secret123")
r_admin = client.post(
"/api/admin/auth/login",
json={"username": "boss", "password": "secret123"},
headers=csrf_headers(client),
)
assert r_admin.status_code == 200, r_admin.text
assert _max_age(_set_cookie(r_admin, "csrf_token")) >= session_age
def test_check_is_not_weakened(client: TestClient):
"""Cookie есть, заголовка нет или он чужой — 403, и токен при этом не перевыдаётся."""
login(client, "Игрок")
token = client.cookies.get("csrf_token")
body = {"favorite_faction_id": None}
no_header = client.patch("/api/users/me/profile", json=body)
assert no_header.status_code == 403
assert _set_cookie(no_header, "csrf_token") is None
wrong = client.patch("/api/users/me/profile", json=body, headers={"X-CSRF-Token": "forged"})
assert wrong.status_code == 403
assert _set_cookie(wrong, "csrf_token") is None
assert client.cookies.get("csrf_token") == token
def test_anonymous_gets_no_token(client: TestClient):
r = client.get("/api/auth/config")
assert r.status_code == 200
assert _set_cookie(r, "csrf_token") is None
def _run_middleware(app_messages: list[dict], cookie: bytes) -> list[dict]:
"""Прогоняет CSRFMiddleware над фейковым приложением и возвращает отправленное."""
from app.main import CSRFMiddleware
async def fake_app(scope, receive, send): # noqa: ANN001
for message in app_messages:
await send(message)
sent: list[dict] = []
async def send(message): # noqa: ANN001
sent.append(message)
async def receive(): # pragma: no cover — фейковому приложению тело запроса не нужно
return {"type": "http.request", "body": b"", "more_body": False}
scope = {
"type": "http",
"method": "GET",
"path": "/api/events",
"raw_path": b"/api/events",
"root_path": "",
"scheme": "http",
"server": ("testserver", 80),
"query_string": b"",
"headers": [(b"cookie", cookie)],
}
asyncio.run(CSRFMiddleware(fake_app)(scope, receive, send))
return sent
def test_reissue_keeps_stream_unbuffered():
"""Перевыдача трогает только стартовое сообщение: чанки SSE идут по одному, без склейки."""
start = {"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/event-stream")]}
chunks = [
{"type": "http.response.body", "body": b": connected\n\n", "more_body": True},
{"type": "http.response.body", "body": b": ping\n\n", "more_body": True},
{"type": "http.response.body", "body": b"", "more_body": False},
]
sent = _run_middleware([start, *chunks], cookie=b"fs_session=abc")
assert sent[1:] == chunks
cookies = [v for k, v in sent[0]["headers"] if k == b"set-cookie"]
assert len(cookies) == 1 and cookies[0].startswith(b"csrf_token=")
def test_reissue_does_not_duplicate_app_cookie():
"""Если приложение само ставит csrf_token (вход), второй Set-Cookie не дописывается."""
own = (b"set-cookie", b"csrf_token=from-app; Path=/")
start = {"type": "http.response.start", "status": 200, "headers": [own]}
body = {"type": "http.response.body", "body": b"{}", "more_body": False}
sent = _run_middleware([start, body], cookie=b"fs_session=abc")
cookies = [v for k, v in sent[0]["headers"] if k == b"set-cookie"]
assert cookies == [own[1]]
-71
View File
@@ -1,71 +0,0 @@
"""Выбывшие игроки: красный крестик = последнее место (засчитывается, без отд. «проигрыша»)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
def _group(client: TestClient, name: str = "Гр") -> int:
return client.post(
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
def _start3(client: TestClient, engine):
me = login(client, "Капитан")
gid = _group(client)
b = add_group_member(engine, gid, "Боец")
c = add_group_member(engine, gid, "Третий")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
started = start_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0]},
{"user_id": b, "faction_id": fids[1]},
{"user_id": c, "faction_id": fids[2]},
],
)
assert started.status_code == 200, started.text
return me, b, c, gid, started.json()["id"]
def test_eliminated_gets_last_place(client: TestClient, engine):
me, b, c, _gid, mid = _start3(client, engine)
r = finish_match(
client,
mid,
[
{"user_id": me["id"], "place": 1},
{"user_id": b, "place": 2},
{"user_id": c, "eliminated": True}, # выбыл
],
)
assert r.status_code == 200, r.text
parts = {p["user_id"]: p for p in r.json()["participants"]}
assert parts[c]["eliminated"] is True
assert parts[c]["place"] == 3 # survivors=2 → последнее место 3
assert parts[me["id"]]["eliminated"] is False
# Выбывший — в конце списка результатов.
assert r.json()["participants"][-1]["user_id"] == c
# Партия засчитана выбывшему как сыгранная (последнее место, 0 очков).
login(client, "Третий")
stats = client.get("/api/users/me/stats").json()
assert stats["overall"]["games"] == 1
assert stats["overall"]["wins"] == 0
def test_all_eliminated_rejected(client: TestClient, engine):
me, b, c, _gid, mid = _start3(client, engine)
r = finish_match(
client,
mid,
[
{"user_id": me["id"], "eliminated": True},
{"user_id": b, "eliminated": True},
{"user_id": c, "eliminated": True},
],
)
assert r.status_code == 422, r.text # должен остаться хотя бы один с местом
-43
View File
@@ -1,43 +0,0 @@
"""SSE-шина: доставка событий подписчику; эндпойнт /api/events требует вход."""
from __future__ import annotations
import asyncio
from fastapi.testclient import TestClient
def test_hub_delivers_to_subscriber():
from app.core.events import EventHub
async def run():
hub = EventHub()
hub.bind_loop(asyncio.get_running_loop())
q = hub.subscribe(1)
hub.publish([1, 2], {"type": "match", "match_id": 5})
event = await asyncio.wait_for(q.get(), timeout=1)
assert event == {"type": "match", "match_id": 5}
hub.unsubscribe(1, q)
asyncio.run(run())
def test_hub_isolates_users():
from app.core.events import EventHub
async def run():
hub = EventHub()
hub.bind_loop(asyncio.get_running_loop())
q1 = hub.subscribe(1)
q2 = hub.subscribe(2)
hub.publish([2], {"type": "invitations"}) # только пользователю 2
got2 = await asyncio.wait_for(q2.get(), timeout=1)
assert got2 == {"type": "invitations"}
assert q1.empty() # пользователю 1 ничего не пришло
asyncio.run(run())
def test_events_requires_auth(client: TestClient):
# Без сессии SSE-эндпойнт не отдаёт поток (401 на зависимости get_current_user).
r = client.get("/api/events")
assert r.status_code == 401
-83
View File
@@ -1,83 +0,0 @@
"""Адресаты событий партии. Рейтинг общий (#80): завершённая партия двигает витрины
всех игроков, поэтому игроки вне группы получают событие ratings (#88)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]:
from app.core import events
published: list[tuple[list[int], dict]] = []
monkeypatch.setattr(
events.hub, "publish", lambda ids, event: published.append((list(ids), event))
)
return published
def _recipients(published: list[tuple[list[int], dict]], kind: str) -> set[int]:
return {uid for ids, e in published if e.get("type") == kind for uid in ids}
def _two_groups(client: TestClient, engine) -> tuple[dict, int, int, int, list[int]]:
"""Хост и Игрок2 в группе партии, Чужой — только в другой группе хоста."""
me = login(client, "Хост")
exps = [e["id"] for e in client.get("/api/expansions").json()]
def group(name: str) -> int:
return client.post(
"/api/groups", json={"name": name, "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
gid, other_gid = group("Группа"), group("Другая")
p2 = add_group_member(engine, gid, "Игрок2")
outsider = add_group_member(engine, other_gid, "Чужой")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return me, gid, p2, outsider, fids
def test_finished_match_reaches_players_outside_group(client: TestClient, engine, monkeypatch):
me, gid, p2, outsider, fids = _two_groups(client, engine)
published = _capture_events(monkeypatch)
started = start_match(
client, gid,
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
)
assert started.status_code == 200, started.text
# Незавершённая партия рейтинг не двигает — знать о ней нужно только группе.
assert _recipients(published, "match") == {me["id"], p2}
assert _recipients(published, "ratings") == set()
published.clear()
fin = finish_match(
client, started.json()["id"], [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
)
assert fin.status_code == 200, fin.text
assert _recipients(published, "match") == {me["id"], p2}
ratings = _recipients(published, "ratings")
assert outsider in ratings
assert not ratings & {me["id"], p2} # группа уже получила подробное событие
def test_deleting_finished_match_reaches_players_outside_group(
client: TestClient, engine, monkeypatch
):
"""Удаление завершённой партии пересчитывает рейтинг всех, кто играл после неё."""
me, gid, p2, outsider, fids = _two_groups(client, engine)
mid = start_match(
client, gid,
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
).json()["id"]
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
published = _capture_events(monkeypatch)
version = client.get(f"/api/matches/{mid}").json()["version"]
r = client.delete(
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
assert _recipients(published, "match") == {me["id"], p2}
assert outsider in _recipients(published, "ratings")
-129
View File
@@ -1,129 +0,0 @@
"""Черновик формы завершения: совместное заполнение результатов партии.
Плюс запрет правки незавершённой партии (места без завершения — «результат есть,
а игры как бы не было»)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
def _start(client: TestClient, engine) -> tuple[dict, int, int, int]:
me = login(client, "Хост")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
p2 = add_group_member(engine, gid, "Игрок2")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
started = start_match(
client, gid,
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
)
assert started.status_code == 200, started.text
return me, gid, p2, started.json()["id"]
def _draft(client: TestClient, mid: int, body: dict):
return client.put(f"/api/matches/{mid}/finish-draft", json=body, headers=csrf_headers(client))
def test_draft_is_shared_between_participants(client: TestClient, engine):
"""Второй участник видит раскладку первого, не перезагружая страницу."""
me, gid, p2, mid = _start(client, engine)
r = _draft(client, mid, {
"blocks": [[me["id"]], [p2]],
"eliminated": [],
"comments": {str(p2): "почти успел"},
"win_reason": "worlds",
})
assert r.status_code == 200, r.text
assert r.json()["finish_draft"]["data"]["blocks"] == [[me["id"]], [p2]]
login(client, "Игрок2")
seen = client.get(f"/api/matches/{mid}").json()["finish_draft"]
assert seen["data"]["blocks"] == [[me["id"]], [p2]]
assert seen["data"]["comments"][str(p2)] == "почти успел"
assert seen["data"]["win_reason"] == "worlds"
assert seen["updated_by_nickname"] == "Хост"
def test_draft_does_not_move_match_version(client: TestClient, engine):
"""Черновик не трогает версию партии.
Иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую
правку — ровно то, ради чего черновик и делался."""
me, gid, p2, mid = _start(client, engine)
v1 = client.get(f"/api/matches/{mid}").json()["version"]
assert _draft(client, mid, {"blocks": [[p2], [me["id"]]]}).status_code == 200
assert client.get(f"/api/matches/{mid}").json()["version"] == v1
# И завершение со «старой» (на деле актуальной) версией проходит.
fin = finish_match(
client, mid, [{"user_id": p2, "place": 1}, {"user_id": me["id"], "place": 2}],
)
assert fin.status_code == 200, fin.text
def test_draft_cleared_after_finish(client: TestClient, engine):
me, gid, p2, mid = _start(client, engine)
assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 200
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
assert client.get(f"/api/matches/{mid}").json()["finish_draft"] is None
# В завершённую партию черновик не пишется.
assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 409
def test_draft_rejects_outsider_and_foreign_players(client: TestClient, engine):
me, gid, p2, mid = _start(client, engine)
stranger = login(client, "Чужак") # в группе не состоит
assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 403
login(client, "Хост")
bad = _draft(client, mid, {"blocks": [[me["id"]], [stranger["id"]]]})
assert bad.status_code == 422, bad.text
def test_in_progress_match_cannot_be_patched(client: TestClient, engine):
"""Места и причина победы у идущей партии — только через завершение.
Иначе партия остаётся in_progress с проставленными местами: висит в
«Незавершённых», но в статистику не попадает и очков не приносит."""
me, gid, p2, mid = _start(client, engine)
parts = client.get(f"/api/matches/{mid}").json()["participants"]
body = {
"participants": [
{"user_id": p["user_id"], "faction_id": p["faction_id"], "place": i + 1}
for i, p in enumerate(parts)
],
"win_reason": "objectives",
}
r = client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client))
assert r.status_code == 409, r.text
assert client.get(f"/api/matches/{mid}").json()["status"] == "in_progress"
def test_admin_cannot_patch_in_progress_match(client: TestClient, engine, make_admin):
me, gid, p2, mid = _start(client, engine)
parts = client.get(f"/api/matches/{mid}").json()["participants"]
make_admin("admin", "secret123")
assert client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
).status_code == 200
body = {
"participants": [
{"user_id": p["user_id"], "faction_id": p["faction_id"], "place": i + 1}
for i, p in enumerate(parts)
],
"win_reason": "objectives",
}
r = client.patch(f"/api/admin/matches/{mid}", json=body, headers=csrf_headers(client))
assert r.status_code == 409, r.text
@@ -1,85 +0,0 @@
"""Список игроков группы — только текущий состав (#76).
Удалённый из группы игрок пропадает из рейтинга группы, но его партии остаются в
истории: они уже повлияли на (общий) рейтинг оставшихся."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
def _ids(stats: dict) -> dict[str, set[int]]:
return {
block: {e["user_id"] for e in stats[block]}
for block in ("leaderboard", "provisional", "inactive")
}
def _group(client: TestClient) -> tuple[int, list[int]]:
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, fids
def _remove(client: TestClient, gid: int, uid: int) -> None:
r = client.delete(f"/api/groups/{gid}/members/{uid}", headers=csrf_headers(client))
assert r.status_code == 200, r.text
def test_removed_member_leaves_group_rating_but_not_overall(client: TestClient, engine):
me = login(client, "Хозяин")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Ушедший")
create_finished_match(
client, gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": b, "faction_id": fids[1], "place": 2},
],
)
before = client.get(f"/api/groups/{gid}/stats").json()
my_score = next(e["score"] for e in before["provisional"] if e["user_id"] == me["id"])
_remove(client, gid, b)
stats = client.get(f"/api/groups/{gid}/stats").json()
ids = _ids(stats)
assert all(b not in block for block in ids.values())
assert me["id"] in ids["provisional"]
# Партия с ушедшим учтена: рейтинг оставшегося не изменился, счётчик партий тоже.
assert next(e["score"] for e in stats["provisional"] if e["user_id"] == me["id"]) == my_score
assert stats["total_matches"] == 1
board = client.get("/api/stats/leaderboard").json()
assert b in {e["user_id"] for e in board["entries"] + board["provisional"]}
# Вернули в группу — снова в списке со своей историей.
add_group_member(engine, gid, "Ушедший")
back = client.get(f"/api/groups/{gid}/stats").json()
entry = next(e for e in back["provisional"] if e["user_id"] == b)
assert (entry["games"], entry["score"]) == (1, 1468)
def test_group_ranks_renumbered_after_removal(client: TestClient, engine):
me = login(client, "Первый")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Второй")
c = add_group_member(engine, gid, "Третий")
for _ in range(10):
create_finished_match(
client, gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": b, "faction_id": fids[1], "place": 2},
{"user_id": c, "faction_id": fids[2], "place": 3},
],
)
assert [e["rank"] for e in client.get(f"/api/groups/{gid}/stats").json()["leaderboard"]] == [1, 2, 3]
_remove(client, gid, b)
board = client.get(f"/api/groups/{gid}/stats").json()["leaderboard"]
assert [(e["user_id"], e["rank"]) for e in board] == [(me["id"], 1), (c, 2)]
-97
View File
@@ -1,97 +0,0 @@
"""Приглашения в группу: создание, просмотр, принятие/отклонение, права участника."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, login
def _group(client: TestClient, name: str = "Группа") -> int:
return client.post(
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
def _dev_user(client: TestClient, nick: str) -> None:
client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client))
def test_invite_accept_flow(client: TestClient):
login(client, "Овнер")
gid = _group(client)
_dev_user(client, "Гость")
r = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
assert r.json()["invited_by_nickname"] == "Овнер"
# Повторное приглашение → 409.
dup = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
)
assert dup.status_code == 409, dup.text
# Приглашённый видит приглашение и принимает.
login(client, "Гость")
invs = client.get("/api/invitations").json()
assert len(invs) == 1
assert invs[0]["group_id"] == gid and invs[0]["group_name"] == "Группа"
inv_id = invs[0]["id"]
acc = client.post(f"/api/invitations/{inv_id}/accept", headers=csrf_headers(client))
assert acc.status_code == 200, acc.text
# Стал участником; первая группа стала активной; приглашение исчезло.
me = client.get("/api/users/me").json()
assert me["active_group_id"] == gid
assert client.get("/api/invitations").json() == []
# Теперь он участник → приглашать его повторно нельзя.
login(client, "Овнер")
again = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
)
assert again.status_code == 409, again.text
members = client.get(f"/api/groups/{gid}/members").json()
assert any(m["nickname"] == "Гость" for m in members)
def test_invite_decline(client: TestClient):
login(client, "Овнер2")
gid = _group(client)
_dev_user(client, "Гость2")
client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Гость2"}, headers=csrf_headers(client)
)
login(client, "Гость2")
inv_id = client.get("/api/invitations").json()[0]["id"]
dec = client.post(f"/api/invitations/{inv_id}/decline", headers=csrf_headers(client))
assert dec.status_code == 200, dec.text
assert client.get("/api/invitations").json() == []
login(client, "Овнер2")
members = client.get(f"/api/groups/{gid}/members").json()
assert all(m["nickname"] != "Гость2" for m in members)
def test_any_member_can_invite(client: TestClient, engine):
login(client, "Овнер3")
gid = _group(client)
add_group_member(engine, gid, "Участник") # обычный member (не владелец)
_dev_user(client, "Новичок")
# Обычный участник входит и приглашает — должно быть можно (assert_member, не owner).
login(client, "Участник")
r = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Новичок"}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
def test_invite_unknown_nick_404(client: TestClient):
login(client, "Овнер4")
gid = _group(client)
r = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "НетТакого"}, headers=csrf_headers(client)
)
assert r.status_code == 404, r.text
-142
View File
@@ -1,142 +0,0 @@
"""Правка завершённой партии игроком: история чинится после изменений в группе."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
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 _played(client: TestClient, engine) -> tuple[dict, int, int, int, dict]:
"""Партия «Аня против Бори» в группе с обоими дополнениями."""
me = login(client, "Аня")
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"]
b = add_group_member(engine, gid, "Боря")
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["tau"], "place": 2},
],
)["id"]
return me, gid, b, mid, factions
def _swap_places(client: TestClient, mid: int, me_id: int, b: int, factions: dict) -> dict:
detail = client.get(f"/api/matches/{mid}").json()
fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]}
return {
"participants": [
{"user_id": me_id, "faction_id": fid[me_id], "place": 2},
{"user_id": b, "faction_id": fid[b], "place": 1},
],
"expected_version": detail["version"],
}
def test_edit_after_expansion_disabled(client: TestClient, engine):
"""Дополнение выключили — партия на Тау всё равно правится.
Регрессия: правка проверяла фракции по ТЕКУЩЕМУ набору группы, и партия,
сыгранная на фракции из отключённого дополнения, становилась неисправимой."""
me, gid, b, mid, factions = _played(client, engine)
off = client.put(
f"/api/groups/{gid}/expansions",
json={"expansion_ids": [_exp_id(client, "forsaken_voids")]},
headers=csrf_headers(client),
)
assert off.status_code == 200, off.text
r = client.patch(
f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions),
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
places = {p["user_id"]: p["place"] for p in client.get(f"/api/matches/{mid}").json()["participants"]}
assert places[b] == 1 and places[me["id"]] == 2
def test_edit_after_player_left_group(client: TestClient, engine):
"""Игрока убрали из группы — партия с ним всё равно правится."""
me, gid, b, mid, factions = _played(client, engine)
out = client.delete(f"/api/groups/{gid}/members/{b}", headers=csrf_headers(client))
assert out.status_code == 200, out.text
r = client.patch(
f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions),
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
def test_edit_rejects_new_outsider_and_unavailable_faction(client: TestClient, engine):
"""Послабление — только для того, что уже в партии.
Вписать нового игрока не из группы или фракцию, которой в партии не было и у
группы нет, по-прежнему нельзя: иначе в историю можно занести что угодно."""
me, gid, b, mid, factions = _played(client, engine)
stranger = client.post(
"/api/auth/dev/users", json={"nickname": "Чужак"}, headers=csrf_headers(client)
).json()
detail = client.get(f"/api/matches/{mid}").json()
fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]}
bad_user = client.patch(
f"/api/matches/{mid}",
json={
"participants": [
{"user_id": me["id"], "faction_id": fid[me["id"]], "place": 1},
{"user_id": stranger["id"], "faction_id": fid[b], "place": 2},
]
},
headers=csrf_headers(client),
)
assert bad_user.status_code == 422, bad_user.text
# Выключаем дополнение и пробуем поставить ЕГО фракцию, которой в партии не было.
assert client.put(
f"/api/groups/{gid}/expansions",
json={"expansion_ids": [_exp_id(client, "forsaken_voids")]},
headers=csrf_headers(client),
).status_code == 200
bad_faction = client.patch(
f"/api/matches/{mid}",
json={
"participants": [
{"user_id": me["id"], "faction_id": factions["necrons"], "place": 1},
{"user_id": b, "faction_id": fid[b], "place": 2},
]
},
headers=csrf_headers(client),
)
assert bad_faction.status_code == 422, bad_faction.text
def test_create_match_still_validated(client: TestClient, engine):
"""Создание партии не ослабло: посторонний игрок по-прежнему отклоняется."""
me, gid, b, mid, factions = _played(client, engine)
stranger = client.post(
"/api/auth/dev/users", json={"nickname": "Чужак2"}, headers=csrf_headers(client)
).json()
r = client.post(
"/api/matches",
json={
"group_id": gid,
"participants": [
{"user_id": me["id"], "faction_id": factions["orks"]},
{"user_id": stranger["id"], "faction_id": factions["eldar"]},
],
},
headers=csrf_headers(client),
)
assert r.status_code == 422, r.text
-184
View File
@@ -1,184 +0,0 @@
"""Уведомления: сервис (create/list/mark_read/purge) и триггеры (инвайт, старт/финиш партии)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from sqlmodel import Session
from app.models import Notification, User
from app.services import notification_service
from tests.conftest import (
add_group_member,
csrf_headers,
finish_match,
login,
start_match,
)
def _group(client: TestClient, name: str = "Группа") -> int:
return client.post(
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
def _dev_user(client: TestClient, nick: str) -> None:
client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client))
def _make_user(engine, nick: str) -> int:
with Session(engine) as s:
u = User(nickname=nick, role="player", auth_provider="stub")
s.add(u)
s.commit()
s.refresh(u)
return u.id # type: ignore[return-value]
# ─── Сервис ────────────────────────────────────────────────────────────────────
def test_create_list_mark_read(engine):
uid = _make_user(engine, "Алиса")
with Session(engine) as s:
notification_service.create(s, uid, type="test", title="Привет", link="/account")
data = notification_service.list_for_user(s, uid)
assert data["unread_count"] == 1
assert len(data["items"]) == 1
assert data["items"][0]["title"] == "Привет"
assert data["items"][0]["link"] == "/account"
assert data["items"][0]["read_at"] is None
marked = notification_service.mark_read(s, uid)
assert marked == 1
after = notification_service.list_for_user(s, uid)
assert after["unread_count"] == 0
assert after["items"][0]["read_at"] is not None
def test_purge_removes_only_long_read(engine):
"""Протухают лишь ПРОЧИТАННЫЕ (спустя 72ч после прочтения): непрочитанное должно
дождаться игрока, сколько бы тот ни отсутствовал."""
uid = _make_user(engine, "Боб")
old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(
hours=notification_service.RETENTION_HOURS + 1
)
with Session(engine) as s:
# Старое непрочитанное, старое давно прочитанное и свежепрочитанное.
s.add(Notification(user_id=uid, type="test", title="Непрочитанное", created_at=old))
s.add(
Notification(
user_id=uid, type="test", title="Давно прочитанное", created_at=old, read_at=old
)
)
s.add(
Notification(
user_id=uid,
type="test",
title="Свежепрочитанное",
created_at=old,
read_at=datetime.now(timezone.utc).replace(tzinfo=None),
)
)
s.commit()
removed = notification_service.purge_expired(s)
assert removed == 1
titles = {n["title"] for n in notification_service.list_for_user(s, uid)["items"]}
assert titles == {"Непрочитанное", "Свежепрочитанное"}
def test_cap_per_user(engine):
"""Кап MAX_PER_USER: при создании сверх лимита удаляются старейшие."""
uid = _make_user(engine, "Копитель")
with Session(engine) as s:
for i in range(notification_service.MAX_PER_USER + 5):
notification_service.create(s, uid, type="test", title=f"n{i}")
data = notification_service.list_for_user(s, uid)
assert len(data["items"]) == notification_service.MAX_PER_USER
titles = {n["title"] for n in data["items"]}
assert "n0" not in titles # старейшие срезаны
assert f"n{notification_service.MAX_PER_USER + 4}" in titles # новейшее на месте
def test_notifications_require_auth(client: TestClient):
assert client.get("/api/notifications").status_code == 401
# ─── Триггеры через HTTP ─────────────────────────────────────────────────────────
def test_invitation_creates_notification(client: TestClient):
login(client, "Овнер")
gid = _group(client)
_dev_user(client, "Гость")
r = client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
login(client, "Гость")
data = client.get("/api/notifications").json()
invites = [n for n in data["items"] if n["type"] == "invite"]
assert len(invites) == 1
assert invites[0]["link"] == "/group"
assert "Группа" in invites[0]["title"]
assert data["unread_count"] >= 1
def test_match_notifications_exclude_actor(client: TestClient, engine):
me = login(client, "Капитан")
gid = _group(client)
member_id = add_group_member(engine, gid, "Боец")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
started = start_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0]},
{"user_id": member_id, "faction_id": fids[1]},
],
)
assert started.status_code == 200, started.text
mid = started.json()["id"]
# Инициатор (Капитан) себе уведомление не шлёт.
mine = client.get("/api/notifications").json()
assert all(n["type"] != "match_started" for n in mine["items"])
# Участник получает уведомление о старте со ссылкой на партию.
login(client, "Боец")
his = client.get("/api/notifications").json()
started_notifs = [n for n in his["items"] if n["type"] == "match_started"]
assert len(started_notifs) == 1
assert started_notifs[0]["link"] == f"/match/{mid}"
# Финиш (инициатор снова Капитан) → участник получает уведомление о завершении.
login(client, "Капитан")
fin = finish_match(
client,
mid,
[{"user_id": me["id"], "place": 1}, {"user_id": member_id, "place": 2}],
)
assert fin.status_code == 200, fin.text
login(client, "Боец")
his2 = client.get("/api/notifications").json()
assert any(
n["type"] == "match_finished" and n["link"] == f"/match/{mid}" for n in his2["items"]
)
def test_mark_read_endpoint(client: TestClient):
login(client, "Овнер2")
gid = _group(client)
_dev_user(client, "Гость2")
client.post(
f"/api/groups/{gid}/invitations", json={"nickname": "Гость2"}, headers=csrf_headers(client)
)
login(client, "Гость2")
assert client.get("/api/notifications").json()["unread_count"] >= 1
r = client.post("/api/notifications/read", json={}, headers=csrf_headers(client))
assert r.status_code == 200, r.text
assert client.get("/api/notifications").json()["unread_count"] == 0
-499
View File
@@ -1,499 +0,0 @@
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from app.main import app
from app.models import User
from tests.conftest import csrf_headers
from tests.test_auth import _telegram_payload
PASSWORD = "correct-horse"
def _register(client: TestClient, nickname: str = "Игрок", password: str = PASSWORD):
return client.post(
"/api/auth/register",
json={"nickname": nickname, "password": password},
headers=csrf_headers(client),
)
def _login(client: TestClient, nickname: str = "Игрок", password: str = PASSWORD):
return client.post(
"/api/auth/login",
json={"nickname": nickname, "password": password},
headers=csrf_headers(client),
)
# ─── Регистрация ─────────────────────────────────────────────────────────────
def test_register_opens_session(client: TestClient, engine):
r = _register(client)
assert r.status_code == 200, r.text
me = r.json()
assert me["nickname"] == "Игрок"
assert me["auth_provider"] == "local"
assert me["has_password"] is True
assert client.cookies.get("fs_session")
assert client.get("/api/users/me").json()["id"] == me["id"]
with Session(engine) as s:
user = s.get(User, me["id"])
assert user.password_hash and PASSWORD not in user.password_hash
def test_register_taken_nickname(client: TestClient):
assert _register(client).status_code == 200
client.cookies.clear()
r = _register(client, password="another-pass")
assert r.status_code == 409
assert r.json()["error"]["code"] == "NICKNAME_TAKEN"
def test_register_rejects_bad_passwords(client: TestClient):
for bad in ["short", " ", "я" * 37]: # короткий, пробелы, 74 байта UTF-8
r = _register(client, password=bad)
assert r.status_code == 422, (bad, r.text)
assert _register(client, password="x" * 129).status_code == 422 # предел схемы
def test_register_rejects_bad_nickname(client: TestClient):
assert _register(client, nickname="x").status_code == 422
def test_register_is_throttled_per_ip(client: TestClient, monkeypatch):
"""Спам регистраций с одного IP упирается в лимит (#62)."""
import app.core.ratelimit as ratelimit
from app.auth.password import _REGISTER_IP_LIMIT
now = [4000.0]
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
for i in range(_REGISTER_IP_LIMIT):
client.cookies.clear()
assert _register(client, nickname=f"Ник{i}").status_code == 200
client.cookies.clear()
blocked = _register(client, nickname="Лишний")
assert blocked.status_code == 429
assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS"
now[0] += 15 * 60 # окно истекло
client.cookies.clear()
assert _register(client, nickname="ПослеОкна").status_code == 200
# ─── Вход ────────────────────────────────────────────────────────────────────
def test_login_after_logout(client: TestClient):
uid = _register(client).json()["id"]
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
client.cookies.clear()
r = _login(client)
assert r.status_code == 200, r.text
assert r.json()["id"] == uid
def test_wrong_password_and_unknown_login_look_the_same(client: TestClient):
_register(client)
client.cookies.clear()
wrong = _login(client, password="wrong-password")
unknown = _login(client, nickname="Никто")
assert wrong.status_code == unknown.status_code == 401
assert wrong.json() == unknown.json()
assert wrong.json()["error"]["code"] == "INVALID_CREDENTIALS"
assert "fs_session" not in client.cookies
def test_admin_credentials_do_not_open_player_session(client: TestClient, make_admin):
make_admin("boss", "secret123")
r = _login(client, nickname="boss", password="secret123")
assert r.status_code == 401
assert "fs_session" not in client.cookies
def test_player_password_does_not_open_admin_session(client: TestClient):
_register(client)
client.cookies.clear()
r = client.post("/api/admin/auth/login", json={"username": "Игрок", "password": PASSWORD})
assert r.status_code == 401
def test_account_without_password_cannot_log_in(client: TestClient, engine):
with Session(engine) as s:
s.add(User(nickname="Телеграмщик", role="player", auth_provider="telegram"))
s.commit()
r = _login(client, nickname="Телеграмщик", password="anything-at-all")
assert r.status_code == 401
def test_disabled_account_cannot_log_in(client: TestClient, engine):
uid = _register(client).json()["id"]
client.cookies.clear()
with Session(engine) as s:
user = s.get(User, uid)
user.is_active = False
s.add(user)
s.commit()
r = _login(client)
assert r.status_code == 403
assert r.json()["error"]["code"] == "ACCOUNT_DISABLED"
def test_login_is_audited_without_secrets(client: TestClient, engine):
from app.models import AuditLog
uid = _register(client).json()["id"]
with Session(engine) as s:
logs = s.exec(select(AuditLog).where(AuditLog.entity_id == uid)).all()
assert {(log.action, (log.payload or {}).get("provider")) for log in logs} >= {
("create", "local"),
("login", "local"),
}
assert all(PASSWORD not in str(log.payload) for log in logs)
# ─── Защита от перебора ──────────────────────────────────────────────────────
def test_throttle_blocks_after_five_failures(client: TestClient, monkeypatch):
import app.core.ratelimit as ratelimit
now = [1000.0]
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
_register(client)
client.cookies.clear()
for _ in range(5):
assert _login(client, password="wrong-password").status_code == 401
blocked = _login(client) # даже верный пароль не проверяется
assert blocked.status_code == 429
err = blocked.json()["error"]
assert err["code"] == "TOO_MANY_ATTEMPTS"
assert 0 < err["details"]["retry_after"] <= 15 * 60 + 1
assert "fs_session" not in client.cookies
now[0] += 15 * 60 # окно истекло
assert _login(client).status_code == 200
def test_success_resets_pair_counter(client: TestClient):
_register(client)
client.cookies.clear()
for _ in range(4):
assert _login(client, password="wrong-password").status_code == 401
assert _login(client).status_code == 200
client.cookies.clear()
for _ in range(4):
assert _login(client, password="wrong-password").status_code == 401
assert _login(client).status_code == 200
def test_throttle_per_ip_across_logins(client: TestClient):
"""С одного адреса нельзя перебирать пароли по многим логинам: 20 неудач — блок."""
for i in range(20):
assert _login(client, nickname=f"Логин{i}", password="wrong-password").status_code == 401
assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429
def test_account_scoped_throttle_survives_ip_rotation(client: TestClient, engine, monkeypatch):
"""Перебор одного логина с РАЗНЫХ адресов (ротация X-Forwarded-For, #58) упирается в
IP-независимый лимит на аккаунт (#60): пара IP+логин и лимит по IP так не копятся."""
import app.core.ratelimit as ratelimit
from app.auth.password import _ACCOUNT_LIMIT, login_player
from app.core.errors import InvalidCredentialsError, TooManyAttemptsError
now = [3000.0]
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
_register(client, nickname="Жертва", password=PASSWORD)
with Session(engine) as s:
for i in range(_ACCOUNT_LIMIT): # каждый раз новый адрес
with pytest.raises(InvalidCredentialsError):
login_player(s, "Жертва", "wrong-password", ip=f"10.0.{i // 256}.{i % 256}")
# ещё одна попытка с совершенно нового адреса — уже блок по лимиту на аккаунт
with pytest.raises(TooManyAttemptsError):
login_player(s, "Жертва", "wrong-password", ip="203.0.113.7")
# ─── Установка и смена пароля ────────────────────────────────────────────────
def _set_password(client: TestClient, new: str, current: str | None = None):
body = {"new_password": new}
if current is not None:
body["current_password"] = current
return client.put("/api/users/me/password", json=body, headers=csrf_headers(client))
def _telegram_login(client: TestClient, monkeypatch, **fields):
from app.core.config import settings
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
return client.post(
"/api/auth/telegram",
json=_telegram_payload("TEST_BOT_TOKEN", **fields),
headers=csrf_headers(client),
)
def test_telegram_user_sets_password_then_logs_in(client: TestClient, monkeypatch):
"""Сценарий 1 и существующие аккаунты: без пароля → задаёт без текущего → входит по нику."""
r = _telegram_login(client, monkeypatch)
assert r.status_code == 200, r.text
me = r.json()
assert me["has_password"] is False
r2 = _set_password(client, PASSWORD)
assert r2.status_code == 200, r2.text
assert r2.json()["has_password"] is True
client.cookies.clear()
r3 = _login(client, nickname=me["nickname"])
assert r3.status_code == 200, r3.text
assert r3.json()["id"] == me["id"]
def test_change_password_requires_current(client: TestClient):
_register(client)
missing = _set_password(client, "new-password-1")
assert missing.status_code == 403
assert missing.json()["error"]["code"] == "WRONG_CURRENT_PASSWORD"
assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403
assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200
client.cookies.clear()
assert _login(client).status_code == 401
assert _login(client, password="new-password-1").status_code == 200
def test_change_password_validates_new(client: TestClient):
_register(client)
r = _set_password(client, "short", current=PASSWORD)
assert r.status_code == 422
client.cookies.clear()
assert _login(client).status_code == 200 # старый пароль не тронут
def test_current_password_guessing_is_throttled(client: TestClient):
_register(client)
for _ in range(5):
assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403
blocked = _set_password(client, "new-password-1", current=PASSWORD)
assert blocked.status_code == 429
def test_set_password_requires_session(client: TestClient):
assert _set_password(client, PASSWORD).status_code == 401
# ─── Отзыв токена: logout и смена пароля (#57, F2) ────────────────────────────
def _me_with_token(cookie_name: str, token: str):
"""Предъявить конкретный токен вручную (эмуляция «другого устройства»/украденной cookie)."""
return TestClient(app).get("/api/users/me", headers={"Cookie": f"{cookie_name}={token}"})
def test_logout_revokes_presented_token(client: TestClient):
_register(client)
tok = client.cookies.get("fs_session")
assert _me_with_token("fs_session", tok).status_code == 200 # пока жив
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
# тот же токен, предъявленный после выхода, больше не принимается
assert _me_with_token("fs_session", tok).status_code == 401
def test_logout_does_not_revoke_other_devices(client: TestClient):
_register(client) # устройство A
tok_a = client.cookies.get("fs_session")
# устройство B: независимый вход тем же аккаунтом (свой jti)
b = TestClient(app)
assert b.post("/api/auth/login", json={"nickname": "Игрок", "password": PASSWORD}).status_code == 200
tok_b = b.cookies.get("fs_session")
assert tok_a and tok_b and tok_a != tok_b
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
assert _me_with_token("fs_session", tok_a).status_code == 401 # A вышел
assert _me_with_token("fs_session", tok_b).status_code == 200 # B не тронут
def test_password_change_revokes_old_sessions_keeps_current(client: TestClient):
_register(client)
old = client.cookies.get("fs_session")
assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200
# это устройство осталось в сессии (cookie перевыдан со свежим ver)
assert client.get("/api/users/me").status_code == 200
# старый токен (другое устройство/утёкший) отозван инкрементом token_version
assert _me_with_token("fs_session", old).status_code == 401
def test_admin_password_reset_revokes_player_sessions(client: TestClient, monkeypatch, make_admin):
player = _telegram_login(client, monkeypatch).json()
stolen = client.cookies.get("fs_session") # действующая сессия игрока
assert _me_with_token("fs_session", stolen).status_code == 200
client.cookies.clear()
_admin_login(client, make_admin)
r = client.put(
f"/api/admin/users/{player['id']}/password",
json={"new_password": "from-admin-1"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
# сброс пароля админом обрывает прежние сессии игрока (в т.ч. злоумышленника)
assert _me_with_token("fs_session", stolen).status_code == 401
# ─── Привязка Telegram ───────────────────────────────────────────────────────
def _link_telegram(client: TestClient, monkeypatch, **fields):
from app.core.config import settings
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
return client.post(
"/api/users/me/telegram",
json=_telegram_payload("TEST_BOT_TOKEN", **fields),
headers=csrf_headers(client),
)
def test_link_telegram_then_login_via_telegram(client: TestClient, monkeypatch):
"""Сценарий 2: аккаунт по паролю → привязал Telegram → вход через него в тот же аккаунт."""
uid = _register(client).json()["id"]
r = _link_telegram(client, monkeypatch) # id=777, тег ivan_tg
assert r.status_code == 200, r.text
assert r.json()["telegram_id"] == 777
assert r.json()["nickname"] == "Игрок" # ник не меняется на тег
client.cookies.clear()
r2 = _telegram_login(client, monkeypatch)
assert r2.status_code == 200, r2.text
assert r2.json()["id"] == uid
assert r2.json()["nickname"] == "Игрок"
def test_link_telegram_taken_by_other_account(client: TestClient, monkeypatch):
assert _telegram_login(client, monkeypatch).status_code == 200 # 777 уже чей-то
client.cookies.clear()
_register(client)
r = _link_telegram(client, monkeypatch)
assert r.status_code == 409
assert r.json()["error"]["code"] == "TELEGRAM_TAKEN"
def test_link_telegram_twice(client: TestClient, monkeypatch):
_register(client)
assert _link_telegram(client, monkeypatch).status_code == 200
r = _link_telegram(client, monkeypatch, id=778)
assert r.status_code == 409
assert r.json()["error"]["code"] == "TELEGRAM_ALREADY_LINKED"
def test_link_telegram_bad_signature(client: TestClient, monkeypatch):
from app.core.config import settings
_register(client)
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
payload = _telegram_payload("TEST_BOT_TOKEN")
payload["hash"] = "deadbeef"
r = client.post("/api/users/me/telegram", json=payload, headers=csrf_headers(client))
assert r.status_code == 401
assert client.get("/api/users/me").json()["telegram_id"] is None
# ─── Пароль игроку из админки ────────────────────────────────────────────────
def _admin_login(client: TestClient, make_admin):
make_admin("boss", "secret123")
r = client.post(
"/api/admin/auth/login",
json={"username": "boss", "password": "secret123"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
return r.json()["id"]
def test_admin_sets_player_password(client: TestClient, monkeypatch, make_admin):
player = _telegram_login(client, monkeypatch).json()
client.cookies.clear()
_admin_login(client, make_admin)
r = client.put(
f"/api/admin/users/{player['id']}/password",
json={"new_password": "from-admin-1"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
assert r.json()["has_password"] is True
client.cookies.clear()
assert _login(client, nickname=player["nickname"], password="from-admin-1").status_code == 200
def test_admin_cannot_set_admin_password(client: TestClient, make_admin):
admin_id = _admin_login(client, make_admin)
r = client.put(
f"/api/admin/users/{admin_id}/password",
json={"new_password": "from-admin-1"},
headers=csrf_headers(client),
)
assert r.status_code == 422
def test_player_cannot_set_passwords_via_admin(client: TestClient):
uid = _register(client).json()["id"]
r = client.put(
f"/api/admin/users/{uid}/password",
json={"new_password": "from-admin-1"},
headers=csrf_headers(client),
)
assert r.status_code == 401
# ─── Защита от перебора пароля администратора (#56, F1) ───────────────────────
def test_admin_login_throttled_after_failures(client: TestClient, make_admin, monkeypatch):
import app.core.ratelimit as ratelimit
now = [2000.0]
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
make_admin("boss", "secret123")
for _ in range(5):
r = client.post("/api/admin/auth/login", json={"username": "boss", "password": "nope"})
assert r.status_code == 401
blocked = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
assert blocked.status_code == 429 # даже верный пароль не проверяется
assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS"
now[0] += 15 * 60 # окно истекло
ok = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
assert ok.status_code == 200
def test_failed_admin_login_is_audited_without_password(client: TestClient, make_admin, engine):
from app.models import AuditLog
make_admin("boss", "secret123")
assert client.post(
"/api/admin/auth/login", json={"username": "boss", "password": "nope-secret-guess"}
).status_code == 401
with Session(engine) as s:
logs = s.exec(select(AuditLog).where(AuditLog.action == "login_failed")).all()
assert any(
log.entity_type == "admin" and (log.payload or {}).get("username") == "boss" for log in logs
)
assert all("nope-secret-guess" not in str(log.payload) for log in logs)
+9 -330
View File
@@ -1,18 +1,9 @@
"""Профиль: «о себе» (bio), любимая фракция, история партий, аватар """Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль."""
(загрузка/отдача/удаление), публичный профиль."""
from __future__ import annotations from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlmodel import Session
from app.models import User from tests.conftest import create_finished_match, csrf_headers, login
from tests.conftest import (
add_group_member,
create_finished_match,
csrf_headers,
login,
start_match,
)
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип. # Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
@@ -24,13 +15,16 @@ def _use_tmp_uploads(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path)) monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path))
def _finished_match_for(client: TestClient, engine, me: dict) -> int: def _finished_match_for(client: TestClient, me: dict) -> int:
"""Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд).""" """Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд)."""
exps = [e["id"] for e in client.get("/api/expansions").json()] exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post( gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
p2 = add_group_member(engine, gid, "Соперник") client.post("/api/auth/dev/users", json={"nickname": "Соперник"}, headers=csrf_headers(client))
p2 = 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()] fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
create_finished_match( create_finished_match(
client, client,
@@ -116,10 +110,10 @@ def test_public_profile(client: TestClient):
assert client.get("/api/users/999999/profile").status_code == 404 assert client.get("/api/users/999999/profile").status_code == 404
def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch, tmp_path): def test_leaderboard_includes_avatar_url(client: TestClient, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path) _use_tmp_uploads(monkeypatch, tmp_path)
me = login(client, "Топчик") me = login(client, "Топчик")
_finished_match_for(client, engine, me) _finished_match_for(client, me)
def entry_for(uid: int) -> dict: def entry_for(uid: int) -> dict:
board = client.get("/api/stats/leaderboard").json() board = client.get("/api/stats/leaderboard").json()
@@ -133,318 +127,3 @@ def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch
headers=csrf_headers(client), headers=csrf_headers(client),
) )
assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка
def test_main_faction_counts_random_games(client: TestClient, engine):
"""«Чаще всего играет на» — по всей истории, включая рандомные раздачи."""
me = login(client, "Фракционер")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
p2 = add_group_member(engine, gid, "Партнёр")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
# Дважды играл фракцией A по рандому, один раз фракцией B — по своему выбору.
for _ in range(2):
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1, "was_random": True},
{"user_id": p2, "faction_id": fids[1], "place": 2},
],
)
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[2], "place": 1},
{"user_id": p2, "faction_id": fids[1], "place": 2},
],
)
stats = client.get("/api/users/me/stats").json()
# Условия выбора не важны: A сыграна чаще, пусть и по рандому.
assert stats["main_faction"]["faction_id"] == fids[0]
assert stats["main_faction"]["name_ru_prepositional"] # падежная форма проставлена
# Любимая больше не выводится из истории — она задаётся игроком вручную.
assert stats["favorite_faction"] is None
def test_main_faction_present_for_random_only_player(client: TestClient, engine):
"""Игроку, который всегда играет рандомом, «чаще всего играет на» всё равно считается."""
me = login(client, "Рандомщик")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
p2 = add_group_member(engine, gid, "Оппонент")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1, "was_random": True},
{"user_id": p2, "faction_id": fids[1], "place": 2, "was_random": True},
],
)
stats = client.get("/api/users/me/stats").json()
assert stats["main_faction"]["faction_id"] == fids[0]
def test_favorite_faction_set_and_clear(client: TestClient):
"""Любимая фракция — личный выбор игрока: сохраняется и снимается."""
login(client, "Выбирающий")
fid = client.get("/api/factions").json()[0]["id"]
r = client.patch(
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
assert r.json()["favorite_faction_id"] == fid
assert client.get("/api/users/me").json()["favorite_faction_id"] == fid
# Партий нет вовсе, а любимая фракция всё равно показывается — она не из статистики.
assert client.get("/api/users/me/stats").json()["favorite_faction"]["id"] == fid
r = client.patch(
"/api/users/me/profile", json={"favorite_faction_id": None}, headers=csrf_headers(client)
)
assert r.status_code == 200
assert r.json()["favorite_faction_id"] is None
assert client.get("/api/users/me/stats").json()["favorite_faction"] is None
def test_favorite_faction_rejects_unknown(client: TestClient):
login(client, "Фантазёр")
r = client.patch(
"/api/users/me/profile", json={"favorite_faction_id": 99999}, headers=csrf_headers(client)
)
assert r.status_code == 422
def test_bio_update_keeps_favorite_faction(client: TestClient):
"""Частичный PATCH: правка «о себе» не должна обнулять выбранную фракцию."""
login(client, "Аккуратный")
fid = client.get("/api/factions").json()[0]["id"]
client.patch(
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
)
r = client.patch("/api/users/me/profile", json={"bio": "Только био"}, headers=csrf_headers(client))
assert r.status_code == 200
assert r.json()["bio"] == "Только био"
assert r.json()["favorite_faction_id"] == fid
def test_prepositional_dictionary_and_fallback():
"""Падежная форма берётся из словаря; незнакомый код — именительный как есть."""
from app.services import faction_service
assert faction_service.prepositional("orks", "Орки") == "Орках"
assert (
faction_service.prepositional("astra_militarum", "Имперская гвардия")
== "Имперской гвардии"
)
# Фракция, заведённая админом мимо словаря, не роняет вывод.
assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы"
# ─── История партий в профиле (#1) ───────────────────────────────────────────
def _history(client: TestClient, user_id: int) -> dict:
r = client.get(f"/api/users/{user_id}/matches")
assert r.status_code == 200, r.text
return r.json()
def _group_with(client: TestClient, engine, *nicknames: str) -> tuple[int, list[int], list[int]]:
"""Группа со всеми дополнениями + перечисленные соседи. → (group_id, их user_id, faction_id)."""
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
mates = [add_group_member(engine, gid, nick) for nick in nicknames]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, mates, fids
def test_history_lists_only_own_finished_matches(client: TestClient, engine):
"""В историю идут только завершённые партии этого игрока."""
me = login(client, "Историк")
gid, (mate,), fids = _group_with(client, engine, "Сосед")
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": mate, "faction_id": fids[1], "place": 2},
],
)
# Незавершённая партия мест не имеет и в историю попадать не должна.
assert start_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[2]},
{"user_id": mate, "faction_id": fids[3]},
],
).status_code == 200
data = _history(client, me["id"])
assert data["total"] == 1
assert [m["status"] for m in data["items"]] == ["finished"]
# Значения по умолчанию едут вместе со списком — гостю хватает одного запроса.
assert data["mode"] == "all"
assert data["detail"] == "compact"
def test_history_excludes_matches_without_the_player(client: TestClient, engine):
"""Чужая партия в историю игрока не попадает, даже внутри его группы."""
me = login(client, "Наблюдатель")
gid, (mate, third), fids = _group_with(client, engine, "Игрок2", "Игрок3")
# Партию заводит сосед, сам игрок в ней не участвует.
login(client, "Игрок2")
create_finished_match(
client,
gid,
[
{"user_id": mate, "faction_id": fids[0], "place": 1},
{"user_id": third, "faction_id": fids[1], "place": 2},
],
)
assert _history(client, me["id"])["total"] == 0
assert _history(client, mate)["total"] == 1
def test_history_best_mode_picks_highest_points(client: TestClient, engine):
"""Режим best берёт партию с наибольшим приростом рейтинга, а не самую свежую.
Второе место из четырёх равных приносит рейтинг (обыграны двое), второе место
в дуэли — отнимает."""
me = login(client, "Лучший")
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
create_finished_match(
client,
gid,
[
{"user_id": a, "faction_id": fids[0], "place": 1},
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
{"user_id": b, "faction_id": fids[2], "place": 3},
{"user_id": c, "faction_id": fids[3], "place": 4},
],
)
# Свежее, но по очкам хуже — последнее место на двоих.
create_finished_match(
client,
gid,
[
{"user_id": a, "faction_id": fids[0], "place": 1},
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
],
)
client.patch(
"/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client)
)
data = _history(client, me["id"])
assert data["mode"] == "best"
assert data["total"] == 1
assert data["items"][0]["player_count"] == 4 # старшая партия, но с лучшими очками
def test_history_prefs_saved_and_validated(client: TestClient):
"""Настройки витрины сохраняются; мусор отклоняется, не сбивая сохранённое."""
login(client, "Настройщик")
r = client.patch(
"/api/users/me/profile",
json={"history_mode": "best", "history_detail": "full"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
assert r.json()["history_mode"] == "best"
assert r.json()["history_detail"] == "full"
assert client.get("/api/users/me").json()["history_detail"] == "full"
bad = client.patch(
"/api/users/me/profile", json={"history_mode": "неведомое"}, headers=csrf_headers(client)
)
assert bad.status_code == 422
assert client.get("/api/users/me").json()["history_mode"] == "best"
def test_history_uses_owner_mode_for_guests(client: TestClient, engine):
"""Гость видит историю в том режиме, который выбрал владелец профиля."""
me = login(client, "Витрина")
gid, (mate,), fids = _group_with(client, engine, "Партнёр")
for _ in range(2):
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": mate, "faction_id": fids[1], "place": 2},
],
)
client.patch(
"/api/users/me/profile",
json={"history_mode": "best", "history_detail": "full"},
headers=csrf_headers(client),
)
login(client, "Прохожий")
data = _history(client, me["id"])
assert data["mode"] == "best"
assert data["detail"] == "full"
assert data["total"] == 1
def test_avatar_version_is_stable_across_surfaces(client: TestClient, engine, monkeypatch, tmp_path):
"""Кэш-бастер аватара одинаков в профиле и в лидерборде, и меняется при перезаливке.
Регрессия: версию профиля считал Python из наивного времени как из локального,
а лидерборд — SQL как из UTC, и браузер тянул одну картинку дважды. Плюс при
том же расширении файла updated_at не двигался и ссылка оставалась прежней."""
_use_tmp_uploads(monkeypatch, tmp_path)
me = login(client, "Версия")
_finished_match_for(client, engine, me)
def version_in(url: str) -> str:
return url.split("?v=")[1]
def leaderboard_url() -> str:
board = client.get("/api/stats/leaderboard").json()
entry = next(
e for e in board["entries"] + board["provisional"] if e["user_id"] == me["id"]
)
return entry["avatar_url"]
first = client.put(
"/api/users/me/avatar",
files={"file": ("a.png", PNG, "image/png")},
headers=csrf_headers(client),
)
assert first.status_code == 200, first.text
v_profile = version_in(first.json()["avatar_url"])
assert version_in(leaderboard_url()) == v_profile
# Повторная загрузка с тем же расширением: avatar_path не меняется, поэтому UPDATE
# строки сам собой не эмитится — updated_at должен двигаться явно, иначе кэш-бастер
# замирает и браузер час показывает прежнюю картинку. Версия в ссылке считается с
# точностью до секунды, поэтому сдвиг проверяем по времени в БД.
with Session(engine) as s:
before = s.get(User, me["id"]).updated_at
second = client.put(
"/api/users/me/avatar",
files={"file": ("a.png", PNG + b"\x00", "image/png")},
headers=csrf_headers(client),
)
assert second.status_code == 200, second.text
with Session(engine) as s:
assert s.get(User, me["id"]).updated_at > before
assert version_in(leaderboard_url()) == version_in(second.json()["avatar_url"])
-217
View File
@@ -1,217 +0,0 @@
"""Движок рейтинга: примеры docs/rating/rating-system.md и сверка с эталоном simulate.py.
Числа примеров — те же, что в документе (раздел 6) и в EXPECTED эталона: разъехаться
документ, эталон и приложение не должны. Сверка с simulate.py дополнительно гоняет
синтетический сезон и требует совпадения каждого изменения рейтинга."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from app.services import scoring
from app.services.scoring import RatedMatch, RatedSeat, rate_match, replay
VETERAN = 40 # партий у «опытного» игрока: K = K_MIN
A, B, C, D, E, F = 1, 2, 3, 4, 5, 6
def _vets(*ids: int) -> dict[int, int]:
return dict.fromkeys(ids, VETERAN)
def _duel(first: int, second: int, **kw) -> RatedMatch:
return RatedMatch((RatedSeat(first, 1), RatedSeat(second, 2)), **kw)
def _seat(uid: int, place: int, objectives=None, worlds=None, eliminated=False) -> RatedSeat:
return RatedSeat(uid, place, objectives=objectives, worlds=worlds, eliminated=eliminated)
FIVE = tuple(RatedSeat(uid, i + 1) for i, uid in enumerate((A, B, C, D, E)))
SIX = tuple(RatedSeat(uid, i + 1) for i, uid in enumerate((A, B, C, D, E, F)))
# (ключ, рейтинги, сыграно партий, партия, ожидаемые ΔR с точностью до 0.01)
EXAMPLES = [
("1a", {A: 1600, B: 1400}, _vets(A, B), _duel(A, B, win_reason="objectives"),
{A: 3.84, B: -3.84}),
("1b", {A: 1600, B: 1400}, _vets(A, B), _duel(B, A, win_reason="objectives"),
{B: 12.16, A: -12.16}),
("2a", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 5), _seat(B, 2, 1, 4)), "objectives", end_round=3),
{A: 11.18, B: -11.18}),
("2b", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 5), _seat(B, 2, 1, 4)), "objectives", end_round=8),
{A: 5.46, B: -5.46}),
("3a", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="objectives"),
{A: 8.0, B: -8.0}),
("3b", dict.fromkeys((A, B, C, D, E), 1500), _vets(A, B, C, D, E),
RatedMatch(FIVE, "objectives"),
{A: 11.0, B: 5.5, C: 0.0, D: -5.5, E: -11.0}),
("4a", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="worlds"),
{A: 6.8, B: -6.8}),
("4b", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="plastic"),
{A: 5.6, B: -5.6}),
("4c", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="resources"),
{A: 4.8, B: -4.8}),
("4d", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 6), _seat(B, 2, 2, 5)), "worlds", end_round=8),
{A: 3.4, B: -3.4}),
("4e", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 8), _seat(B, 2, 0, 2)), "objectives", end_round=3),
{A: 16.0, B: -16.0}),
("5", {A: 1550, B: 1500, C: 1480, D: 1450}, _vets(A, B, C, D),
RatedMatch(
(_seat(A, 1, 4, 8), _seat(B, 2, 3, 7),
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
"objectives", end_round=7,
),
{A: 9.27, B: 5.85, C: -7.6, D: -7.53}),
("6a", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=True),
{A: 12.0, B: 7.2, C: 2.4, D: -2.4, E: -7.2, F: -12.0}),
("6b", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=False),
{A: 10.29, B: 7.54, C: 2.74, D: -2.06, E: -6.86, F: -11.66}),
("7", {A: 1500, B: 1500}, {A: 0, B: VETERAN}, _duel(A, B, win_reason="objectives"),
{A: 32.0, B: -8.0}),
]
@pytest.mark.parametrize(
"ratings,games,match,want", [e[1:] for e in EXAMPLES], ids=[e[0] for e in EXAMPLES]
)
def test_document_examples(ratings, games, match, want):
delta, _perf = rate_match(ratings, games, match)
assert {uid: round(v, 2) for uid, v in delta.items()} == want
def test_examples_cover_whole_section():
assert len(EXAMPLES) == 15
def test_last_standing_counts_full_objective_gap():
"""Победа last_standing: отрыв победителя по целям = 1, сколько бы маркеров ни было."""
seats = (_seat(A, 1, 1, 6), _seat(B, 2, 1, 0, eliminated=True))
ordinary = rate_match({}, _vets(A, B), RatedMatch(seats, "objectives"))[0][A]
standing = rate_match({}, _vets(A, B), RatedMatch(seats, "last_standing"))[0][A]
# Отрыв по целям 1 вместо 0 → множитель больше на W_OBJ·1 = 0.5; ΔR = K·ΔM·(S − E).
assert standing - ordinary == pytest.approx(16 * 0.5 * 0.5)
def test_eliminated_are_not_compared_with_each_other():
"""Выбывшие между собой не сравниваются (#91): слабый выбывший среди сильных не
получает рейтинг, а рейтинги прочих выбывших на его изменение не влияют."""
six = range(A, F + 1)
seats = (_seat(A, 1),) + tuple(_seat(u, 2, eliminated=True) for u in six if u != A)
match = RatedMatch(seats, "last_standing")
strong = {**dict.fromkeys(six, 1800), F: 1200}
delta = rate_match(strong, _vets(*six), match)[0]
assert delta[F] < 0
# Сильные выбывшие → слабые: у F и у победителя ничего не меняется от этого.
weak = {**dict.fromkeys(six, 1200), A: 1800}
again = rate_match(weak, _vets(*six), match)[0]
assert again[F] == pytest.approx(delta[F])
# Победителю сила соперников по-прежнему важна: против слабых он получает меньше.
assert again[A] < delta[A]
# ─── Сверка с эталоном ───────────────────────────────────────────────────────
SIMULATE = Path(__file__).resolve().parents[2] / "docs" / "rating" / "simulate.py"
@pytest.fixture(scope="module")
def sim():
if not SIMULATE.exists():
pytest.skip("docs/rating/simulate.py недоступен")
spec = importlib.util.spec_from_file_location("rating_simulate", SIMULATE)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module # dataclasses ищут модуль по имени
spec.loader.exec_module(module) # type: ignore[union-attr]
return module
def _convert(m, ids: dict[str, int], match_id: int) -> RatedMatch:
return RatedMatch(
tuple(
RatedSeat(
ids[s.player], s.place, eliminated=s.eliminated,
objectives=s.objectives, worlds=s.worlds,
)
for s in m.seats
),
m.win_reason,
end_round=m.round,
nine_rounds_rule=m.nine_rounds,
id=match_id,
)
def test_constants_match_reference(sim):
p = sim.PROPOSED
assert (p.r0, p.d, p.k_max, p.k_min, p.k_games) == (
scoring.R0, scoring.D, scoring.K_MAX, scoring.K_MIN, scoring.K_GAMES
)
assert (p.w_table, p.w_tempo, p.w_obj, p.w_worlds) == (
scoring.W_TABLE, scoring.W_TEMPO, scoring.W_OBJ, scoring.W_WORLDS
)
assert (p.mu_obj, p.mu_worlds, p.m_min, p.m_max) == (
scoring.MU_OBJ, scoring.MU_WORLDS, scoring.M_MIN, scoring.M_MAX
)
assert dict(p.closeness) == scoring.CLOSENESS
assert sim.BOARD_TILES == scoring.BOARD_TILES
assert not p.autocorr
assert p.skip_eliminated_pairs # выбывшие между собой не сравниваются (#91)
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
@pytest.mark.parametrize("stripped", [False, True], ids=["full", "history"])
def test_replay_matches_reference_season(sim, scenario, stripped):
"""Весь сезон: каждое изменение рейтинга совпадает с эталоном до 1e-9."""
cfg = sim.SCENARIOS[scenario]
_skill, matches = sim.generate_season(
cfg["seed"], sim.SEASON_MATCHES, cfg["informative"], cfg["clubs"], cfg["learning"]
)
if stripped:
matches = [sim.strip_details(m) for m in matches]
ids: dict[str, int] = {}
for m in matches:
for s in m.seats:
ids.setdefault(s.player, len(ids) + 1)
reference = sim.Elo(sim.PROPOSED)
ours = replay(_convert(m, ids, i) for i, m in enumerate(matches))
for i, m in enumerate(matches):
for player, dv in reference.update(m).items():
assert ours.delta[(i, ids[player])] == pytest.approx(dv, abs=1e-9)
for player, uid in ids.items():
assert ours.ratings[uid] == pytest.approx(reference.rating(player), abs=1e-6)
def test_monotone_and_zero_sum(sim):
"""Победитель без ничьей не теряет, последний без ничьей и любой выбывший не получают;
при равных K сумма изменений за партию — ноль."""
cfg = sim.SCENARIOS["сигнал"]
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
ids: dict[str, int] = {}
for m in matches:
for s in m.seats:
ids.setdefault(s.player, len(ids) + 1)
veterans = dict.fromkeys(ids.values(), VETERAN)
for i, m in enumerate(matches):
rm = _convert(m, ids, i)
delta, _ = rate_match({}, veterans, rm)
places = [s.place for s in rm.seats]
for s in rm.seats:
if s.eliminated:
assert delta[s.user_id] < 0
if places.count(s.place) > 1:
continue
if s.place == 1:
assert delta[s.user_id] > 0
if s.place == max(places):
assert delta[s.user_id] < 0
assert sum(delta.values()) == pytest.approx(0.0, abs=1e-9)
-226
View File
@@ -1,226 +0,0 @@
"""Итоги партии для рейтинга (#23): раунд окончания, цели и миры, правило 9 раундов,
причина «последний выживший». Сервер проверяет диапазоны и явные противоречия."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
def _group(client: TestClient, engine, players: int) -> tuple[int, list[int], list[int]]:
"""Группа со всеми дополнениями и players участниками (первый — вошедший)."""
me = login(client, "Хозяин")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
uids = [me["id"]] + [add_group_member(engine, gid, f"Игрок{i}") for i in range(2, players + 1)]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, uids, fids
def _start(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
r = start_match(
client, gid, [{"user_id": u, "faction_id": fids[i]} for i, u in enumerate(uids)]
)
assert r.status_code == 200, r.text
return r.json()
def _finish(client: TestClient, mid: int, participants: list[dict], win_reason: str, **extra):
body = {"participants": participants, "win_reason": win_reason, **extra}
return client.post(f"/api/matches/{mid}/finish", json=body, headers=csrf_headers(client))
def _set_rule(client: TestClient, gid: int, enabled: bool) -> dict:
r = client.patch(
f"/api/groups/{gid}", json={"nine_rounds_rule": enabled}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
return r.json()
def test_finish_saves_round_objectives_and_worlds(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
assert match["max_rounds"] == 8 and match["end_round"] is None
r = _finish(
client, match["id"],
[
{"user_id": a, "place": 1, "objectives": 2, "worlds": 5},
{"user_id": b, "place": 2, "objectives": 1}, # миры не указаны — так и остаётся
],
"objectives", end_round=6,
)
assert r.status_code == 200, r.text
data = r.json()
assert data["end_round"] == 6
parts = {p["user_id"]: p for p in data["participants"]}
assert (parts[a]["objectives"], parts[a]["worlds"]) == (2, 5)
assert (parts[b]["objectives"], parts[b]["worlds"]) == (1, None)
listed = client.get(f"/api/groups/{gid}/matches").json()["items"][0]["participants"]
assert {p["user_id"]: p["objectives"] for p in listed} == {a: 2, b: 1}
def test_end_round_limited_by_max_rounds(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
rows = [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}]
assert _finish(client, match["id"], rows, "objectives", end_round=9).status_code == 422
assert _finish(client, match["id"], rows, "objectives", end_round=0).status_code == 422
assert _finish(client, match["id"], rows, "objectives", end_round=8).status_code == 200
def test_nine_rounds_rule_is_snapshotted_for_five_players(client: TestClient, engine):
gid, uids, fids = _group(client, engine, 5)
assert _set_rule(client, gid, True)["nine_rounds_rule"] is True
assert client.get(f"/api/groups/{gid}").json()["name"] == "Группа" # имя не тронуто
five = _start(client, gid, uids, fids)
four = _start(client, gid, uids[:4], fids)
assert (five["nine_rounds_rule"], five["max_rounds"]) == (True, 9)
assert (four["nine_rounds_rule"], four["max_rounds"]) == (True, 8) # правило — только с 5
# Смена настройки группы не переписывает уже начатую партию.
_set_rule(client, gid, False)
assert client.get(f"/api/matches/{five['id']}").json()["max_rounds"] == 9
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
assert _finish(client, five["id"], rows, "objectives", end_round=9).status_code == 200
rows4 = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids[:4])]
assert _finish(client, four["id"], rows4, "objectives", end_round=9).status_code == 422
def test_last_standing_required_exactly_when_one_survivor(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _start(client, gid, [a, b, c], fids)
alone = [
{"user_id": a, "place": 1},
{"user_id": b, "eliminated": True},
{"user_id": c, "eliminated": True},
]
r = _finish(client, match["id"], alone, "objectives")
assert r.status_code == 422 and "последний выживший" in r.json()["error"]["message"]
two = [
{"user_id": a, "place": 1},
{"user_id": b, "place": 2},
{"user_id": c, "eliminated": True},
]
assert _finish(client, match["id"], two, "last_standing").status_code == 422
ok = _finish(client, match["id"], alone, "last_standing")
assert ok.status_code == 200, ok.text
assert ok.json()["win_reason"] == "last_standing"
def test_eliminated_player_has_no_worlds(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _start(client, gid, [a, b, c], fids)
rows = [
{"user_id": a, "place": 1, "worlds": 7},
{"user_id": b, "place": 2, "worlds": 4},
{"user_id": c, "eliminated": True, "worlds": 2},
]
assert _finish(client, match["id"], rows, "objectives").status_code == 422
rows[2] = {"user_id": c, "eliminated": True, "objectives": 1} # миры не указаны
r = _finish(client, match["id"], rows, "objectives")
assert r.status_code == 200, r.text
parts = {p["user_id"]: p for p in r.json()["participants"]}
assert (parts[c]["worlds"], parts[c]["objectives"]) == (0, 1)
def test_negative_counts_rejected(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
rows = [{"user_id": a, "place": 1, "objectives": -1}, {"user_id": b, "place": 2}]
assert _finish(client, match["id"], rows, "objectives").status_code == 422
def _finished(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
match = _start(client, gid, uids, fids)
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
r = _finish(client, match["id"], rows, "objectives")
assert r.status_code == 200, r.text
return r.json()
def _edit_rows(match: dict, **by_user) -> list[dict]:
rows = []
for p in match["participants"]:
row = {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": p["place"]}
row.update(by_user.get(str(p["user_id"]), {}))
rows.append(row)
return rows
def test_edit_checks_last_standing_and_round(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _finished(client, gid, [a, b, c], fids)
mid = match["id"]
def patch(body: dict):
return client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client))
elim = {"place": None, "eliminated": True}
rows = _edit_rows(match, **{str(b): elim, str(c): elim})
assert patch({"participants": rows, "win_reason": "worlds"}).status_code == 422
# Причина не передана — сверяется с записанной («по целям»): тоже противоречие.
assert patch({"participants": rows}).status_code == 422
r = patch({"participants": rows, "win_reason": "last_standing", "end_round": 5})
assert r.status_code == 200, r.text
assert (r.json()["win_reason"], r.json()["end_round"]) == ("last_standing", 5)
# Одна только причина: при одном выжившем вернуть «по целям» нельзя.
assert patch({"win_reason": "objectives"}).status_code == 422
# Один только раунд: в пределах лимита — можно, за лимитом — нет.
assert patch({"end_round": 9}).status_code == 422
assert patch({"end_round": None}).json()["end_round"] is None
def test_admin_edit_saves_counts(client: TestClient, engine, make_admin):
gid, (a, b), fids = _group(client, engine, 2)
match = _finished(client, gid, [a, b], fids)
make_admin("admin", "secret123")
assert client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
).status_code == 200
rows = _edit_rows(match, **{str(a): {"objectives": 2, "worlds": 6}})
r = client.patch(
f"/api/admin/matches/{match['id']}",
json={"participants": rows, "win_reason": "objectives", "end_round": 7},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
parts = {p["user_id"]: p for p in r.json()["participants"]}
assert (parts[a]["objectives"], parts[a]["worlds"], r.json()["end_round"]) == (2, 6, 7)
def test_draft_keeps_round_and_counts(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
body = {
"blocks": [[a], [b]],
"win_reason": "last_standing", # черновик — незаконченный ввод, правило не проверяется
"end_round": 4,
"objectives": {str(a): 2},
"worlds": {str(a): 5, str(b): 3},
}
r = client.put(f"/api/matches/{match['id']}/finish-draft", json=body, headers=csrf_headers(client))
assert r.status_code == 200, r.text
data = client.get(f"/api/matches/{match['id']}").json()["finish_draft"]["data"]
assert data["end_round"] == 4
assert data["objectives"] == {str(a): 2}
assert data["worlds"] == {str(a): 5, str(b): 3}
bad = client.put(
f"/api/matches/{match['id']}/finish-draft",
json={"worlds": {str(a): -3}},
headers=csrf_headers(client),
)
assert bad.status_code == 422
-240
View File
@@ -1,240 +0,0 @@
"""Рейтинг в витринах (#23, #80): Elo по упорядоченной истории, один рейтинг на игрока."""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.services.scoring import RatedMatch, RatedSeat, replay
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
def _group(client: TestClient, name: str = "Группа") -> tuple[int, list[int]]:
gid = client.post(
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, fids
def _duel(client: TestClient, gid: int, fids: list[int], winner: int, loser: int) -> dict:
return create_finished_match(
client, gid,
[
{"user_id": winner, "faction_id": fids[0], "place": 1},
{"user_id": loser, "faction_id": fids[1], "place": 2},
],
)
def _board(client: TestClient, path: str = "/api/stats/leaderboard") -> dict[int, dict]:
data = client.get(path).json()
rows = data["entries"] + data["provisional"] if "entries" in data else (
data["leaderboard"] + data["provisional"]
)
return {e["user_id"]: e for e in rows}
def test_newcomer_duel_moves_rating_by_32(client: TestClient, engine):
"""Первая дуэль новичков на 1500: K = 64, ожидание 0.5, деталей нет → ±32."""
me = login(client, "Хозяин")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Гость")
_duel(client, gid, fids, me["id"], b)
board = client.get("/api/stats/leaderboard").json()
by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]}
assert by_id[me["id"]]["score"] == 1532
assert by_id[b]["score"] == 1468
# 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст.
assert board["entries"] == []
assert board["min_games"] == 10
prof = client.get("/api/users/me/stats").json()
assert prof["overall"]["score"] == 1532
# Фракционная метрика — S − E: победа при шансах 0.5 даёт +0.5 → 50.0.
assert prof["factions"][0]["score"] == 50.0
def test_ranked_after_min_games(client: TestClient, engine):
me = login(client, "Чемпион")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Спарринг")
for _ in range(10):
_duel(client, gid, fids, me["id"], b)
board = client.get("/api/stats/leaderboard").json()
ranks = {e["user_id"]: (e["rank"], e["score"]) for e in board["entries"]}
assert ranks[me["id"]][0] == 1 and ranks[b][0] == 2
assert ranks[me["id"]][1] > 1500 > ranks[b][1]
assert isinstance(ranks[me["id"]][1], int)
def test_editing_past_match_recalculates_later_ones(client: TestClient, engine):
"""Рейтинг — функция истории: правка первой партии меняет итог после второй."""
me = login(client, "А")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Б")
first = _duel(client, gid, fids, me["id"], b)
_duel(client, gid, fids, me["id"], b)
def expected(first_winner: int, first_loser: int) -> dict[int, int]:
rep = replay([
RatedMatch((RatedSeat(first_winner, 1), RatedSeat(first_loser, 2)), "objectives", id=1),
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=2),
])
return {uid: round(r) for uid, r in rep.ratings.items()}
before = expected(me["id"], b)
assert {uid: e["score"] for uid, e in _board(client).items()} == before
rows = [
{"user_id": b, "faction_id": fids[1], "place": 1},
{"user_id": me["id"], "faction_id": fids[0], "place": 2},
]
r = client.patch(
f"/api/matches/{first['id']}",
json={"participants": rows, "win_reason": "objectives"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
after = expected(b, me["id"])
assert after != before
assert {uid: e["score"] for uid, e in _board(client).items()} == after
def test_group_page_shows_overall_rating_with_group_stats(client: TestClient, engine):
"""Рейтинг один на всё приложение (#80): в группе он тот же, что в общем топе,
а игры и победы — только по партиям группы."""
me = login(client, "Путешественник")
g1, f1 = _group(client, "Первая")
g2, f2 = _group(client, "Вторая")
b = add_group_member(engine, g1, "Сосед")
c = add_group_member(engine, g2, "Соседка")
_duel(client, g1, f1, me["id"], b) # в первой группе — победа
_duel(client, g2, f2, c, me["id"]) # во второй — поражение
overall = _board(client)[me["id"]]
assert overall["games"] == 2
assert overall["score"] == round(
replay([
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=1),
RatedMatch((RatedSeat(c, 1), RatedSeat(me["id"], 2)), "objectives", id=2),
]).ratings[me["id"]]
)
first = _board(client, f"/api/groups/{g1}/stats")[me["id"]]
second = _board(client, f"/api/groups/{g2}/stats")[me["id"]]
assert first["score"] == second["score"] == overall["score"]
assert (first["games"], first["wins"]) == (1, 1)
assert (second["games"], second["wins"]) == (1, 0)
# Главная и профиль — общие показатели; блок активной группы — игры в группе.
client.put("/api/users/me/active-group", json={"group_id": g2}, headers=csrf_headers(client))
home = client.get("/api/home").json()
assert (home["profile"]["overall"]["games"], home["profile"]["overall"]["score"]) == (
2, overall["score"]
)
assert (home["active_group"]["games"], home["active_group"]["score"]) == (1, overall["score"])
def test_veteran_is_not_a_newcomer_in_new_group(client: TestClient, engine):
"""Статус «Новичок» — про надёжность рейтинга, а он общий: 10 партий где угодно
делают игрока ранжированным и в группе, где он сыграл одну."""
me = login(client, "Ветеран")
g1, f1 = _group(client, "Старая")
b = add_group_member(engine, g1, "Спарринг")
for _ in range(10):
_duel(client, g1, f1, me["id"], b)
g2, f2 = _group(client, "Новая")
c = add_group_member(engine, g2, "Новенький")
add_group_member(engine, g2, "Спарринг") # опытный, но в новой группе не играл
d = add_group_member(engine, g2, "Зритель") # не играл нигде
_duel(client, g2, f2, me["id"], c)
stats = client.get(f"/api/groups/{g2}/stats").json()
ranked = {e["user_id"]: e for e in stats["leaderboard"]}
assert ranked[me["id"]]["rank"] == 1
assert (ranked[me["id"]]["games"], ranked[me["id"]]["rating_confirmed"]) == (1, True)
assert ranked[me["id"]]["score"] == _board(client)[me["id"]]["score"]
assert [e["user_id"] for e in stats["provisional"]] == [c]
inactive = {e["user_id"]: e for e in stats["inactive"]}
assert inactive[b]["score"] == _board(client)[b]["score"]
assert (inactive[b]["games"], inactive[b]["rating_confirmed"]) == (0, True)
assert (inactive[d]["score"], inactive[d]["rating_confirmed"]) == (None, False)
def test_best_match_is_biggest_rating_gain(client: TestClient, engine):
"""Лучшая партия — наибольший прирост рейтинга, а не свежая из равных побед.
Обе партии — победы в дуэли. Первая — новичком над равным (+32), вторая — уже
с рейтингом 1532 и меньшим K над новичком (≈ +28): лучше первая."""
me = login(client, "Лучший")
gid, fids = _group(client)
x = add_group_member(engine, gid, "Икс")
y = add_group_member(engine, gid, "Игрек")
first = _duel(client, gid, fids, me["id"], x)
_duel(client, gid, fids, me["id"], y)
client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client))
data = client.get(f"/api/users/{me['id']}/matches").json()
assert data["total"] == 1
assert data["items"][0]["id"] == first["id"]
def test_match_left_with_one_participant_is_not_a_game(client: TestClient, engine, make_admin):
"""Dev-удаление аккаунта вычёркивает игрока из партий, не трогая сами партии. Партия,
где остался один участник, — не игра: её нет в рейтинге, историях и списках группы
(карточка «с одним игроком» раньше висела в профиле)."""
me = login(client, "Выживший")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Удалённый")
c = add_group_member(engine, gid, "Соперник")
orphan = _duel(client, gid, fids, me["id"], b)
kept = _duel(client, gid, fids, c, me["id"])
make_admin("admin", "secret123")
assert client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
).status_code == 200
assert client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client)).status_code == 200
# Админка партию по-прежнему видит — удалить её можно оттуда.
assert client.get(f"/api/admin/matches/{orphan['id']}").status_code == 200
history = client.get(f"/api/users/{me['id']}/matches").json()
assert (history["total"], [i["id"] for i in history["items"]]) == (1, [kept["id"]])
group_list = client.get(f"/api/groups/{gid}/matches").json()
assert (group_list["total"], [i["id"] for i in group_list["items"]]) == (1, [kept["id"]])
assert client.get(f"/api/groups/{gid}/stats").json()["total_matches"] == 1
# В рейтинге — только настоящая партия: дуэль новичков, проигрыш −32.
board = _board(client)
assert (board[me["id"]]["games"], board[me["id"]]["score"]) == (1, 1468)
assert board[c]["score"] == 1532
def test_history_shows_rating_delta_of_its_owner(client: TestClient, engine):
"""История игрока несёт изменение его общего рейтинга за каждую партию (#77)."""
me = login(client, "Историк")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Оппонент")
_duel(client, gid, fids, me["id"], b) # новички на 1500: ±32
_duel(client, gid, fids, b, me["id"]) # реванш: 1468 обыгрывает 1532
mine = client.get(f"/api/users/{me['id']}/matches").json()["items"]
theirs = client.get(f"/api/users/{b}/matches").json()["items"]
# Свежие сверху: реванш первым.
assert (mine[1]["rating_delta"], theirs[1]["rating_delta"]) == (32.0, -32.0)
assert mine[0]["rating_delta"] == -theirs[0]["rating_delta"] < 0
# Изменения складываются в рейтинг (с точностью округления до десятых).
score = _board(client)[me["id"]]["score"]
assert abs(1500 + sum(i["rating_delta"] for i in mine) - score) <= 0.6
# В списке партий группы дельты нет — непонятно, чья она была бы.
group_items = client.get(f"/api/groups/{gid}/matches").json()["items"]
assert [i["rating_delta"] for i in group_items] == [None, None]
# Режим «лучшая партия» тоже её отдаёт.
client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client))
best = client.get(f"/api/users/{me['id']}/matches").json()["items"]
assert best[0]["rating_delta"] == 32.0
-69
View File
@@ -1,69 +0,0 @@
"""Статистика профиля: цифры сходятся с лидербордом, а главная не грузит историю лишний раз."""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.services import stats_service
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dict, int, int]:
me = login(client, "Статист")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
p2 = add_group_member(engine, gid, "Соперник")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
for i in range(games):
# Чередуем победителя и берём разные фракции: средние и разбивка по фракциям
# должны получиться нетривиальными, а фракции в партии не повторяться.
winner_first = i % 2 == 0
mine, theirs = fids[(2 * i) % len(fids)], fids[(2 * i + 1) % len(fids)]
create_finished_match(
client, gid,
[
{"user_id": me["id"], "faction_id": mine, "place": 1 if winner_first else 2},
{"user_id": p2, "faction_id": theirs, "place": 2 if winner_first else 1},
],
)
return me, gid, p2
def test_profile_numbers_match_leaderboard(client: TestClient, engine):
"""Профиль и лидерборд собирают итог разными путями: цифры обязаны совпадать."""
me, gid, p2 = _group_with_matches(client, engine, games=4)
profile = client.get("/api/users/me/stats").json()["overall"]
board = client.get("/api/stats/leaderboard").json()
entry = next(
e for e in board["entries"] + board["provisional"] if e["user_id"] == me["id"]
)
for field in ("games", "wins", "win_rate", "avg_place", "score"):
assert profile[field] == entry[field], field
def test_home_loads_history_once(client: TestClient, engine, monkeypatch):
"""Главная грузит историю партий один раз: топ, профиль и итог активной группы
считаются из неё. Без этой проверки лишняя загрузка тихо вернётся при правке витрин."""
me, gid, p2 = _group_with_matches(client, engine, games=2)
client.put(
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
)
calls: list[int] = []
original = stats_service.load_history
def spy(session):
calls.append(1)
return original(session)
monkeypatch.setattr(stats_service, "load_history", spy)
r = client.get("/api/home")
assert r.status_code == 200, r.text
assert len(calls) == 1
# Главная всё ещё показывает и профиль, и блок активной группы.
body = r.json()
assert body["profile"]["overall"]["games"] == 2
assert body["active_group"]["games"] == 2
-65
View File
@@ -1,65 +0,0 @@
"""Поиск игроков для автокомплита по нику: GET /api/users/search."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlmodel import Session
from app.models import User
from tests.conftest import login
def _add_user(engine, nickname: str, *, active: bool = True, role: str = "player") -> None:
"""Готовим данные напрямую в БД: нужны выключенные аккаунты и админы."""
with Session(engine) as s:
s.add(
User(
nickname=nickname,
role=role,
auth_provider="stub" if role == "player" else "local",
is_active=active,
# CHECK ck_users_admin_has_password: админ обязан иметь пароль.
password_hash="x" if role == "admin" else None,
)
)
s.commit()
def test_search_prefix_first_cyrillic_case_insensitive(client: TestClient, engine):
login(client, "Искатель")
for nick in ("Барсик", "барс", "Морбарс", "Zed"):
_add_user(engine, nick)
r = client.get("/api/users/search", params={"q": "БАР"})
assert r.status_code == 200, r.text
got = r.json()
# Префиксные раньше подстрочных, внутри — по алфавиту; регистр кириллицы не важен.
assert [u["nickname"] for u in got] == ["барс", "Барсик", "Морбарс"]
assert {"user_id", "nickname", "avatar_url"} <= set(got[0].keys())
def test_search_empty_query_returns_empty_list(client: TestClient):
login(client, "Искатель")
r = client.get("/api/users/search", params={"q": " "})
assert r.status_code == 200, r.text
assert r.json() == []
def test_search_skips_inactive_and_admins(client: TestClient, engine):
login(client, "Искатель")
_add_user(engine, "Спящий барсук", active=False)
_add_user(engine, "адм барс", role="admin")
r = client.get("/api/users/search", params={"q": "барс"})
assert r.status_code == 200, r.text
assert r.json() == []
def test_search_respects_limit(client: TestClient, engine):
login(client, "Искатель")
for i in range(5):
_add_user(engine, f"Игрок {i}")
assert len(client.get("/api/users/search", params={"q": "игрок"}).json()) == 3
assert (
len(client.get("/api/users/search", params={"q": "игрок", "limit": 5}).json()) == 5
)
-31
View File
@@ -1,31 +0,0 @@
"""Ошибки валидации запроса: всегда 422 в едином конверте и без эха тела запроса."""
from __future__ import annotations
from fastapi.testclient import TestClient
def test_non_json_body_is_422_not_500(client: TestClient):
"""HTML-форма шлёт text/plain: тело приходит сырыми bytes, ответ раньше падал в 500."""
for path, raw in [
("/api/auth/telegram", '{"id": 1}'),
("/api/admin/auth/login", '{"username": "a", "password": "b"}'),
]:
r = client.post(path, content=raw, headers={"Content-Type": "text/plain"})
assert r.status_code == 422, (path, r.text)
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
def test_validation_error_does_not_echo_body(client: TestClient):
secret = "very-secret-password"
r = client.post("/api/admin/auth/login", json={"password": secret})
assert r.status_code == 422, r.text
assert secret not in r.text
details = r.json()["error"]["details"]
assert details and all(set(d) == {"type", "loc", "msg"} for d in details)
def test_validation_error_points_to_field(client: TestClient):
r = client.post("/api/admin/auth/login", json={"password": "x"})
locs = [d["loc"] for d in r.json()["error"]["details"]]
assert ["body", "username"] in locs
+23 -43
View File
@@ -1,6 +1,6 @@
# Публикация: домены, VPS, туннели # Публикация: домены, VPS, туннели
Приложение крутится дома (Pi — прод) и на твоём ПК (dev). Дома белого IP нет Приложение крутится дома (Pi — прод) и на твоём ПК (dev/test). Дома белого IP нет
(CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует (CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует
HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые
приложение само открывает к VPS. приложение само открывает к VPS.
@@ -11,57 +11,37 @@ HTTPS твоими сертификатами и проксирует трафи
│ ▲ туннель-КОНТЕЙНЕР │ │ ▲ туннель-КОНТЕЙНЕР │
│ └── Pi : app:8000 PROD │ │ └── Pi : app:8000 PROD │
forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │ forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │
│ ▲ ssh с ПК (по требованию) │ │ ▲ контейнер (test) ИЛИ │
│ └── ПК dev : vite:5173 DEV │ │ ▲ ssh с ПК (dev) │
│ ├── ПК test : app:8000 │
│ └── ПК dev : vite:5173 │
└───────────────────────────────────────────────────┘ └───────────────────────────────────────────────────┘
``` ```
- **PROD** — Pi. `docker compose up -d` поднимает три сервиса: `app` + `tunnel` + `backup` - **PROD** — Pi. `docker compose up -d` поднимает два сервиса: `app` + `tunnel`. У `app`
(образы из Gitea-реестра, собираются на ПК `scripts/build-push.ps1`). У `app` **портов на **портов на хост нет** — наружу его выставляет только туннель-контейнер
хост нет** — наружу его выставляет только туннель-контейнер (`ssh -R 9000:app:8000` к VPS). (`ssh -R 9000:app:8000` к VPS). Постоянно, Docker сам переподключает. См. [`pi/`](pi/README.md).
Работает постоянно: при обрыве `ssh` завершается, и Docker перезапускает контейнер - **TEST** — ПК. То же самое: `docker compose -f docker-compose.test.yml up` поднимает
(`restart: unless-stopped`). См. [`pi/`](pi/README.md). `app` + `tunnel` (`ssh -R 9001:app:8000`). Портов на хост нет — тест виден только на
`forbidden-stars.ru`. Обычно запускается лаунчером при `APP_ENV=test`.
- **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при - **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при
`LOCAL_PUBLIC=vps` лаунчер (`run.ps1` / `run.sh`) дополнительно поднимает SSH-туннель с ПК `LOCAL_PUBLIC=vps` лаунчер (`run.ps1`) дополнительно поднимает SSH-туннель с ПК
(`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru` (слот **9001**). (`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru`.
- PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо от dev. - DEV и TEST делят слот **9001** (`forbidden-stars.ru`) → поднимай что-то **одно за раз**.
Временный прод на ПК (`docker-compose.temp.yml`) тоже занимает **9000** — одновременно с Pi нельзя. PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо.
Ключи туннеля (приватные, в git не идут; публичные части — в `authorized_keys` пользователя Ключ туннеля — **`deploy/tunnel/id_tunnel`** (приватный, в git не идёт). Его публичную
`tunnel` на VPS): часть добавь в `authorized_keys` пользователя `tunnel` на VPS. Один и тот же ключ годится
- **Pi** — `TUNNEL_KEY_B64` (base64 приватного ключа) в `.env`; файла ключа на Pi нет. для контейнерного туннеля (Pi/ПК) и для dev-туннеля `run.ps1`.
- **ПК, временный прод** — файл `deploy/tunnel/id_tunnel`, монтируется в туннель-контейнер.
- **ПК, dev** — `run.ps1`/`run.sh` зовут системный `ssh` без `-i`, то есть с ключом по
умолчанию из `~/.ssh`. Он должен быть в `authorized_keys` (можно тем же, что `id_tunnel`).
Настройка по шагам: Настройка по шагам:
1. **VPS** — [`vps/README.md`](vps/README.md): Caddy, файрвол, пользователь `tunnel`, сертификаты, `Caddyfile`. 1. **VPS** — [`vps/README.md`](vps/README.md): Caddy, файрвол, пользователь `tunnel`, сертификаты, `Caddyfile`.
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ туннеля в `TUNNEL_KEY_B64`, `.env`, `docker compose up -d`. 2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ в `deploy/tunnel/id_tunnel`, `.env`, `docker compose up -d`.
3. **ПК (dev)** — ключ по умолчанию в `~/.ssh` (для dev-туннеля) и, если нужен временный прод, 3. **ПК (dev/test)** — тот же ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или
файл `deploy/tunnel/id_tunnel`; pubkey — в `authorized_keys` у `tunnel@VPS`. ключ по умолчанию для dev (`run.ps1`); pubkey — в `authorized_keys` у `tunnel@VPS`.
4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает
снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их на ПК.
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`, `id_backup*`) живут на Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`) живут на VPS/Pi/ПК,
VPS/Pi/ПК, в репозитории только `Caddyfile`, страница-заглушка, образы `deploy/tunnel/` и в репозитории только `Caddyfile`, `deploy/tunnel/` (образ туннеля) и шаблоны.
`deploy/backup/` и шаблоны.
> Telegram-вход требует HTTPS-домен: у BotFather `/setdomain` укажи оба домена > Telegram-вход требует HTTPS-домен: у BotFather `/setdomain` укажи оба домена
> (`forbiddenstars.ru` и `forbidden-stars.ru`). > (`forbiddenstars.ru` и `forbidden-stars.ru`).
## Реал-тайм (SSE)
Приложение шлёт события «данные изменились» через SSE: `GET /api/events`
(`text/event-stream`). Это обычный HTTP — проходит через Caddy и SSH-туннель без доп.
настроек, КРОМЕ одного: для пути `/api/events` в `Caddyfile` выделен отдельный `handle`
**без `encode`** и с **`flush_interval -1`** (иначе сжатие/буферизация задержат поток).
После правки `Caddyfile` на VPS: `sudo systemctl reload caddy` (или `restart`).
Шина событий — **in-memory**, рассчитана на один процесс (uvicorn `--workers 1`, как в
контейнере). Если когда-нибудь поднимешь несколько воркеров/реплик — шину нужно вынести во
внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию.
SSE-поток сам не закрывается, поэтому uvicorn запускается с `--timeout-graceful-shutdown`
(10 с в `entrypoint.sh`, 2 с в `run.*`). Без него остановка ждёт закрытия всех соединений: в dev
`--reload` при открытой вкладке висит вечно, в контейнере остановку обрывает только SIGKILL
по `stop_grace_period`.
-6
View File
@@ -1,6 +0,0 @@
# В контекст сборки попадают ТОЛЬКО файлы образа. Всё остальное (в первую очередь приватный
# ключ deploy/backup/id_backup, если он лежит здесь на ПК) в образ не попадёт.
*
!Dockerfile
!fs-backup.sh
!entrypoint.sh
-34
View File
@@ -1,34 +0,0 @@
# Контейнер бэкапов: restic (дедупликация, шифрование, сжатие zstd) + расписание supercronic.
# Снимает консистентную копию SQLite и файлы томов приложения, хранит снимки локально (том
# backup-data) и на VPS (SFTP). Управление — команда fs-backup (см. deploy/backup/README.md).
FROM restic/restic:0.19.1
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
# tini — корректные сигналы; curl — отчёты и бот в Telegram (#83).
# jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
RUN apk add --no-cache sqlite supercronic tini curl
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
RUN addgroup -g 10001 fsbackup \
&& adduser -D -u 10001 -G fsbackup -h /home/fsbackup fsbackup \
&& mkdir -p /fs/uploads /fs/achievements /fs-db /backup/repo /backup/state /backup/cache /import \
&& chown -R 10001:10001 /fs /fs-db /backup /import /home/fsbackup
COPY fs-backup.sh /usr/local/bin/fs-backup
COPY entrypoint.sh /usr/local/bin/fs-backup-entrypoint
RUN sed -i 's/\r$//' /usr/local/bin/fs-backup /usr/local/bin/fs-backup-entrypoint \
&& chmod 755 /usr/local/bin/fs-backup /usr/local/bin/fs-backup-entrypoint
ENV HOME=/home/fsbackup \
RESTIC_CACHE_DIR=/backup/cache \
TZ=Europe/Moscow
USER 10001:10001
WORKDIR /home/fsbackup
# Здоров = последний успешный бэкап свежее BACKUP_MAX_AGE_HOURS (в каждом репозитории).
HEALTHCHECK --interval=10m --timeout=60s --start-period=2h --retries=1 \
CMD ["fs-backup", "health"]
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/fs-backup-entrypoint"]
File diff suppressed because it is too large Load Diff
-60
View File
@@ -1,60 +0,0 @@
#!/bin/sh
# Точка входа контейнера backup: первый снимок (если снимков ещё нет) и расписание.
# Расписание — supercronic по BACKUP_SCHEDULE (бэкап) и BACKUP_VERIFY_SCHEDULE (проверка).
set -eu
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
# Telegram-бот (#83): отвечает владельцу на /backups и /status. Стартует раньше проверки
# пароля — при выключенных бэкапах скажет, что они выключены. Упал — перезапуск через минуту.
if [ -n "${BACKUP_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${BACKUP_TELEGRAM_CHAT_ID:-}" ]; then
( while :; do fs-backup telegram bot || true; sleep 60; done ) &
else
log "Telegram не настроен (BACKUP_TELEGRAM_CHAT_ID/токен бота) — отчёты в Telegram выключены."
fi
# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём,
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
if [ -z "${BACKUP_PASSWORD:-}" ]; then
log "BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ."
log "Настройка по шагам: deploy/backup/README.md. После правки .env: docker compose up -d backup"
exec tail -f /dev/null
fi
mkdir -p /tmp/fs-backup
CRONTAB=/tmp/fs-backup/crontab
: > "$CRONTAB"
if [ -n "${BACKUP_SCHEDULE:-}" ]; then
echo "${BACKUP_SCHEDULE} fs-backup run --scheduled" >> "$CRONTAB"
fi
if [ -n "${BACKUP_VERIFY_SCHEDULE:-}" ]; then
echo "${BACKUP_VERIFY_SCHEDULE} fs-backup verify" >> "$CRONTAB"
fi
if [ ! -s "$CRONTAB" ]; then
log "Расписание отключено — только ручной запуск: docker compose exec backup fs-backup run"
exec tail -f /dev/null
fi
if ! fs-backup has-snapshots; then
# Новый сервер: app и backup стартуют разом, и БД может быть ещё без таблиц. /api/health
# отвечает только после миграций и bootstrap — ждём его (#74). Не дождались — пробуем всё
# равно: guard_empty не сохранит БД без таблиц.
_app="${BACKUP_APP_HOST:-app}"
_wait="${BACKUP_APP_WAIT_SECONDS:-600}"
log "Снимков в локальном репозитории ещё нет — жду приложение (до ${_wait} с) и делаю первый бэкап."
_waited=0
until wget -q -T 3 -O /dev/null "http://$_app:8000/api/health" 2>/dev/null; do
if [ "$_waited" -ge "$_wait" ]; then
log "Приложение не ответило за ${_wait} с — пробую бэкап без него."
break
fi
sleep 10
_waited=$((_waited + 10))
done
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
fi
log "Расписание (TZ=${TZ:-UTC}):"
sed 's/^/[backup] /' "$CRONTAB"
exec supercronic -passthrough-logs "$CRONTAB"
File diff suppressed because it is too large Load Diff
+25 -164
View File
@@ -1,185 +1,46 @@
# Raspberry Pi — ПРОД (`forbiddenstars.ru`) # Raspberry Pi — ПРОД (`forbiddenstars.ru`)
На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл Pi запускает приложение **и туннель** в Docker: `docker compose up` поднимает два сервиса —
ключа туннеля не нужны: `app` (FastAPI + SPA) и `tunnel` (ssh-контейнер). У `app` **портов на хост нет**, наружу он
- образы (`app` + `tunnel` + `backup`) тянутся из Gitea-реестра (`pull_policy: always`); доступен только через туннель-контейнер (`ssh -R 9000:app:8000` к VPS). Публичная точка —
- приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64). VPS, домен `forbiddenstars.ru`. Снаружи Pi недоступен (CGNAT) — туннель сам стучится наружу.
`docker compose up` поднимает три контейнера: `app` (FastAPI+SPA, портов на хост нет), ## 1. Ключ для туннеля
`tunnel` (`ssh -R 9000:app:8000` к VPS, стартует после `healthy` у `app`) и `backup` (restic, Туннель-контейнер ходит на `tunnel@VPS` по ключу из `deploy/tunnel/id_tunnel` (gitignored).
см. раздел «Бэкапы»). Публичная точка — VPS, домен `forbiddenstars.ru` (Pi за CGNAT — туннель Создай ключ и добавь его pubkey в `authorized_keys` пользователя `tunnel` на VPS:
стучится наружу сам).
---
## 0. Предпосылки (один раз, не на Pi)
1. **VPS настроен**: Caddy `forbiddenstars.ru` → `127.0.0.1:9000`, пользователь `tunnel`,
сертификаты, страница-заглушка — см. `deploy/vps/README.md`.
2. **Образы собраны и запушены в реестр** (на ПК с Docker Desktop):
```powershell
docker login gitea.arseniev.info
.\scripts\build-push.ps1
```
Без этого `docker compose up` на Pi не найдёт образы в реестре.
## 1. Система и Docker на Pi
```bash ```bash
ssh pi@<ip-пая> ssh-keygen -t ed25519 -f deploy/tunnel/id_tunnel -N ""
sudo apt update && sudo apt -y full-upgrade ssh-copy-id -i deploy/tunnel/id_tunnel.pub tunnel@186.246.51.17
sudo apt -y install curl openssh-client # base64/ssh-keygen уже есть в системе # (или вручную: содержимое deploy/tunnel/id_tunnel.pub -> /home/tunnel/.ssh/authorized_keys на VPS)
curl -fsSL https://get.docker.com | sudo sh # Docker Engine + compose-плагин (arm64)
sudo usermod -aG docker $USER
sudo systemctl enable --now docker # автозапуск после ребута
newgrp docker # применить группу (или перезайти по SSH)
docker version && docker compose version # проверка
``` ```
### Лимиты памяти (memory cgroup) ## 2. .env
Прошивка Raspberry Pi сама добавляет ядру `cgroup_disable=memory`. Без контроллера `memory`
Docker **молча игнорирует** `mem_limit` из `docker-compose.yml` (app 512m, tunnel 64m, backup
384m) и на каждый контейнер пишет «Your kernel does not support memory limit capabilities or
the cgroup is not mounted. Limitation discarded.». Тогда утечка или тяжёлый бэкап могут
занять всю RAM Pi, и OOM-killer прибьёт что попало.
Проверка — если есть вывод, лимиты не работают:
```bash ```bash
docker info 2>&1 | grep -i "no memory limit" cp .env.example .env # заполни секреты; APP_ENV контейнер форсит в production
cat /sys/fs/cgroup/cgroup.controllers # в списке должно быть слово memory
``` ```
Включить — дописать параметр **в ту же единственную строку** `cmdline.txt` (перевод строки Проверь, что заданы параметры VPS (их читает сервис `tunnel`):
в этом файле ломает загрузку) и перезагрузить Pi. Прод на время перезагрузки недоступен,
контейнеры поднимутся сами:
```bash
sudo cp /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak
grep -q "cgroup_enable=memory" /boot/firmware/cmdline.txt || \
sudo sed -i '1 s/$/ cgroup_enable=memory/' /boot/firmware/cmdline.txt
cat /boot/firmware/cmdline.txt # одна строка, в конце cgroup_enable=memory
sudo reboot
``` ```
После перезагрузки `cgroup.controllers` содержит `memory`, а `docker info` не пишет
`No memory limit support`. Если Pi не загрузился, верните бэкап: вставьте карту в ПК и на
разделе `bootfs` замените `cmdline.txt` содержимым `cmdline.txt.bak`.
## 2. SSH-ключ для туннеля
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
- **приватный** ключ → в `.env` как `TUNNEL_KEY_B64` (base64, одной строкой);
- **публичный** (`.pub`) → в `authorized_keys` пользователя `tunnel` на VPS.
Сгенерировать прямо на Pi:
```bash
ssh-keygen -t ed25519 -f ~/fs_tunnel -N "" # создаст ~/fs_tunnel (приватный) и ~/fs_tunnel.pub
```
Добавить публичный ключ на VPS (Pi ходит наружу — это работает даже за CGNAT):
```bash
ssh-copy-id -i ~/fs_tunnel.pub tunnel@186.246.51.17
# Если у tunnel нет пароля (только ключ) — добавь вручную через свой админ-доступ к VPS:
# cat ~/fs_tunnel.pub # скопируй строку
# на VPS: echo '<строка>' >> /home/tunnel/.ssh/authorized_keys
```
Закодировать приватный ключ в base64 **одной строкой** — это значение для `TUNNEL_KEY_B64`:
```bash
base64 -w0 ~/fs_tunnel; echo # выведет длинную строку без переносов — скопируй её целиком
```
> Альтернатива: если ключ уже есть на ПК (`deploy/tunnel/id_tunnel`) и его pubkey уже на VPS —
> не плоди новый, закодируй тот:
> `[Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\tunnel\id_tunnel")))`
После того как base64 вставлен в `.env`, файлы ключа на Pi можно удалить — ключ теперь в `.env`,
а pubkey уже на VPS:
```bash
shred -u ~/fs_tunnel ~/fs_tunnel.pub # или просто rm
```
## 3. Два файла: docker-compose.yml + .env
```bash
mkdir -p ~/forbidden-stars && cd ~/forbidden-stars
# branch — main (или dev, если ещё не смёржено в main):
curl -fsSLO https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/raw/branch/main/docker-compose.yml
curl -fsSL https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/raw/branch/main/.env.example -o .env
nano .env
```
Заполнить в `.env`:
```
SECRET_KEY=... # python3 -c "import secrets;print(secrets.token_urlsafe(48))"
ADMIN_USERNAME=... # логин/пароль секретной админки
ADMIN_PASSWORD=...
TELEGRAM_BOT_TOKEN=... # бот @BotFather; затем /setdomain → forbiddenstars.ru
TELEGRAM_BOT_USERNAME=...
VPS_TUNNEL_HOST=186.246.51.17 VPS_TUNNEL_HOST=186.246.51.17
TUNNEL_KEY_B64=... # длинная строка base64 из шага 2 (целиком, одной строкой) VPS_TUNNEL_USER=tunnel
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost # уже значение по умолчанию
IMAGE_TAG=latest
``` ```
`APP_ENV` (форсится в `production`) и `VPS_TUNNEL_PORT` (прод → 9000) не трогать.
Блок `BACKUP_*` можно оставить пустым: бэкапы включаются позже, по
[`deploy/backup/README.md`](../backup/README.md).
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32 ## 3. Запуск
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются автоматически **только при первом создании**
> админа: если потом поменять их в `.env`, у существующего админа само ничего не изменится.
>
> **Смена пароля админа** (плановая или при утечке) — осознанной командой, через панель
> его не сменить:
> 1. поменять `ADMIN_PASSWORD` в `.env` на Pi;
> 2. `docker compose up -d` — пересоздаст `app` с новым `.env` (контейнер читает `.env`
> только при создании; без этого шага команда ниже увидит старый пароль);
> 3. `docker compose exec app python -m app.bootstrap --reset-admin-password`.
>
> Все админские сессии, в том числе чужие, если пароль утёк, после этого завершаются.
> Логин (`ADMIN_USERNAME`) команда не меняет.
## 4. Запуск
```bash ```bash
docker login gitea.arseniev.info # один раз, доступ к реестру docker compose up -d --build # поднимет app + tunnel
docker compose up -d # pull_policy: always → тянет образы из реестра, без сборки docker compose ps # оба up; tunnel стартует после healthy app
docker compose ps # app healthy → поднимется tunnel
docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@... docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@...
``` ```
На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env` Открой `https://forbiddenstars.ru` — должен отдать прод.
(если админа ещё нет).
## 5. Проверка
- Открой `https://forbiddenstars.ru` — должно отдать приложение (не заглушку).
- У @BotFather `/setdomain` → добавь `forbiddenstars.ru` (иначе Telegram-вход не заработает).
- Админка: удержать «Меню» 10 с → `/admin/login`, войти логином/паролём из `.env`.
## Обновление ## Обновление
```bash ```bash
# Pi: docker compose exec backup fs-backup run --tag before-update # по желанию, если бэкапы включены git pull
# ПК: .\scripts\build-push.ps1 docker compose up -d --build
# Pi: docker compose up -d # always-pull подтянет свежие образы
```
Если в новой версии менялся `docker-compose.yml` или `.env.example`, сначала скачайте
свежий `docker-compose.yml` (шаг 3) и допишите новые переменные в `.env`.
## Автозапуск после перезагрузки
Уже обеспечен: `systemctl enable docker` + `restart: unless-stopped`. После `sudo reboot`
контейнеры поднимутся сами (используют локальный образ, без повторного pull).
## Бэкапы
Отдельный контейнер `backup` в том же `docker-compose.yml`: каждую ночь делает зашифрованный
снимок БД, `uploads` и `achievements` на Pi и на VPS. Дополнительных файлов на Pi не нужно,
всё настраивается блоком `BACKUP_*` в `.env`. Пока `BACKUP_PASSWORD` пуст, бэкапы выключены.
Пошаговая настройка, восстановление и действия при гибели Pi —
[`deploy/backup/README.md`](../backup/README.md).
```bash
docker compose exec backup fs-backup status # состояние
docker compose exec backup fs-backup list # хронология снимков
``` ```
> Не выполняйте `docker compose down -v`: флаг `-v` удаляет тома с данными и локальными бэкапами. ## Бэкап БД
`scripts/backup.sh` (консистентная онлайн-копия SQLite; по cron на хосте). Том БД — `db-data`.
## Если что-то не так Логи: `docker compose logs -f app` / `... tunnel`. Если `https://forbiddenstars.ru` отдаёт
- `https://forbiddenstars.ru` отдаёт заглушку/502 → туннель не поднят: `docker compose logs tunnel` 502 — значит туннель не поднят: смотри `docker compose logs tunnel` (чаще всего ключ не в
(чаще: pubkey не в `authorized_keys` на VPS, пустой/битый `TUNNEL_KEY_B64`, либо на VPS занят `authorized_keys` на VPS или не заданы `VPS_TUNNEL_*` в `.env`).
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
- `docker compose up` пишет «Your kernel does not support memory limit capabilities…
Limitation discarded.» → лимиты памяти не работают: включите memory cgroup, раздел 1,
«Лимиты памяти».
+10 -12
View File
@@ -9,20 +9,18 @@ VPS_TUNNEL_USER="${VPS_TUNNEL_USER:-tunnel}"
: "${VPS_TUNNEL_PORT:?VPS_TUNNEL_PORT is required}" : "${VPS_TUNNEL_PORT:?VPS_TUNNEL_PORT is required}"
UPSTREAM="${UPSTREAM:-app:8000}" UPSTREAM="${UPSTREAM:-app:8000}"
# Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env), # Ключ должен быть смонтирован файлом. Если тут пусто/папка — Docker создал каталог,
# либо смонтированный файл /key/id_tunnel (временный прод на ПК, где репозиторий есть на хосте). # потому что файла deploy/tunnel/id_tunnel на хосте нет.
mkdir -p /root/.ssh if [ ! -f /key/id_tunnel ]; then
KEY=/root/.ssh/id_tunnel echo "[tunnel] ERROR: /key/id_tunnel is not a regular file."
if [ -n "${TUNNEL_KEY_B64:-}" ]; then echo "[tunnel] Put your private key at deploy/tunnel/id_tunnel on the host (its pubkey must be in authorized_keys of tunnel@VPS)."
echo "$TUNNEL_KEY_B64" | base64 -d > "$KEY"
elif [ -f /key/id_tunnel ]; then
cp /key/id_tunnel "$KEY"
else
echo "[tunnel] ERROR: нет ключа — задай TUNNEL_KEY_B64 в .env (base64) или примонтируй /key/id_tunnel."
echo "[tunnel] base64 ключа без переносов: base64 -w0 deploy/tunnel/id_tunnel"
exit 1 exit 1
fi fi
chmod 600 "$KEY"
# Копируем ключ и выставляем строгие права (с хоста права могут не годиться, особенно с Windows).
mkdir -p /root/.ssh
cp /key/id_tunnel /root/.ssh/id_tunnel
chmod 600 /root/.ssh/id_tunnel
echo "[tunnel] -R ${VPS_TUNNEL_PORT}:${UPSTREAM} -> ${VPS_TUNNEL_USER}@${VPS_TUNNEL_HOST}" echo "[tunnel] -R ${VPS_TUNNEL_PORT}:${UPSTREAM} -> ${VPS_TUNNEL_USER}@${VPS_TUNNEL_HOST}"
exec ssh -N \ exec ssh -N \
+5 -84
View File
@@ -1,101 +1,22 @@
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа. # Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели: # Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
# #
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, ssh + restart) PROD # forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV # forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
# #
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к # Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host — # приложению добавляет X-Forwarded-Proto=https / X-Forwarded-For / Host —
# приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile. # приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile.
#
# X-Forwarded-For ПЕРЕЗАПИСЫВАЕМ реальным пиром (header_up ... {remote_host}), а не
# добавляем: иначе клиент мог бы подставить своё левое значение и подменить IP для
# throttle и аудита (#58). Вместе с сужением forwarded-allow-ips в entrypoint.sh это
# делает клиентский IP достоверным.
# Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом). # Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом).
#
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
# буферизация задержат поток событий (реал-тайм перестанет работать).
#
# Туннель лёг (апстрим недоступен) → reverse_proxy отдаёт 502/503/504, и сниппет (edge)
# подменяет ответ на страницу-заглушку (/etc/caddy/maintenance/maintenance.html) вместо
# «зависшей загрузки» SPA. Тот же сниппет запрещает кэш HTML-документа (Cache-Control: no-store),
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
# Файл заглушки — deploy/vps/maintenance.html.
# Edge-поведение, общее для сайтов: security-заголовки + запрет кэша HTML + заглушка при падении апстрима.
(edge) {
# Security-заголовки (#61). HSTS — принудительный HTTPS на год с поддоменами; nosniff —
# запрет MIME-sniffing; frame DENY — защита от кликджекинга (наши страницы нельзя встроить
# в чужой iframe); Referrer/Permissions — минимизация утечек. Server скрываем, чтобы не
# светить используемый прокси.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
-Server
}
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки: строгая политика легко
# ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с telegram.org + iframe
# oauth.telegram.org) и EventSource (/api/events). Раскомментировать после проверки,
# что вход и реал-тайм работают (#61).
# header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
@html header Accept *text/html*
header @html Cache-Control "no-store"
# Туннель лёг (апстрим 502/503/504) → отдаём свою страницу-заглушку со статусом 503.
handle_errors {
@down expression `{http.error.status_code} in [502, 503, 504]`
handle @down {
root * /etc/caddy/maintenance
rewrite * /maintenance.html
header Cache-Control "no-store"
file_server {
status 503
}
}
}
}
forbiddenstars.ru { forbiddenstars.ru {
tls /etc/caddy/certs/forbiddenstars.ru/fullchain.pem /etc/caddy/certs/forbiddenstars.ru/privkey.pem tls /etc/caddy/certs/forbiddenstars.ru/fullchain.pem /etc/caddy/certs/forbiddenstars.ru/privkey.pem
@sse path /api/events
handle @sse {
reverse_proxy 127.0.0.1:9000 {
flush_interval -1
header_up X-Forwarded-For {remote_host}
}
}
handle {
encode zstd gzip encode zstd gzip
reverse_proxy 127.0.0.1:9000 { reverse_proxy 127.0.0.1:9000
header_up X-Forwarded-For {remote_host}
}
}
import edge
} }
forbidden-stars.ru { forbidden-stars.ru {
tls /etc/caddy/certs/forbidden-stars.ru/fullchain.pem /etc/caddy/certs/forbidden-stars.ru/privkey.pem tls /etc/caddy/certs/forbidden-stars.ru/fullchain.pem /etc/caddy/certs/forbidden-stars.ru/privkey.pem
@sse path /api/events
handle @sse {
reverse_proxy 127.0.0.1:9001 {
flush_interval -1
header_up X-Forwarded-For {remote_host}
}
}
handle {
encode zstd gzip encode zstd gzip
reverse_proxy 127.0.0.1:9001 { reverse_proxy 127.0.0.1:9001
header_up X-Forwarded-For {remote_host}
}
}
import edge
} }
+11 -40
View File
@@ -1,11 +1,11 @@
# VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей # VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей
Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами
для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev). для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev/test).
``` ```
forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
forbidden-stars.ru → 127.0.0.1:9001 ← ПК (ssh из лаунчера, по требованию) DEV forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
``` ```
> Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит > Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит
@@ -51,9 +51,7 @@ install -d -m 700 -o tunnel -g tunnel /home/tunnel/.ssh
install -m 600 -o tunnel -g tunnel /dev/null /home/tunnel/.ssh/authorized_keys install -m 600 -o tunnel -g tunnel /dev/null /home/tunnel/.ssh/authorized_keys
``` ```
Публичные ключи Pi и ПК добавишь в `/home/tunnel/.ssh/authorized_keys` на шагах настройки Публичные ключи Pi и ПК добавишь в `/home/tunnel/.ssh/authorized_keys` на шагах настройки
Pi (`deploy/pi/README.md`) и ПК (`deploy/tunnel/id_tunnel.pub` и/или ключ по умолчанию из Pi (`deploy/pi/README.md`) и ПК (через `ssh-copy-id`).
`~/.ssh` для dev-туннеля). Ключи **дописывай** (`>>`), а не перезаписывай файл (`>`):
иначе туннель другого хоста перестанет пускать.
## 5. Сертификаты ## 5. Сертификаты
@@ -69,11 +67,11 @@ Caddy читает **PEM** (текст с `-----BEGIN CERTIFICATE-----`). Рас
Удобно собрать прямо на VPS — залей свои файлы и склей: Удобно собрать прямо на VPS — залей свои файлы и склей:
```bash ```bash
mkdir -p /etc/caddy/certs/forbidden-stars.ru /etc/caddy/certs/forbiddenstars.ru /root/certs-tmp mkdir -p /etc/caddy/certs/forbidden-stars.ru /root/certs-tmp
# с локальной машины (пример для домена forbidden-stars.ru; имена файлов — свои): # с локальной машины (пример для домена forbidden-stars.ru):
scp forbidden-stars.crt intermediate.crt forbidden-stars.key root@186.246.51.17:/root/certs-tmp/ scp forbidden-stars.crt intermediate.crt forbidden-stars.key root@186.246.51.17:/root/certs-tmp/
# на VPS — собрать fullchain (leaf + промежуточный) и положить ключ: # на VPS — собрать fullchain (leaf + промежуточный) и положить ключ:
cat /root/certs-tmp/forbidden-stars.crt /root/certs-tmp/intermediate.crt \ cat /root/certs-tmp/www_forbidden_stars_ru_2026_12_31.crt /root/certs-tmp/intermediate_pem_globalsign_ssl_dv_free_1.crt \
> /etc/caddy/certs/forbidden-stars.ru/fullchain.pem > /etc/caddy/certs/forbidden-stars.ru/fullchain.pem
cp /root/certs-tmp/forbidden-stars.key /etc/caddy/certs/forbidden-stars.ru/privkey.pem cp /root/certs-tmp/forbidden-stars.key /etc/caddy/certs/forbidden-stars.ru/privkey.pem
# то же для forbiddenstars.ru (свои crt/intermediate/key), затем права # то же для forbiddenstars.ru (свои crt/intermediate/key), затем права
@@ -87,43 +85,16 @@ rm -rf /root/certs-tmp
> как «промежуточную» часть. Если дал один файл, где leaf и chain уже вместе, — это и есть > как «промежуточную» часть. Если дал один файл, где leaf и chain уже вместе, — это и есть
> `fullchain.pem` (склейка не нужна). > `fullchain.pem` (склейка не нужна).
## 6. Caddyfile + страница-заглушка ## 6. Caddyfile
Скопируй `deploy/vps/Caddyfile` из репозитория в `/etc/caddy/Caddyfile` и положи страницу Скопируй `deploy/vps/Caddyfile` из репозитория в `/etc/caddy/Caddyfile`, затем:
«сайт недоступен» (показывается, когда туннель к хосту лежит — вместо зависшей «Загрузки» SPA):
```bash ```bash
mkdir -p /etc/caddy/maintenance
# с локальной машины:
scp deploy/vps/maintenance.html root@186.246.51.17:/etc/caddy/maintenance/maintenance.html
# на VPS:
caddy validate --config /etc/caddy/Caddyfile caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy systemctl reload caddy
``` ```
> Заглушка живёт в сниппете `(edge)` Caddyfile (там же security-заголовки и `no-store` для
> HTML): при ответе апстрима 502/503/504 Caddy отдаёт `/etc/caddy/maintenance/maintenance.html`
> со статусом 503 и `Cache-Control: no-store`. Для `/api/events` (SSE) у каждого домена
> отдельный `handle` без `encode` и с `flush_interval -1`.
> Правки текста/вида — в `deploy/vps/maintenance.html`, затем повтори `scp` (reload Caddy не нужен,
> файл читается на каждый запрос).
## 7. Проверка ## 7. Проверка
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev на ПК 1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК (`run.ps1` при `LOCAL_PUBLIC=vps`).
(лаунчер `run.ps1` при `LOCAL_PUBLIC=vps`).
2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`. 2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`.
3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки» 3. Пока соответствующий туннель не поднят — Caddy отдаёт `502`, это ожидаемо.
(HTTP 503), это ожидаемо.
Логи Caddy: `journalctl -u caddy -f`. Логи Caddy: `journalctl -u caddy -f`.
## 8. Приём бэкапов с Pi (оффсайт-копии)
Контейнер `backup` на Pi каждую ночь отправляет зашифрованный снимок (restic) на VPS по SFTP.
Для этого на VPS нужен отдельный пользователь `fsbackup` **только для SFTP**: без shell,
без туннелей, без пароля. Репозиторий лежит в `/srv/fs-backups/restic`, вне веб-корней
Caddy, так что бэкапы не публичны.
> Имя не `backup`: в Debian/Ubuntu системный пользователь `backup` уже существует.
Пошаговая настройка: создание пользователя, ключ, `sshd_config.d/60-fs-backup.conf`,
проверки — [`deploy/backup/README.md`, шаг 3](../backup/README.md#шаг-3-vps-пользователь-только-для-sftp).
Проверка приёма после первого бэкапа: `ls -la /srv/fs-backups/restic` (там `config`, `data`,
`index`, `keys`, `snapshots`). Старые снимки удаляет сам контейнер с Pi по политике хранения.
-64
View File
@@ -1,64 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<!-- Сама перепроверяется: когда хост вернётся, страница перезагрузится и пустит в приложение. -->
<meta http-equiv="refresh" content="20" />
<title>Сайт недоступен — Forbidden Stars</title>
<style>
:root {
--bg: #0f1115;
--surface: #171a21;
--border: #2a2f3a;
--text: #e8eaed;
--text-muted: #9aa0aa;
--accent: #e0533d;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
max-width: 420px;
width: 100%;
text-align: center;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.emoji { font-size: 56px; line-height: 1; margin-bottom: 14px; }
h1 { font-size: 22px; margin: 0 0 10px; }
p { color: var(--text-muted); font-size: 15px; line-height: 1.5; margin: 0 0 18px; }
.hint { font-size: 13px; color: var(--text-muted); }
.dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent);
margin-right: 6px;
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
</style>
</head>
<body>
<div class="card">
<div class="emoji">🍫</div>
<h1>Технические шоколадки</h1>
<p>Сайт временно недоступен — мы уже чиним. Загляните через минутку.</p>
<div class="hint"><span class="dot"></span>Страница обновится сама, как только всё заработает</div>
</div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More