Compare commits
197
Commits
fae400e5f4
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b33b442e9 | ||
|
|
d12afe62d0 | ||
|
|
30270916a4 | ||
|
|
2f0b22ccd4 | ||
|
|
89cbb81be7 | ||
|
|
f5f8dc03ce | ||
|
|
ec1e94119c | ||
|
|
8d4f7a95df | ||
|
|
5b89dc9d2f | ||
|
|
00bd3c72b6 | ||
|
|
f7f74fee8c | ||
|
|
643a9f1795 | ||
|
|
709b864c60 | ||
|
|
03834c6855 | ||
|
|
0ee7cd1274 | ||
|
|
1bc3940ee8 | ||
|
|
164db495b4 | ||
|
|
cff48f7cc7 | ||
|
|
bb5f2a1121 | ||
|
|
63a2cddd5e | ||
|
|
5035ee41e6 | ||
|
|
6c35cb04d4 | ||
|
|
63f541d61f | ||
|
|
7829a49e9d | ||
|
|
ec0445fbcb | ||
|
|
ab1fc3f00f | ||
|
|
a0a0e522ef | ||
|
|
2e496a3f21 | ||
|
|
526e4adf0c | ||
|
|
1e04f132b8 | ||
|
|
640f712037 | ||
|
|
1c391448c1 | ||
|
|
25c3c5ba4d | ||
|
|
6f54e0f9df | ||
|
|
ba69b9f21d | ||
|
|
171aebb6f8 | ||
|
|
adc47beb72 | ||
|
|
cc64bb4644 | ||
|
|
6ea4a67672 | ||
|
|
8aa6bb7235 | ||
|
|
e58b4f6614 | ||
|
|
e40d93e7be | ||
|
|
28846c561c | ||
|
|
af7d2b358d | ||
|
|
101c457f7e | ||
|
|
dafbf5bad4 | ||
|
|
63fd90e017 | ||
|
|
7579ca2d35 | ||
|
|
8b400b76c6 | ||
|
|
19f6068ddc | ||
|
|
d764ff86d3 | ||
|
|
e2d729d339 | ||
|
|
f73316f37f | ||
|
|
648c10f2a7 | ||
|
|
8f1518b0b3 | ||
|
|
37fe286db9 | ||
|
|
ec65f104ee | ||
|
|
ce7fbad58e | ||
|
|
0799aee684 | ||
|
|
112b583264 | ||
|
|
038a788f99 | ||
|
|
706eeb0af1 | ||
|
|
3f8667b561 | ||
|
|
d84cef2491 | ||
|
|
3587fc8af2 | ||
|
|
0cbee9d866 | ||
|
|
1056d81b87 | ||
|
|
c0e162df72 | ||
|
|
a09766c031 | ||
|
|
462079d835 | ||
|
|
c73b4519cb | ||
|
|
82cf7a3393 | ||
|
|
5efce76cd9 | ||
|
|
cb3af484cd | ||
|
|
65561155b4 | ||
|
|
7b3edcc7e9 | ||
|
|
b4663796a3 | ||
|
|
aabcc106d3 | ||
|
|
29a0e17fd1 | ||
|
|
a94de6492a | ||
|
|
bdd07e8103 | ||
|
|
4133c3c505 | ||
|
|
e0f1ed5fee | ||
|
|
634a4c0e08 | ||
|
|
f2aaa2a15e | ||
|
|
f3067b1f89 | ||
|
|
ea743ccac0 | ||
|
|
e5b3505bc2 | ||
|
|
0d81901a63 | ||
|
|
2986a58ef1 | ||
|
|
d41391eb69 | ||
|
|
763745a223 | ||
|
|
32d64c94af | ||
|
|
8be9a23c0c | ||
|
|
225e62fc1f | ||
|
|
a9de68f27c | ||
|
|
94e2c09952 | ||
|
|
8ac2757cd6 | ||
|
|
1831a3e033 | ||
|
|
c4b72cca39 | ||
|
|
62d75ea176 | ||
|
|
9d9e4a345a | ||
|
|
530cf3f4cd | ||
|
|
d0f01a4cc2 | ||
|
|
f031bd5e89 | ||
|
|
9dda7de067 | ||
|
|
be80891449 | ||
|
|
bca2d7a058 | ||
|
|
45f2b2007f | ||
|
|
1790e195e0 | ||
|
|
befabba752 | ||
|
|
60b22dd8df | ||
|
|
b3e4f71bda | ||
|
|
309e31a49a | ||
|
|
534ba640cc | ||
|
|
09be0f57c6 | ||
|
|
2a4c5d9d25 | ||
|
|
81ad2a0403 | ||
|
|
5e4e4835f2 | ||
|
|
c8d5efea65 | ||
|
|
9ce501dd11 | ||
|
|
e7323cc3c4 | ||
|
|
b349891bdb | ||
|
|
3023e9f515 | ||
|
|
376605f554 | ||
|
|
4b7d8678ce | ||
|
|
5c3aa6ba69 | ||
|
|
1dbff7ae58 | ||
|
|
48a46ad57d | ||
|
|
93d81010f7 | ||
|
|
8a2b53d0b2 | ||
|
|
f22ee1ec29 | ||
|
|
4a681430af | ||
|
|
45bdadece9 | ||
|
|
ebb368686d | ||
|
|
ddbcd74058 | ||
|
|
e5b7420bae | ||
|
|
fc1b1fd912 | ||
|
|
dbc32b7f4d | ||
|
|
ac296a8f12 | ||
|
|
c4f75d5de0 | ||
|
|
378094c366 | ||
|
|
49864db87e | ||
|
|
28852f9d38 | ||
|
|
ca96073ccf | ||
|
|
e38555b74d | ||
|
|
002c657855 | ||
|
|
7b61443fb9 | ||
|
|
d2c0feafcc | ||
|
|
cbf3cfc6e6 | ||
|
|
d81f5d8964 | ||
|
|
029a043bfc | ||
|
|
c544f72063 | ||
|
|
886ff5d4e2 | ||
|
|
1a61b05923 | ||
|
|
b3db5a4047 | ||
|
|
32c74abc43 | ||
|
|
1e19038b7c | ||
|
|
f56f9eb47b | ||
|
|
6d75b4c130 | ||
|
|
75feb9d15a | ||
|
|
0745d07887 | ||
|
|
cec3aa25a6 | ||
|
|
712dc529c3 | ||
|
|
395b134c9b | ||
|
|
7269afcd4e | ||
|
|
4d365a373b | ||
|
|
1438a17441 | ||
|
|
cd7e5d16b4 | ||
|
|
c39c96d5dc | ||
|
|
28c0358b45 | ||
|
|
d32237975d | ||
|
|
460b57e792 | ||
|
|
6196c39d25 | ||
|
|
8f4a58d33d | ||
|
|
f4286ffa6c | ||
|
|
ef5c242e16 | ||
|
|
2500ca575b | ||
|
|
16452f7bbe | ||
|
|
eb34714868 | ||
|
|
9c4535e8c1 | ||
|
|
043595beff | ||
|
|
8ac1b5acef | ||
|
|
a466ae9868 | ||
|
|
020c694a44 | ||
|
|
1cb2fb2bea | ||
|
|
87c7f48a3e | ||
|
|
366742d6da | ||
|
|
a5c54512a5 | ||
|
|
66370c7df4 | ||
|
|
f337ff27b2 | ||
|
|
e3a78301c2 | ||
|
|
c4065962e2 | ||
|
|
96daddae05 | ||
|
|
b302ec769a | ||
|
|
3291c67b2c | ||
|
|
865c967d5e |
+16
-6
@@ -1,29 +1,39 @@
|
||||
# Шаблоны отсчитываются от корня контекста: без «**/» правило ловит только корень
|
||||
# (так `data/` пропускал backend/data/dev/ с dev-БД и загрузками в образ, #71).
|
||||
|
||||
# Python
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.egg-info/
|
||||
backend/.venv/
|
||||
backend/.pytest_cache/
|
||||
backend/*.db
|
||||
backend/*.db-wal
|
||||
backend/*.db-shm
|
||||
backend/openapi.json
|
||||
|
||||
# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и
|
||||
# локальные копии бэкапов — ни в образ, ни в контекст сборки
|
||||
backend/data/
|
||||
data/
|
||||
backups/
|
||||
**/*.db
|
||||
**/*.db-wal
|
||||
**/*.db-shm
|
||||
|
||||
# Node / сборка фронта
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# DEV-вход (по нику) физически НЕ попадает в прод-образ
|
||||
# DEV-код физически НЕ попадает в прод-образ (вход по нику, удаление аккаунтов)
|
||||
backend/app/auth/dev_stub.py
|
||||
backend/app/routers/dev_auth.py
|
||||
backend/app/routers/dev_admin.py
|
||||
|
||||
# Тесты и dev-манифест зависимостей в прод-образе не нужны (ставим из requirements.txt)
|
||||
backend/tests/
|
||||
backend/pyproject.toml
|
||||
|
||||
# Прочее
|
||||
.env
|
||||
data/
|
||||
**/.env
|
||||
**/.DS_Store
|
||||
*.md
|
||||
.claude/
|
||||
|
||||
+91
-16
@@ -1,13 +1,12 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Forbidden Stars — единый .env (dev / test / prod)
|
||||
# Forbidden Stars — единый .env (dev / prod)
|
||||
# Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт.
|
||||
# Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC.
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
||||
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
||||
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
|
||||
# test — прод-клон в Docker локально (порт 8080), вход только TG
|
||||
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, есть вход по нику (stub)
|
||||
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
||||
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
||||
APP_ENV=development
|
||||
@@ -15,54 +14,130 @@ APP_ENV=development
|
||||
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
||||
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
||||
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
||||
# TEST и PROD выставляют себя сами через туннель-КОНТЕЙНЕР (docker-compose*.yml) — им
|
||||
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже они тоже читают.
|
||||
# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля,
|
||||
# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY
|
||||
# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69).
|
||||
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
||||
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
||||
LOCAL_PUBLIC=local
|
||||
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер test/prod.
|
||||
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер прода.
|
||||
# Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS.
|
||||
VPS_TUNNEL_HOST=186.246.51.17
|
||||
VPS_TUNNEL_USER=tunnel
|
||||
VPS_TUNNEL_PORT=9001 # слот VPS для dev-туннеля (forbidden-stars.ru); контейнеры: prod=9000, test=9001
|
||||
# VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию:
|
||||
# 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) ──────────────
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-admin-password
|
||||
ADMIN_NICKNAME=Администратор
|
||||
ADMIN_BOOTSTRAP_ENABLED=true
|
||||
|
||||
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
|
||||
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
|
||||
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
||||
# Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля).
|
||||
# Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
||||
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
||||
# и forbidden-stars.ru (dev/test).
|
||||
# и forbidden-stars.ru (dev).
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_BOT_USERNAME=
|
||||
# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev/test https://forbidden-stars.ru.
|
||||
# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev https://forbidden-stars.ru.
|
||||
PUBLIC_BASE_URL=
|
||||
|
||||
# ─── БЕЗОПАСНОСТЬ / СЕССИИ ────────────────────────────────────────────────────
|
||||
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
||||
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
||||
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
||||
# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод.
|
||||
SECRET_KEY=change-me-dev-secret-not-for-production
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_USER_TTL_MINUTES=10080
|
||||
JWT_ADMIN_TTL_MINUTES=480
|
||||
# COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie):
|
||||
# dev+localhost → false; dev через VPS, test, prod → true. Вручную задавать НЕ нужно.
|
||||
# dev+localhost → false; dev через VPS, prod → true. Вручную задавать НЕ нужно.
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ────────────
|
||||
# dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL
|
||||
# (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml).
|
||||
# dev → DEV_DATABASE_URL (файл в ./data/dev/); prod → PROD_DATABASE_URL
|
||||
# (том /data контейнера, см. docker-compose*.yml).
|
||||
DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db
|
||||
PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db
|
||||
|
||||
# Каталог загрузок (зарезервировано под фото/видео), тоже раздельно.
|
||||
# Каталог загрузок (фото партий), раздельно для dev и prod.
|
||||
DEV_UPLOAD_DIR=./data/dev/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
|
||||
|
||||
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
||||
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
||||
# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
|
||||
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
|
||||
# пояс устройства игрока не учитывается.
|
||||
APP_TZ_OFFSET_HOURS=3
|
||||
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
+1
-16
@@ -1,20 +1,5 @@
|
||||
# ── Нормализация переводов строк ──────────────────────────────────────────────
|
||||
# Шелл-скрипты обязаны быть с LF: в Linux-контейнере и на Pi CRLF ломает shebang.
|
||||
# entrypoint.sh контейнер чинит сам (sed в Dockerfile), но backup.sh запускается
|
||||
# с хоста Pi — для него LF в репозитории критичен.
|
||||
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
|
||||
*.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
|
||||
|
||||
+7
-2
@@ -15,7 +15,7 @@ venv/
|
||||
data/
|
||||
backend/dev.db*
|
||||
|
||||
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod)
|
||||
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/prod)
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
@@ -23,6 +23,7 @@ backend/dev.db*
|
||||
*.pem
|
||||
*.key
|
||||
id_tunnel*
|
||||
id_backup*
|
||||
deploy/**/certs/
|
||||
|
||||
# Node / фронт
|
||||
@@ -34,11 +35,15 @@ frontend/.vite/
|
||||
# Сгенерированный снапшот OpenAPI (контракт фронта закоммичен в schema.d.ts)
|
||||
backend/openapi.json
|
||||
|
||||
# Бэкапы (создаёт scripts/backup.sh на Pi)
|
||||
# Бэкапы, скачанные на ПК (scripts/fs-backup.* pull) — не зашифрованы, в git не идут
|
||||
backups/
|
||||
|
||||
# Фронт-макеты для проработки UI (локальные прототипы, не для репозитория)
|
||||
mockups/
|
||||
|
||||
# AI-ассистенты (локальные, в репозиторий не идут)
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.claude/
|
||||
|
||||
# Редактор / ОС
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ COPY --from=frontend /app/frontend/dist /app/static
|
||||
RUN sed -i 's/\r$//' /app/entrypoint.sh \
|
||||
&& chmod +x /app/entrypoint.sh \
|
||||
&& useradd --create-home --uid 10001 appuser \
|
||||
&& mkdir -p /data/uploads \
|
||||
&& mkdir -p /data/uploads /data/achievements \
|
||||
&& chown -R appuser:appuser /app /data
|
||||
USER appuser
|
||||
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
# Forbidden Stars — учёт партий
|
||||
|
||||
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
|
||||
профили игроков (вход через Telegram, пока — dev-заглушка), группы, создание партий
|
||||
с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель.
|
||||
профили игроков (вход по логину и паролю или через Telegram), группы с приглашениями,
|
||||
создание партий с рандомом фракций и фильтром по дополнениям группы, фото партий,
|
||||
уведомления, статистика и общий топ, админ-панель.
|
||||
|
||||
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
|
||||
- **Фронтенд:** React · Vite · TypeScript (SPA, общается с ядром только по REST API)
|
||||
- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker
|
||||
- **Фронтенд:** React · Vite · TypeScript (SPA; с ядром общается по REST API, живые
|
||||
обновления приходят SSE-потоком `/api/events`)
|
||||
- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker; наружу — через VPS-привратник (см. «Домен и публикация»)
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
backend/ FastAPI: ядро, REST API, БД, миграции, seed
|
||||
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
|
||||
frontend/ React + Vite SPA
|
||||
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
|
||||
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК)
|
||||
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
||||
docker-compose.yml
|
||||
.env.example
|
||||
docker-compose.yml прод на Pi: app + tunnel + backup
|
||||
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
||||
run.ps1 / run.sh единый лаунчер dev
|
||||
.env.example шаблон единого .env
|
||||
```
|
||||
|
||||
## Локальная разработка
|
||||
|
||||
Всё проверяется в `development` — отдельного тестового контейнера нет.
|
||||
|
||||
### Единый лаунчер (`run.ps1` / `run.sh`)
|
||||
|
||||
После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно,
|
||||
решает `APP_ENV` в корневом `.env`:
|
||||
После разовой настройки (ниже) dev запускается **одной командой** (лаунчер читает
|
||||
`APP_ENV` в корневом `.env`):
|
||||
|
||||
```powershell
|
||||
.\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh)
|
||||
@@ -31,9 +39,9 @@ docker-compose.yml
|
||||
|
||||
| `APP_ENV` в `.env` | что делает лаунчер |
|
||||
|---|---|
|
||||
| `development` | `uvicorn --reload` (бэк) + `vite` (фронт) нативно, в двух окнах |
|
||||
| `test` | `docker compose` прод-клон на :8080 (со сборкой образа) |
|
||||
| `production` | не запускает — прод деплоится отдельно (см. «Git и деплой») |
|
||||
| `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` |
|
||||
| `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») |
|
||||
| другое значение | отказ: допустимы только `development` и `production` (бэкенд тоже не стартует) |
|
||||
|
||||
Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта
|
||||
(после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд.
|
||||
@@ -47,9 +55,9 @@ python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1 # если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
||||
pip install -e ".[dev]" # .[dev] — один аргумент (пакет + dev-зависимости)
|
||||
Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория
|
||||
alembic upgrade head # применит миграции и сидинг
|
||||
python -m app.bootstrap # создаст/синхронизирует администратора из .env
|
||||
uvicorn app.main:app --reload # http://localhost:8000 (Swagger: /api/docs)
|
||||
alembic upgrade head # применит миграции и сидинг справочников
|
||||
python -m app.bootstrap # справочники + создаст/синхронизирует администратора из .env (опционально)
|
||||
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs)
|
||||
```
|
||||
|
||||
**Windows cmd.exe** (здесь `&&` поддерживается):
|
||||
@@ -61,7 +69,7 @@ pip install -e ".[dev]"
|
||||
copy ..\.env.example ..\.env
|
||||
alembic upgrade head
|
||||
python -m app.bootstrap
|
||||
uvicorn app.main:app --reload
|
||||
uvicorn app.main:app --reload --timeout-graceful-shutdown 2
|
||||
```
|
||||
|
||||
**Linux / macOS / Git Bash:**
|
||||
@@ -72,9 +80,17 @@ pip install -e ".[dev]"
|
||||
cp ../.env.example ../.env
|
||||
alembic upgrade head
|
||||
python -m app.bootstrap
|
||||
uvicorn app.main:app --reload
|
||||
uvicorn app.main:app --reload --timeout-graceful-shutdown 2
|
||||
```
|
||||
|
||||
> В 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`.
|
||||
> Его читают и бэкенд (через абсолютный путь, независимо от рабочей папки), и `docker compose`.
|
||||
> Файл — локальный, на каждой машине свой (dev/prod различаются строкой `APP_ENV`).
|
||||
@@ -87,100 +103,117 @@ npm run gen:api # сгенерирует типы из живог
|
||||
npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека)
|
||||
```
|
||||
|
||||
Вход в dev-режиме — экран `/login`: в деве доступны оба метода (Telegram + вход по нику),
|
||||
в проде — только Telegram (см. раздел «Аутентификация»).
|
||||
Вход в dev-режиме — экран `/login`: логин/пароль, Telegram и вход по нику без пароля (stub);
|
||||
в проде stub нет (см. раздел «Аутентификация»).
|
||||
|
||||
## 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
|
||||
cp .env.example .env # заполните SECRET_KEY, ADMIN_PASSWORD и пр.
|
||||
docker compose build # на ARM64 собирается нативно
|
||||
docker compose up -d # приложение на :8000, БД на томе
|
||||
# Pi, папка с docker-compose.yml и .env
|
||||
docker compose up -d # pull_policy: always — тянет свежие образы, без сборки
|
||||
```
|
||||
|
||||
FastAPI отдаёт собранный SPA и API с одного origin. Миграции и бутстрап админа
|
||||
выполняются автоматически при старте (`entrypoint.sh`).
|
||||
Портов на хост нет — прод доступен только на `https://forbiddenstars.ru` через
|
||||
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
|
||||
справочников и создание админа выполняются автоматически при старте (`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 — `$` дословно),
|
||||
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
|
||||
и docker compose (prod — `$` = подстановка переменной). Чтобы значение совпадало
|
||||
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
||||
`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`):
|
||||
|
||||
| | dev | test / prod |
|
||||
| | dev | prod |
|
||||
|---|---|---|
|
||||
| Telegram Login Widget | ✓ | ✓ (единственный) |
|
||||
| Вход по нику (stub) | ✓ | ✗ (физически отсутствует) |
|
||||
| Логин (= ник) и пароль | ✓ | ✓ (основной) |
|
||||
| Telegram Login Widget | ✓ | ✓ |
|
||||
| Вход по нику без пароля (stub) | ✓ | ✗ (физически отсутствует) |
|
||||
|
||||
- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
|
||||
файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
|
||||
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
|
||||
(`app/main.py`), а на фронте dev-блок вырезается из прод-сборки (`import.meta.env.DEV`).
|
||||
- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`).
|
||||
`GET /api/auth/config` отдаёт доступные методы и `telegram_bot_username` для виджета.
|
||||
- **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока
|
||||
(смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt.
|
||||
От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин», 20 на IP и
|
||||
50 на аккаунт (независимо от IP), дальше `429 TOO_MANY_ATTEMPTS`. Регистраций — не больше
|
||||
10 с одного IP за окно. Игрок без пароля (из Telegram или созданный до паролей) после входа
|
||||
видит обязательное окно «Задайте пароль»: закрыть его нельзя, только задать пароль или
|
||||
выйти; в 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 (когда будете подключать реальный вход):**
|
||||
1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**.
|
||||
2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev/test).
|
||||
2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev).
|
||||
3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`).
|
||||
4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает.
|
||||
|
||||
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях.
|
||||
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех
|
||||
окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд.
|
||||
|
||||
## Окружения (dev / test / prod)
|
||||
## Окружения (dev / prod)
|
||||
|
||||
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер):
|
||||
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env`. Допустимы только
|
||||
`development` и `production` — с любым другим значением бэкенд не стартует:
|
||||
|
||||
| | dev | test (прод-клон локально) | prod (Pi) |
|
||||
|---|---|---|---|
|
||||
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` |
|
||||
| `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) |
|
||||
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
|
||||
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
|
||||
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
|
||||
| Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram |
|
||||
| | dev | prod (Pi) |
|
||||
|---|---|---|
|
||||
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `docker compose up -d` |
|
||||
| `APP_ENV` | `development` | `production` (форсится в compose) |
|
||||
| Env-файл | единый `.env` | единый `.env` (на Pi) |
|
||||
| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbiddenstars.ru` |
|
||||
| База данных | `backend/data/dev/…` | том `db-data` (`/data`) |
|
||||
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram |
|
||||
| Swagger (`/api/docs`) | ✓ | ✗ |
|
||||
| Fail-fast по дефолтным секретам | ✗ | ✓ |
|
||||
|
||||
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
|
||||
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
|
||||
`production`. Отдельного `.env.test` больше нет.
|
||||
- **Один `.env` на машину** в корне (рядом с `.env.example`). Прод-контейнер значение
|
||||
`APP_ENV` из него **игнорирует** и всегда `production`.
|
||||
- **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL`
|
||||
(`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это
|
||||
РАЗНЫЕ тома). Данные дева в образ **не попадают** (`data/` в `.dockerignore`).
|
||||
- **В Docker идёт только прод-код:** dev-вход (stub) и тесты физически исключены из образа
|
||||
(`.dockerignore`); `test` — тот же образ, что и прод, просто локально и с `APP_ENV=test`.
|
||||
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
|
||||
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
|
||||
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
|
||||
исключены из образа (`.dockerignore`). Данные дева (`backend/data/`), любые файлы SQLite
|
||||
и локальные бэкапы (`backups/`) в образ тоже не попадают.
|
||||
|
||||
## Git и деплой
|
||||
|
||||
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь.
|
||||
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и проверка здесь.
|
||||
- Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во
|
||||
всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный;
|
||||
чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов.
|
||||
- **Деплой на Pi:** `git pull` ветки `main` → `docker compose up -d --build`.
|
||||
- **Чистая выгрузка в папку без git** (опц.): `scripts/export-prod.sh <dir> main` (через
|
||||
`git archive` + `export-ignore` — в папку идёт ровно прод-набор, без лаунчера/тестов/dev-входа).
|
||||
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
|
||||
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
||||
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
||||
|
||||
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
||||
|
||||
@@ -188,21 +221,32 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
||||
|
||||
Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а
|
||||
приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT).
|
||||
У **test/prod** туннель — **отдельный контейнер** в их `docker-compose`, и портов на хост
|
||||
они не публикуют (доступны только через домен):
|
||||
У **прода** туннель — **отдельный контейнер** в `docker-compose.yml`, и портов на хост он
|
||||
не публикует (доступен только через домен):
|
||||
|
||||
| | домен | как выставляется | слот VPS |
|
||||
|---|---|---|---|
|
||||
| prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 |
|
||||
| test | `forbidden-stars.ru` | туннель-контейнер → `app:8000` | 9001 |
|
||||
| dev | `forbidden-stars.ru` | `run.ps1` при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
|
||||
| dev | `forbidden-stars.ru` | лаунчер (`run.ps1`/`run.sh`) при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
|
||||
|
||||
- Прод (9000) и dev/test (9001) на **разных слотах/доменах** → прод и (dev|test) работают
|
||||
одновременно. Dev и test делят слот 9001 → по очереди.
|
||||
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + `run.ps1` выставляет его на домен.
|
||||
- Прод (9000) и dev (9001) на **разных слотах/доменах** → работают одновременно. Временный
|
||||
прод на ПК (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать.
|
||||
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен.
|
||||
В dev при этом открыты stub-вход по нику и Swagger — держите туннель поднятым только на
|
||||
время проверки (задача #69).
|
||||
- `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`),
|
||||
`pi/` (app + туннель в Docker), образ туннеля — `deploy/tunnel/`. Ключ — `deploy/tunnel/id_tunnel`.
|
||||
`pi/` (app + туннель + бэкапы в Docker), образ туннеля — `deploy/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).
|
||||
|
||||
## Дополнения и фракции
|
||||
|
||||
@@ -213,4 +257,6 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
||||
| Forsaken Voids | Инквизиция, Сёстры битвы, Друкхари, Адептус Механикус |
|
||||
|
||||
Группа отмечает имеющиеся дополнения; при создании партии доступны только фракции
|
||||
этих дополнений (база — всегда).
|
||||
этих дополнений (база — всегда). Справочник сидится из `backend/app/seed/reference_data.py`.
|
||||
Админ может переименовать фракцию в панели, но сейчас переименование откатывается при
|
||||
каждом перезапуске приложения (задача #72).
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Кастомизация профиля: о себе (bio) и аватар (avatar_path).
|
||||
|
||||
Идемпотентна: на свежей БД (0001 создаёт схему из актуальных моделей через create_all)
|
||||
столбцы уже есть → no-op; на существующей БД — добавляет столбцы. render_as_batch включён
|
||||
в env.py (нужен для ALTER в SQLite).
|
||||
|
||||
Revision ID: 0004_user_profile
|
||||
Revises: 0003_match_lifecycle
|
||||
Create Date: 2026-06-17
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004_user_profile"
|
||||
down_revision: Union[str, None] = "0003_match_lifecycle"
|
||||
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")}
|
||||
|
||||
add = []
|
||||
if "bio" not in cols:
|
||||
add.append(sa.Column("bio", sa.Text(), nullable=True))
|
||||
if "avatar_path" not in cols:
|
||||
add.append(sa.Column("avatar_path", sa.String(255), nullable=True))
|
||||
|
||||
if add:
|
||||
with op.batch_alter_table("users") as b:
|
||||
for col in add:
|
||||
b.add_column(col)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("users") as b:
|
||||
for name in ("avatar_path", "bio"):
|
||||
try:
|
||||
b.drop_column(name)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Задел под титулы/ачивки: users.title_achievement_slug + таблица user_achievements.
|
||||
|
||||
Сами определения ачивок хранятся файлами (settings.achievements_dir), в БД — только
|
||||
будущая выдача игрокам. Идемпотентна (как 0003/0004): на свежей БД (0001 = create_all из
|
||||
актуальных моделей) столбец/таблица уже есть → no-op; на существующей — добавляет.
|
||||
|
||||
Revision ID: 0005_achievements
|
||||
Revises: 0004_user_profile
|
||||
Create Date: 2026-06-17
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005_achievements"
|
||||
down_revision: Union[str, None] = "0004_user_profile"
|
||||
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 "title_achievement_slug" not in cols:
|
||||
with op.batch_alter_table("users") as b:
|
||||
b.add_column(sa.Column("title_achievement_slug", sa.String(64), nullable=True))
|
||||
|
||||
if "user_achievements" not in insp.get_table_names():
|
||||
op.create_table(
|
||||
"user_achievements",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("achievement_slug", sa.String(64), nullable=False),
|
||||
sa.Column("earned_at", sa.DateTime(), nullable=False),
|
||||
sa.UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_user_achievements_user_id", "user_achievements", ["user_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
if "user_achievements" in insp.get_table_names():
|
||||
op.drop_table("user_achievements")
|
||||
with op.batch_alter_table("users") as b:
|
||||
try:
|
||||
b.drop_column("title_achievement_slug")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Приглашения в группу: таблица 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")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Медиа партии: таблица 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")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Уведомления: таблица 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")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Участник партии: признак выбытия (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")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Пользователь: любимая фракция (личный выбор в профиле).
|
||||
|
||||
Идемпотентна: на свежей БД столбец создаёт 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")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Пользователь: настройки витрины истории партий в профиле.
|
||||
|
||||
Идемпотентна: на свежей БД столбцы создаёт 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")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Партия: общий черновик формы завершения (совместное заполнение результатов).
|
||||
|
||||
Идемпотентна: на свежей БД таблицу создаёт 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")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Пользователь: версия сессий (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")
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Рейтинг (#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")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Объявления администрации (#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")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Слияние веток миграций: рейтинг (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
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Вход администратора под защитой от перебора (#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
|
||||
@@ -21,9 +21,14 @@ def get_current_user(
|
||||
payload = security.decode_token(token, security.AUDIENCE_USER)
|
||||
except jwt.PyJWTError as exc: # noqa: F841
|
||||
raise AuthError("Сессия недействительна.")
|
||||
if security.is_session_revoked(payload):
|
||||
raise AuthError("Сессия недействительна.")
|
||||
user = session.get(User, int(payload["sub"]))
|
||||
if user is None or not user.is_active:
|
||||
raise AuthError("Сессия недействительна.")
|
||||
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||
# Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57).
|
||||
raise AuthError("Сессия недействительна.")
|
||||
return user
|
||||
|
||||
|
||||
@@ -37,7 +42,11 @@ def get_current_admin(
|
||||
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
||||
except jwt.PyJWTError:
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
if security.is_session_revoked(payload):
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
user = session.get(User, int(payload["sub"]))
|
||||
if user is None or user.role != "admin" or not user.is_active:
|
||||
raise ForbiddenError("Доступ только для администратора.")
|
||||
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||
raise AuthError("Сессия администратора недействительна.")
|
||||
return user
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
|
||||
"""Dev-провайдер: вход без секрета по нику/идентификатору (только development)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Общий вход: внешняя личность → пользователь → сессия.
|
||||
|
||||
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
|
||||
и dev-входом.
|
||||
Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через
|
||||
Telegram, /auth/login, /auth/register и dev-вход.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,27 +10,36 @@ from sqlmodel import Session
|
||||
|
||||
from app.auth.provider import ExternalIdentity
|
||||
from app.core import security
|
||||
from app.core.security import client_ip
|
||||
from app.core.errors import ForbiddenError
|
||||
from app.models import User
|
||||
from app.services import audit_service, user_service
|
||||
|
||||
|
||||
def login_with_identity(
|
||||
session: Session, response: Response, request: Request, identity: ExternalIdentity
|
||||
def establish_session(
|
||||
session: Session, response: Response, request: Request, user: User, provider: str
|
||||
) -> User:
|
||||
user = user_service.get_or_create_from_identity(session, identity)
|
||||
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
||||
if not user.is_active:
|
||||
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
||||
security.set_user_session(response, user.id, identity.provider) # type: ignore[arg-type]
|
||||
security.set_user_session(response, user.id, provider, user.token_version) # type: ignore[arg-type]
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="login",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
payload={"provider": identity.provider},
|
||||
ip=request.client.host if request.client else None,
|
||||
payload={"provider": provider},
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def login_with_identity(
|
||||
session: Session, response: Response, request: Request, identity: ExternalIdentity
|
||||
) -> User:
|
||||
"""Вход с авто-созданием (dev-вход по нику): ник подбирается автоматически."""
|
||||
user = user_service.get_or_create_from_identity(session, identity)
|
||||
return establish_session(session, response, request, user, identity.provider)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Вход игрока по логину (нику) и паролю.
|
||||
|
||||
Прод-модуль: основной способ входа во всех окружениях. НЕ путать с 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)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Доступные методы входа по окружению.
|
||||
|
||||
Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG).
|
||||
Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development.
|
||||
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -9,7 +9,7 @@ from app.core.config import settings
|
||||
|
||||
|
||||
def enabled_methods() -> list[str]:
|
||||
methods = ["telegram"]
|
||||
methods = ["password", "telegram"]
|
||||
if settings.is_development:
|
||||
methods.append("stub")
|
||||
return methods
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
||||
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
||||
Доступен и в dev, и в prod (в prod — единственный метод входа).
|
||||
Доступен во всех окружениях — наряду со входом по логину и паролю.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
||||
|
||||
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
||||
Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -11,6 +14,7 @@ from app.core.security import hash_password, verify_password
|
||||
from app.db.session import engine
|
||||
from app.models import User
|
||||
from app.seed.reference_data import seed_reference_data
|
||||
from app.services import user_service
|
||||
|
||||
|
||||
def _ensure_admin(session: Session) -> None:
|
||||
@@ -38,7 +42,9 @@ def _ensure_admin(session: Session) -> None:
|
||||
|
||||
# Администратор уже существует.
|
||||
if not settings.is_development:
|
||||
# В test/prod пароль НЕ перезаписываем (мог быть изменён через панель).
|
||||
# В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама
|
||||
# по себе ничего не делает — применить её можно только осознанно, командой
|
||||
# ротации (reset_admin_password), которая заодно отзывает админские сессии.
|
||||
return
|
||||
|
||||
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
||||
@@ -55,11 +61,48 @@ def _ensure_admin(session: Session) -> None:
|
||||
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:
|
||||
with Session(engine) as session:
|
||||
seed_reference_data(session) # идемпотентно
|
||||
_ensure_admin(session)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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__":
|
||||
main()
|
||||
|
||||
+91
-19
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
||||
@@ -11,6 +12,15 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||
_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):
|
||||
model_config = SettingsConfigDict(
|
||||
@@ -20,20 +30,21 @@ class Settings(BaseSettings):
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# ── Главный переключатель окружения: development | test | production ───────
|
||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
|
||||
# test — прод-клон в Docker локально (порт 8080), ведёт себя как прод.
|
||||
# ── Главный переключатель окружения: development | production ─────────────
|
||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, есть stub-вход по нику.
|
||||
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# Публикация локального окружения (dev/test) наружу через VPS-туннель.
|
||||
# Публикация локального dev-окружения наружу через VPS-туннель.
|
||||
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
||||
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
|
||||
# на forbidden-stars.ru. Приложению значение говорит, опубликовано ли оно (is_published):
|
||||
# от этого зависят Secure-cookie и проверка секретов при старте.
|
||||
local_public: str = "local"
|
||||
|
||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
||||
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
|
||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда
|
||||
# в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
|
||||
# на фронте — оно приходит туда в GET /api/auth/config (#68).
|
||||
app_tz_offset_hours: int = 3
|
||||
|
||||
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
||||
@@ -45,16 +56,20 @@ class Settings(BaseSettings):
|
||||
dev_upload_dir: str = "./data/dev/uploads"
|
||||
prod_upload_dir: str = "/data/uploads"
|
||||
|
||||
# Каталог ачивок (папка на ачивку: meta.json, condition.py, icon.*), раздельно.
|
||||
dev_achievements_dir: str = "./data/dev/achievements"
|
||||
prod_achievements_dir: str = "/data/achievements"
|
||||
|
||||
# JWT / cookie
|
||||
secret_key: str = "change-me-dev-secret-not-for-production"
|
||||
secret_key: str = _DEFAULT_SECRET_KEY
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
||||
jwt_admin_ttl_minutes: int = 60 * 8
|
||||
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
|
||||
cookie_domain: str | None = None
|
||||
|
||||
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub,
|
||||
# prod: только telegram) — отдельного переключателя провайдера нет.
|
||||
# Аутентификация. Методы входа определяются окружением (auth/registry.py): везде
|
||||
# логин/пароль + Telegram, в development ещё stub — отдельного переключателя нет.
|
||||
telegram_bot_token: str | None = None
|
||||
telegram_bot_username: str | None = None
|
||||
public_base_url: str | None = None
|
||||
@@ -62,7 +77,7 @@ class Settings(BaseSettings):
|
||||
# Бутстрап администратора
|
||||
admin_bootstrap_enabled: bool = True
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "change-me-admin-password"
|
||||
admin_password: str = _DEFAULT_ADMIN_PASSWORD
|
||||
admin_nickname: str = "Администратор"
|
||||
|
||||
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
||||
@@ -80,17 +95,13 @@ class Settings(BaseSettings):
|
||||
стартовый bootstrap в lifespan и синхронизацию админа из .env."""
|
||||
return self.app_env.lower() == "development"
|
||||
|
||||
@property
|
||||
def is_test(self) -> bool:
|
||||
return self.app_env.lower() == "test"
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env.lower() == "production"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""БД: dev — отдельный файл дева; test и prod — том контейнера (/data)."""
|
||||
"""БД: dev — отдельный файл дева; prod — том контейнера (/data)."""
|
||||
return self.dev_database_url if self.is_development else self.prod_database_url
|
||||
|
||||
@property
|
||||
@@ -98,15 +109,76 @@ class Settings(BaseSettings):
|
||||
return self.dev_upload_dir if self.is_development else self.prod_upload_dir
|
||||
|
||||
@property
|
||||
def cookie_secure(self) -> bool:
|
||||
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
|
||||
нативный dev на localhost по HTTP (development + local_public=local)."""
|
||||
def achievements_dir(self) -> str:
|
||||
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
|
||||
def cookie_secure(self) -> bool:
|
||||
"""Secure-cookie нужен везде, где снаружи HTTPS (домен), — у опубликованного
|
||||
приложения. На localhost по HTTP браузер Secure-cookie не вернул бы."""
|
||||
return self.is_published
|
||||
|
||||
@property
|
||||
def cookie_domain_value(self) -> str | 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
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -94,6 +94,20 @@ class NicknameTakenError(ConflictError):
|
||||
super().__init__("Никнейм уже занят.")
|
||||
|
||||
|
||||
class TelegramNicknameRequiredError(ConflictError):
|
||||
"""Первый вход через Telegram, но предлагаемый ник (тег) занят/некорректен —
|
||||
фронт должен показать выбор ника и повторить регистрацию через /telegram/register.
|
||||
`suggested` идёт в details для предзаполнения поля."""
|
||||
|
||||
code = "TELEGRAM_NICKNAME_REQUIRED"
|
||||
|
||||
def __init__(self, suggested: str, message: str | None = None) -> None:
|
||||
super().__init__(
|
||||
message or f"Ник «{suggested}» уже занят — выберите другой.",
|
||||
details={"suggested": suggested},
|
||||
)
|
||||
|
||||
|
||||
class FactionNotAvailableError(ValidationError):
|
||||
code = "FACTION_NOT_AVAILABLE"
|
||||
|
||||
@@ -122,5 +136,41 @@ class InvalidCredentialsError(AuthError):
|
||||
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:
|
||||
return exc.to_response()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
|
||||
|
||||
Счётчики живут в памяти процесса — как и 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()
|
||||
@@ -6,9 +6,10 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Response
|
||||
from fastapi import Request, Response
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.token_revocation import revoked_tokens
|
||||
|
||||
USER_COOKIE = "fs_session"
|
||||
ADMIN_COOKIE = "fs_admin"
|
||||
@@ -37,7 +38,13 @@ def verify_password(password: str, password_hash: str) -> bool:
|
||||
|
||||
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str:
|
||||
def create_token(
|
||||
subject: str | int,
|
||||
audience: str,
|
||||
ttl_minutes: int,
|
||||
provider: str = "",
|
||||
token_version: int = 0,
|
||||
) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(subject),
|
||||
@@ -46,6 +53,9 @@ def create_token(subject: str | int, audience: str, ttl_minutes: int, provider:
|
||||
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
||||
"jti": secrets.token_hex(8),
|
||||
"provider": provider,
|
||||
# Версия сессий владельца: при её росте (смена/сброс пароля) старые токены
|
||||
# с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57).
|
||||
"ver": token_version,
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
@@ -65,12 +75,19 @@ def generate_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def _set_csrf_cookie(response: Response, max_age: int) -> str:
|
||||
def _csrf_max_age() -> int:
|
||||
"""Токен один на обе сессии, поэтому живёт не меньше самой долгой из них: иначе вход
|
||||
в админку (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()
|
||||
response.set_cookie(
|
||||
key=CSRF_COOKIE,
|
||||
value=csrf,
|
||||
max_age=max_age,
|
||||
max_age=_csrf_max_age(),
|
||||
httponly=False, # должен читаться JS, чтобы продублировать в заголовок
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
@@ -80,9 +97,17 @@ def _set_csrf_cookie(response: Response, max_age: int) -> str:
|
||||
return csrf
|
||||
|
||||
|
||||
def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||
def fresh_csrf_set_cookie() -> tuple[bytes, bytes]:
|
||||
"""Готовый заголовок 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
|
||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
|
||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version)
|
||||
response.set_cookie(
|
||||
key=USER_COOKIE,
|
||||
value=token,
|
||||
@@ -93,12 +118,12 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||
path=_USER_PATH,
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
_set_csrf_cookie(response, ttl * 60)
|
||||
_set_csrf_cookie(response)
|
||||
|
||||
|
||||
def set_admin_session(response: Response, admin_id: int) -> None:
|
||||
def set_admin_session(response: Response, admin_id: int, token_version: int = 0) -> None:
|
||||
ttl = settings.jwt_admin_ttl_minutes
|
||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
|
||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version)
|
||||
response.set_cookie(
|
||||
key=ADMIN_COOKIE,
|
||||
value=token,
|
||||
@@ -109,7 +134,7 @@ def set_admin_session(response: Response, admin_id: int) -> None:
|
||||
path=_ADMIN_PATH,
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
_set_csrf_cookie(response, ttl * 60)
|
||||
_set_csrf_cookie(response)
|
||||
|
||||
|
||||
def clear_user_session(response: Response) -> None:
|
||||
@@ -118,3 +143,38 @@ def clear_user_session(response: Response) -> None:
|
||||
|
||||
def clear_admin_session(response: Response) -> None:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Точечный отзыв отдельных 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()
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Инициализация схемы и справочников (для тестов и локального быстрого старта).
|
||||
|
||||
В 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)
|
||||
+134
-25
@@ -10,12 +10,24 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError, app_error_handler
|
||||
from app.routers import admin, auth, groups, matches, reference, stats, users
|
||||
from app.routers import (
|
||||
achievements,
|
||||
admin,
|
||||
announcements,
|
||||
auth,
|
||||
events,
|
||||
groups,
|
||||
invitations,
|
||||
matches,
|
||||
notifications,
|
||||
reference,
|
||||
stats,
|
||||
users,
|
||||
)
|
||||
|
||||
# Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать).
|
||||
_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static")))
|
||||
@@ -23,22 +35,55 @@ _STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.p
|
||||
_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""Double-submit CSRF: для аутентифицированных мутаций на /api требуем
|
||||
совпадения заголовка X-CSRF-Token и cookie csrf_token."""
|
||||
def _with_fresh_csrf_cookie(send): # noqa: ANN001, ANN202
|
||||
"""Дописывает свежий csrf_token в заголовки ответа. Трогает только
|
||||
http.response.start: тело (в т.ч. SSE-поток) проходит насквозь, чанк за чанком."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next): # noqa: ANN001
|
||||
path = request.url.path
|
||||
if request.method in _UNSAFE_METHODS and path.startswith("/api"):
|
||||
async def wrapped(message): # noqa: ANN001
|
||||
if message["type"] == "http.response.start":
|
||||
headers = list(message.get("headers", []))
|
||||
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 = (
|
||||
security.USER_COOKIE in request.cookies
|
||||
or security.ADMIN_COOKIE in request.cookies
|
||||
)
|
||||
if has_session:
|
||||
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)
|
||||
if not cookie_token or cookie_token != header_token:
|
||||
return JSONResponse(
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": {
|
||||
@@ -48,13 +93,57 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
}
|
||||
},
|
||||
)
|
||||
return await call_next(request)
|
||||
await response(scope, receive, send)
|
||||
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
|
||||
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 при старте
|
||||
# (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
||||
try:
|
||||
from app.bootstrap import bootstrap
|
||||
@@ -64,21 +153,30 @@ async def _lifespan(_app: FastAPI):
|
||||
logging.getLogger("fs").warning(
|
||||
"Стартовый bootstrap пропущен (примените миграции): %s", exc
|
||||
)
|
||||
|
||||
purge_task = asyncio.create_task(_notifications_purge_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
purge_task.cancel()
|
||||
|
||||
|
||||
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(
|
||||
title="Forbidden Stars API",
|
||||
version="0.1.0",
|
||||
openapi_url="/api/openapi.json",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json" if docs_enabled else None,
|
||||
docs_url="/api/docs" if docs_enabled else None,
|
||||
redoc_url="/api/redoc" if docs_enabled else None,
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin).
|
||||
# В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем.
|
||||
# В prod (и в dev через VPS-туннель) всё single-origin → CORS не подключаем.
|
||||
if settings.is_development and settings.cors_origins_list:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -94,30 +192,37 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
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(
|
||||
status_code=422,
|
||||
content={
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Ошибка валидации запроса.",
|
||||
"details": exc.errors(),
|
||||
"details": details,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# API-роутеры под /api.
|
||||
api_routers = [auth.router, users.router, groups.router, matches.router,
|
||||
reference.router, stats.router, admin.router]
|
||||
api_routers = [auth.router, users.router, groups.router, invitations.router,
|
||||
matches.router, reference.router, stats.router, achievements.router,
|
||||
events.router, notifications.router, announcements.router,
|
||||
admin.router]
|
||||
for r in api_routers:
|
||||
app.include_router(r, prefix="/api")
|
||||
|
||||
# DEV-вход (по нику) — только в development и только если код физически есть
|
||||
# (в test/prod-образе dev_auth/dev_stub исключены, импорт просто не выполнится).
|
||||
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
|
||||
# и только если код физически есть (в прод-образе dev_*-файлы исключены
|
||||
# .dockerignore, импорт просто не выполнится).
|
||||
if settings.is_development:
|
||||
for mod_name in ("dev_auth", "dev_admin"):
|
||||
try:
|
||||
from app.routers import dev_auth
|
||||
|
||||
app.include_router(dev_auth.router, prefix="/api")
|
||||
mod = __import__(f"app.routers.{mod_name}", fromlist=["router"])
|
||||
app.include_router(mod.router, prefix="/api")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -148,9 +253,13 @@ def _mount_spa(app: FastAPI) -> None:
|
||||
full_path
|
||||
and _STATIC_DIR in candidate.parents
|
||||
and candidate.is_file()
|
||||
and candidate.name != "index.html"
|
||||
):
|
||||
# Хэшированные ассеты Vite — обычное кэширование (имя меняется при сборке).
|
||||
return FileResponse(candidate)
|
||||
return FileResponse(index_file)
|
||||
# index.html (корень и SPA-маршруты) — НЕ кэшируем: иначе браузер отдаёт старый
|
||||
# документ из кэша и при недоступности хоста не доходит до Caddy с заглушкой.
|
||||
return FileResponse(index_file, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
+274
-26
@@ -4,7 +4,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
@@ -23,9 +23,9 @@ from sqlalchemy import (
|
||||
)
|
||||
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))
|
||||
is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
sort_order: int = Field(default=0, nullable=False)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
class Faction(SQLModel, table=True):
|
||||
@@ -56,7 +56,7 @@ class Faction(SQLModel, table=True):
|
||||
)
|
||||
)
|
||||
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,6 +73,10 @@ class User(SQLModel, table=True):
|
||||
"role <> 'admin' OR password_hash IS NOT NULL",
|
||||
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)
|
||||
@@ -80,12 +84,22 @@ class User(SQLModel, table=True):
|
||||
sa_column=Column(BigInteger, nullable=True, unique=True)
|
||||
)
|
||||
nickname: str = Field(sa_column=Column(String(64), nullable=False, unique=True))
|
||||
bio: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||
# Относительный путь файла аватара (под settings.upload_dir); наличие = есть аватар.
|
||||
avatar_path: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
|
||||
role: str = Field(default="player", sa_column=Column(String(16), nullable=False, index=True, server_default="player"))
|
||||
auth_provider: str = Field(
|
||||
default="stub",
|
||||
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
||||
)
|
||||
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
|
||||
# Версия сессий: инкремент отзывает все ранее выданные 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(
|
||||
sa_column=Column(
|
||||
Integer,
|
||||
@@ -94,15 +108,58 @@ class User(SQLModel, table=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"))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
|
||||
# (выдача ачивок игрокам — следующий этап).
|
||||
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=_utcnow,
|
||||
sa_column_kwargs={"onupdate": _utcnow},
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class UserAchievement(SQLModel, table=True):
|
||||
"""Полученные игроком ачивки. Задел под будущую выдачу — сейчас не заполняется.
|
||||
|
||||
Ссылается на ачивку по slug (источник правды — папки на диске, не таблица БД)."""
|
||||
|
||||
__tablename__ = "user_achievements"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
earned_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
class AuthIdentity(SQLModel, table=True):
|
||||
__tablename__ = "auth_identity"
|
||||
__table_args__ = (
|
||||
@@ -117,7 +174,7 @@ class AuthIdentity(SQLModel, table=True):
|
||||
)
|
||||
provider: str = Field(sa_column=Column(String(16), nullable=False))
|
||||
external_id: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
# ─── Группы и членство ───────────────────────────────────────────────────────
|
||||
@@ -132,12 +189,18 @@ class Group(SQLModel, table=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(
|
||||
default_factory=_utcnow,
|
||||
sa_column_kwargs={"onupdate": _utcnow},
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
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):
|
||||
@@ -159,7 +222,35 @@ class GroupMember(SQLModel, table=True):
|
||||
)
|
||||
)
|
||||
role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member"))
|
||||
joined_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
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):
|
||||
@@ -179,7 +270,7 @@ class GroupExpansion(SQLModel, table=True):
|
||||
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)
|
||||
|
||||
|
||||
# ─── Партии и участники ──────────────────────────────────────────────────────
|
||||
@@ -190,7 +281,8 @@ class Match(SQLModel, table=True):
|
||||
Index("ix_matches_group_played", "group_id", "played_at"),
|
||||
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
|
||||
CheckConstraint(
|
||||
"win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')",
|
||||
"win_reason IS NULL OR win_reason IN "
|
||||
"('objectives','worlds','plastic','resources','last_standing')",
|
||||
name="ck_match_win_reason",
|
||||
),
|
||||
)
|
||||
@@ -210,6 +302,14 @@ class Match(SQLModel, table=True):
|
||||
finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
|
||||
duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
||||
win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
|
||||
# Раунд, в котором партия закончилась (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))
|
||||
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||
created_by: int = Field(
|
||||
@@ -217,10 +317,10 @@ class Match(SQLModel, table=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(
|
||||
default_factory=_utcnow,
|
||||
sa_column_kwargs={"onupdate": _utcnow},
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
@@ -251,9 +351,163 @@ class MatchParticipant(SQLModel, table=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"))
|
||||
comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
# Итог партии для рейтинга (NULL — не указан): маркеры целей и дружественные миры
|
||||
# на конец партии. У выбывшего миров 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)
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
@@ -277,12 +531,6 @@ class AuditLog(SQLModel, table=True):
|
||||
payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True))
|
||||
ip: str | None = Field(sa_column=Column(String(45), nullable=True))
|
||||
user_agent: str | None = Field(sa_column=Column(String(256), nullable=True))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
# Заготовка под будущие вложения (НЕ в v1-миграции, добавится отдельно):
|
||||
# class Attachment(SQLModel, table=True):
|
||||
# id, match_id (FK CASCADE), participant_id (FK NULL SET NULL),
|
||||
# uploaded_by (FK), kind ('photo'|'video'), storage_path, mime_type,
|
||||
# size_bytes, created_at
|
||||
# Файлы — на томе /data/uploads; в БД только метаданные и относительный путь.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Публичное чтение ачивок: список и иконки (для отображения; выдача — на будущее)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.models import User
|
||||
from app.schemas import api as s
|
||||
from app.services import achievement_service
|
||||
|
||||
router = APIRouter(prefix="/achievements", tags=["achievements"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[s.AchievementRead])
|
||||
def list_achievements(_user: User = Depends(get_current_user)) -> list[dict]:
|
||||
return achievement_service.list_achievements()
|
||||
|
||||
|
||||
# Иконка — без auth: косметический ассет, нужен и игроку, и в админ-панели (cookie
|
||||
# админа scoped на /api/admin и сюда не отправляется). Отдаётся по известному slug.
|
||||
@router.get("/{slug}/icon")
|
||||
def get_icon(slug: str) -> FileResponse:
|
||||
path, media_type = achievement_service.icon_file(slug)
|
||||
return FileResponse(
|
||||
path, media_type=media_type, headers={"Cache-Control": "public, max-age=3600"}
|
||||
)
|
||||
+341
-52
@@ -1,18 +1,33 @@
|
||||
"""Админ-роутер: отдельный вход (логин+пароль) и управление сущностями."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.admin_login import login_admin
|
||||
from app.auth.deps import get_current_admin
|
||||
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.db.session import get_session
|
||||
from app.models import User
|
||||
from app.routers.matches import build_match_read
|
||||
from app.routers.matches import attachment_read, build_match_read, participant_inputs
|
||||
from app.schemas import api as s
|
||||
from app.services import admin_service, audit_service, faction_service, match_service
|
||||
from app.services.match_service import ParticipantInput
|
||||
from app.services import (
|
||||
achievement_service,
|
||||
admin_service,
|
||||
announcement_service,
|
||||
attachment_service,
|
||||
audit_service,
|
||||
faction_service,
|
||||
match_service,
|
||||
notify,
|
||||
user_service,
|
||||
)
|
||||
|
||||
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -26,15 +41,30 @@ def admin_login(
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> s.AdminMe:
|
||||
admin = admin_service.authenticate_admin(session, body.username, body.password)
|
||||
security.set_admin_session(response, admin.id) # type: ignore[arg-type]
|
||||
try:
|
||||
admin = login_admin(session, body.username, body.password, client_ip(request))
|
||||
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(
|
||||
session,
|
||||
actor_id=admin.id,
|
||||
action="login",
|
||||
entity_type="admin",
|
||||
entity_id=admin.id,
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
@@ -42,7 +72,8 @@ def admin_login(
|
||||
|
||||
|
||||
@router.post("/auth/logout", response_model=s.OkResponse)
|
||||
def admin_logout(response: Response) -> s.OkResponse:
|
||||
def admin_logout(request: Request, response: Response) -> s.OkResponse:
|
||||
security.revoke_session_token(request, security.ADMIN_COOKIE, security.AUDIENCE_ADMIN)
|
||||
security.clear_admin_session(response)
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -54,14 +85,8 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
|
||||
|
||||
# ─── Пользователи ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users", response_model=list[s.AdminUserRead])
|
||||
def list_users(
|
||||
query: str | None = Query(None),
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> list[s.AdminUserRead]:
|
||||
return [
|
||||
s.AdminUserRead(
|
||||
def _admin_user_read(u: User) -> s.AdminUserRead:
|
||||
return s.AdminUserRead(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
@@ -69,9 +94,17 @@ def list_users(
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
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)
|
||||
@@ -90,34 +123,38 @@ def update_user(
|
||||
entity_type="user",
|
||||
entity_id=user_id,
|
||||
payload=body.model_dump(exclude_none=True),
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.AdminUserRead(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
nickname=u.nickname,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
created_at=u.created_at.isoformat(),
|
||||
)
|
||||
return _admin_user_read(u)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", response_model=s.OkResponse)
|
||||
def delete_user(
|
||||
@router.put("/users/{user_id}/password", response_model=s.AdminUserRead)
|
||||
def set_user_password(
|
||||
user_id: int,
|
||||
body: s.AdminPasswordSet,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
admin_service.delete_user(session, user_id)
|
||||
) -> s.AdminUserRead:
|
||||
"""Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется."""
|
||||
u = admin_service.set_player_password(session, user_id, body.new_password)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="user", entity_id=user_id,
|
||||
ip=request.client.host if request.client else None,
|
||||
session,
|
||||
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 s.OkResponse()
|
||||
return _admin_user_read(u)
|
||||
|
||||
|
||||
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
||||
# routers/dev_admin.py (исключён из прод-образа). В проде аккаунт только
|
||||
# отключается (PATCH is_active), удалять нельзя.
|
||||
|
||||
|
||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||
@@ -145,7 +182,7 @@ def delete_group(
|
||||
admin_service.delete_group(session, group_id)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id,
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.OkResponse()
|
||||
@@ -206,18 +243,6 @@ def update_match(
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.MatchRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
participants = None
|
||||
if body.participants is not None:
|
||||
participants = [
|
||||
ParticipantInput(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.update_match(
|
||||
session,
|
||||
match,
|
||||
@@ -226,7 +251,10 @@ def update_match(
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
win_reason=body.win_reason,
|
||||
win_reason_set=("win_reason" in body.model_fields_set),
|
||||
participants=participants,
|
||||
end_round=body.end_round,
|
||||
end_round_set=("end_round" in body.model_fields_set),
|
||||
participants=participant_inputs(body),
|
||||
expected_version=body.expected_version,
|
||||
)
|
||||
audit_service.record(
|
||||
session,
|
||||
@@ -234,9 +262,10 @@ def update_match(
|
||||
action="update",
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_changed(session, match)
|
||||
return build_match_read(session, match, can_modify=True)
|
||||
|
||||
|
||||
@@ -271,7 +300,7 @@ def rename_faction(
|
||||
entity_type="faction",
|
||||
entity_id=faction_id,
|
||||
payload={"name_ru": f.name_ru},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.FactionRead(
|
||||
@@ -286,12 +315,272 @@ def delete_match(
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> 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)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_removed(session, match_id, group_id, finished=finished)
|
||||
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])
|
||||
def list_achievements(
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> list[dict]:
|
||||
return achievement_service.list_achievements()
|
||||
|
||||
|
||||
@router.post("/achievements", response_model=s.AchievementRead)
|
||||
def create_achievement(
|
||||
body: s.AchievementCreate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
ach = achievement_service.create(body.name, body.description, body.condition)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="create", entity_type="achievement",
|
||||
payload={"slug": ach["slug"], "name": ach["name"]},
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return ach
|
||||
|
||||
|
||||
@router.patch("/achievements/{slug}", response_model=s.AchievementRead)
|
||||
def update_achievement(
|
||||
slug: str,
|
||||
body: s.AchievementUpdate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
ach = achievement_service.update(
|
||||
slug, name=body.name, description=body.description, condition=body.condition
|
||||
)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="update", entity_type="achievement",
|
||||
payload={"slug": slug}, ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return ach
|
||||
|
||||
|
||||
@router.put("/achievements/{slug}/icon", response_model=s.AchievementRead)
|
||||
def upload_achievement_icon(
|
||||
slug: str,
|
||||
file: UploadFile = File(...),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
content, ext = user_service.read_capped_image(
|
||||
file, _ACHIEVEMENT_ICON_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
|
||||
)
|
||||
return achievement_service.set_icon(slug, content, ext)
|
||||
|
||||
|
||||
@router.delete("/achievements/{slug}", response_model=s.OkResponse)
|
||||
def delete_achievement(
|
||||
slug: str,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
achievement_service.delete(slug)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="achievement",
|
||||
payload={"slug": slug}, ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Объявления администрации для игрока: что показать сейчас и «Понятно».
|
||||
|
||||
Появление нового объявления у открытой вкладки обеспечивает 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()
|
||||
@@ -1,20 +1,24 @@
|
||||
"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход.
|
||||
"""Постоянный роутер аутентификации: конфиг, вход по паролю, Telegram-вход, выход.
|
||||
|
||||
Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
||||
Stub-вход (по нику без пароля) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.login import login_with_identity
|
||||
from app.auth.login import establish_session
|
||||
from app.auth.password import login_player, throttle_register
|
||||
from app.auth.registry import enabled_methods
|
||||
from app.auth.telegram import TelegramProvider
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.core.errors import TelegramNicknameRequiredError
|
||||
from app.core.security import client_ip
|
||||
from app.db.session import get_session
|
||||
from app.routers.users import build_me
|
||||
from app.schemas import api as s
|
||||
from app.services import audit_service, user_service
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -24,9 +28,47 @@ def auth_config() -> s.AuthConfig:
|
||||
return s.AuthConfig(
|
||||
methods=enabled_methods(),
|
||||
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)
|
||||
def telegram_login(
|
||||
body: s.TelegramAuthPayload,
|
||||
@@ -34,12 +76,51 @@ def telegram_login(
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> s.MeRead:
|
||||
"""Вход/регистрация через Telegram.
|
||||
|
||||
Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход —
|
||||
регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409
|
||||
TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register
|
||||
с выбранным ником.
|
||||
"""
|
||||
identity = TelegramProvider().authenticate(body.model_dump())
|
||||
user = login_with_identity(session, response, request, identity)
|
||||
user = user_service.find_by_identity(session, identity)
|
||||
if user is None:
|
||||
suggested = (identity.suggested_nickname or "").strip()
|
||||
if not user_service.nickname_format_ok(suggested) or not user_service.nickname_available(
|
||||
session, suggested
|
||||
):
|
||||
raise TelegramNicknameRequiredError(suggested)
|
||||
user = user_service.register_from_identity(session, identity, suggested)
|
||||
establish_session(session, response, request, user, identity.provider)
|
||||
return build_me(session, user)
|
||||
|
||||
|
||||
@router.post("/telegram/register", response_model=s.MeRead)
|
||||
def telegram_register(
|
||||
body: s.TelegramRegister,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> s.MeRead:
|
||||
"""Завершение регистрации через Telegram с выбранным ником.
|
||||
|
||||
Подпись виджета проверяется заново (поле nickname в HMAC не входит), поэтому личность
|
||||
доверенная — подделать чужой telegram_id нельзя. Если пользователь уже успел
|
||||
зарегистрироваться (гонка) — просто входим.
|
||||
"""
|
||||
identity = TelegramProvider().authenticate(body.model_dump(exclude={"nickname"}))
|
||||
user = user_service.find_by_identity(session, identity)
|
||||
if user is None:
|
||||
user = user_service.register_from_identity(session, identity, body.nickname)
|
||||
establish_session(session, response, request, user, identity.provider)
|
||||
return build_me(session, user)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=s.OkResponse)
|
||||
def logout(response: Response) -> s.OkResponse:
|
||||
def logout(request: Request, 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)
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
|
||||
|
||||
Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер
|
||||
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
|
||||
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
|
||||
возможность удаления не попадает в прод — там аккаунт можно только
|
||||
отключить (PATCH is_active).
|
||||
|
||||
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
||||
строки участия игрока удаляются (player_count партий НЕ пересчитывается, поэтому
|
||||
их статистика может стать несогласованной — осознанный выбор). Созданные им партии
|
||||
и группы в его владении переназначаются на админа (FK RESTRICT не даёт удалить
|
||||
пользователя, а сами партии/группы мы сохраняем).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.deps import get_current_admin
|
||||
from app.core.security import client_ip
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.db.session import get_session
|
||||
from app.models import Group, Match, MatchParticipant, User
|
||||
from app.schemas import api as s
|
||||
from app.services import audit_service
|
||||
|
||||
router = APIRouter(prefix="/admin/dev", tags=["admin-dev"])
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", response_model=s.OkResponse)
|
||||
def delete_user_hard(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("Пользователь не найден.")
|
||||
if user.role == "admin":
|
||||
raise ValidationError("Нельзя удалить администратора.")
|
||||
nickname = user.nickname # сохраняем до удаления (для аудита)
|
||||
|
||||
# 1. Вычёркиваем игрока из всех партий (его строки участия).
|
||||
for part in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.user_id == user_id)
|
||||
).all():
|
||||
session.delete(part)
|
||||
# 2. Партии и группы, где он создатель/владелец, переназначаем на админа.
|
||||
for match in session.exec(select(Match).where(Match.created_by == user_id)).all():
|
||||
match.created_by = admin.id # type: ignore[assignment]
|
||||
session.add(match)
|
||||
for group in session.exec(select(Group).where(Group.owner_id == user_id)).all():
|
||||
group.owner_id = admin.id # type: ignore[assignment]
|
||||
session.add(group)
|
||||
session.flush()
|
||||
# 3. group_members и auth_identity уйдут каскадом, audit_log.actor_id → NULL.
|
||||
session.delete(user)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=admin.id,
|
||||
action="delete",
|
||||
entity_type="user",
|
||||
entity_id=user_id,
|
||||
payload={"hard": True, "nickname": nickname},
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.OkResponse()
|
||||
@@ -1,8 +1,8 @@
|
||||
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
|
||||
|
||||
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
|
||||
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код
|
||||
входа по логину остаётся только на деве.
|
||||
а подключается роутер лишь при APP_ENV=development (см. app/main.py). Так вход
|
||||
без пароля остаётся только на деве (по логину и паролю входят везде).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.dev_stub import DevStubProvider
|
||||
from app.auth.login import login_with_identity
|
||||
from app.auth.login import establish_session, login_with_identity
|
||||
from app.auth.provider import ExternalIdentity
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
@@ -28,7 +28,19 @@ def dev_login(
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> s.MeRead:
|
||||
identity = DevStubProvider().authenticate({"nickname": body.nickname})
|
||||
# Dev-вход «стать игроком с этим ником». Если такой игрок уже есть (любого провайдера —
|
||||
# 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)
|
||||
return build_me(session, user)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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"},
|
||||
)
|
||||
@@ -5,14 +5,20 @@ from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
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.models import User
|
||||
from app.schemas import api as s
|
||||
from app.services import (
|
||||
audit_service,
|
||||
group_service,
|
||||
invitation_service,
|
||||
membership_service,
|
||||
notification_service,
|
||||
notify,
|
||||
stats_service,
|
||||
user_service,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||
@@ -27,6 +33,7 @@ def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail:
|
||||
owner_id=group.owner_id,
|
||||
my_role=member.role,
|
||||
expansion_ids=group_service.group_expansion_ids(session, group_id),
|
||||
nine_rounds_rule=group.nine_rounds_rule,
|
||||
)
|
||||
|
||||
|
||||
@@ -56,7 +63,7 @@ def create_group(
|
||||
entity_type="group",
|
||||
entity_id=group.id,
|
||||
payload={"name": group.name},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
@@ -73,15 +80,19 @@ def get_group(
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=s.GroupDetail)
|
||||
def rename_group(
|
||||
def update_group(
|
||||
group_id: int,
|
||||
body: s.GroupRename,
|
||||
body: s.GroupUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.GroupDetail:
|
||||
"""Название и домашние правила группы; меняются только переданные поля."""
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
if body.name is not None:
|
||||
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]
|
||||
|
||||
|
||||
@@ -123,34 +134,50 @@ def list_members(
|
||||
) -> list[s.MemberRead]:
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
return [
|
||||
s.MemberRead(user_id=u.id, nickname=u.nickname, role=m.role)
|
||||
s.MemberRead(
|
||||
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)
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{group_id}/members", response_model=s.MemberRead)
|
||||
def add_member(
|
||||
@router.post("/{group_id}/invitations", response_model=s.InvitationRead)
|
||||
def invite_member(
|
||||
group_id: int,
|
||||
body: s.MemberAdd,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MemberRead:
|
||||
) -> s.InvitationRead:
|
||||
# Приглашать вправе ЛЮБОЙ участник группы (не только владелец) — assert_member.
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
member, added = membership_service.add_member_by_nickname(session, group, body.nickname)
|
||||
inv, invited = invitation_service.create_invitation(session, group, user, body.nickname)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="create",
|
||||
entity_type="group_member",
|
||||
entity_type="group_invitation",
|
||||
entity_id=group_id,
|
||||
payload={"added_user_id": added.id, "nickname": added.nickname},
|
||||
ip=request.client.host if request.client else None,
|
||||
payload={"invited_user_id": invited.id, "nickname": invited.nickname},
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return s.MemberRead(user_id=added.id, nickname=added.nickname, role=member.role)
|
||||
notify.invitations_changed(invited.id) # type: ignore[arg-type] # живое появление у получателя
|
||||
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)
|
||||
@@ -165,6 +192,7 @@ def remove_member(
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
membership_service.remove_member(session, group, user_id)
|
||||
notify.group_changed(session, group_id, extra_user_ids=[user_id]) # + удалённому
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@@ -179,10 +207,13 @@ def change_member_role(
|
||||
group_service.assert_owner(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
member = membership_service.change_role(session, group, user_id, body.role)
|
||||
from app.services.user_service import get_user
|
||||
|
||||
u = get_user(session, user_id)
|
||||
return s.MemberRead(user_id=u.id, nickname=u.nickname, role=member.role)
|
||||
u = user_service.get_user(session, user_id)
|
||||
return s.MemberRead(
|
||||
user_id=u.id,
|
||||
nickname=u.nickname,
|
||||
role=member.role,
|
||||
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
||||
)
|
||||
|
||||
|
||||
# ─── Партии и статистика группы ──────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Входящие приглашения игрока: список, принятие, отклонение."""
|
||||
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()
|
||||
+178
-24
@@ -1,21 +1,65 @@
|
||||
"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.core.errors import NoGroupError
|
||||
from app.core.security import client_ip
|
||||
from app.core.errors import ConflictError, NoGroupError, NotFoundError
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.db.session import get_session
|
||||
from app.models import Match, User
|
||||
from app.models import Match, MatchAttachment, User
|
||||
from app.schemas import api as s
|
||||
from app.services import audit_service, group_service, match_service
|
||||
from app.services import (
|
||||
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.scoring import max_rounds
|
||||
|
||||
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:
|
||||
if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type]
|
||||
raise NoGroupError()
|
||||
@@ -29,11 +73,25 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
faction_id=f.id, # type: ignore[arg-type]
|
||||
faction_name=f.name_ru,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
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]
|
||||
]
|
||||
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(
|
||||
id=match.id, # type: ignore[arg-type]
|
||||
group_id=match.group_id,
|
||||
@@ -43,12 +101,20 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
finished_at=iso_utc(match.finished_at),
|
||||
duration_minutes=match.duration_minutes,
|
||||
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,
|
||||
overall_comment=match.overall_comment,
|
||||
created_by=match.created_by,
|
||||
can_modify=can_modify,
|
||||
version=match_service.match_version(match),
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,10 +155,12 @@ def start_match(
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
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))
|
||||
|
||||
|
||||
@@ -108,7 +176,15 @@ def finish_match(
|
||||
match = match_service.get_match(session, match_id)
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
finish = [
|
||||
FinishInput(user_id=p.user_id, place=p.place, comment=p.comment, faction_id=p.faction_id)
|
||||
FinishInput(
|
||||
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
|
||||
]
|
||||
match = match_service.finish_match(
|
||||
@@ -116,8 +192,10 @@ def finish_match(
|
||||
match,
|
||||
finish=finish,
|
||||
win_reason=body.win_reason,
|
||||
end_round=body.end_round,
|
||||
overall_comment=body.overall_comment,
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
expected_version=body.expected_version,
|
||||
)
|
||||
audit_service.record(
|
||||
session,
|
||||
@@ -126,10 +204,12 @@ def finish_match(
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
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))
|
||||
|
||||
|
||||
@@ -154,18 +234,6 @@ def update_match(
|
||||
) -> s.MatchRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
participants = None
|
||||
if body.participants is not None:
|
||||
participants = [
|
||||
ParticipantInput(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.update_match(
|
||||
session,
|
||||
match,
|
||||
@@ -174,7 +242,10 @@ def update_match(
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
win_reason=body.win_reason,
|
||||
win_reason_set=("win_reason" in body.model_fields_set),
|
||||
participants=participants,
|
||||
end_round=body.end_round,
|
||||
end_round_set=("end_round" in body.model_fields_set),
|
||||
participants=participant_inputs(body),
|
||||
expected_version=body.expected_version,
|
||||
)
|
||||
audit_service.record(
|
||||
session,
|
||||
@@ -182,17 +253,98 @@ def update_match(
|
||||
action="update",
|
||||
entity_type="match",
|
||||
entity_id=match.id,
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_changed(session, match)
|
||||
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)
|
||||
def delete_match(
|
||||
match_id: int,
|
||||
request: Request,
|
||||
expected_version: str | None = Query(None),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.OkResponse:
|
||||
@@ -200,7 +352,8 @@ def delete_match(
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
match_id_val = match.id
|
||||
group_id_val = match.group_id
|
||||
match_service.delete_match(session, match)
|
||||
finished = match.status == "finished" # после удаления статус уже не прочитать
|
||||
match_service.delete_match(session, match, expected_version=expected_version)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
@@ -208,8 +361,9 @@ def delete_match(
|
||||
entity_type="match",
|
||||
entity_id=match_id_val,
|
||||
payload={"group_id": group_id_val},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_removed(session, match_id_val, group_id_val, finished=finished) # type: ignore[arg-type]
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Уведомления игрока: список (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()
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Роутер текущего пользователя."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.auth.password import check_current_password
|
||||
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.models import User
|
||||
from app.schemas import api as s
|
||||
@@ -12,6 +17,8 @@ from app.services import audit_service, group_service, stats_service, user_servi
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
_AVATAR_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
|
||||
|
||||
|
||||
def build_me(session: Session, user: User) -> s.MeRead:
|
||||
groups = [
|
||||
@@ -25,7 +32,13 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
||||
auth_provider=user.auth_provider,
|
||||
telegram_id=user.telegram_id,
|
||||
active_group_id=user.active_group_id,
|
||||
bio=user.bio,
|
||||
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,
|
||||
has_password=user.password_hash is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -52,7 +65,7 @@ def update_me(
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
payload={"nickname": user.nickname},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
@@ -63,9 +76,171 @@ def update_me(
|
||||
auth_provider=user.auth_provider,
|
||||
telegram_id=user.telegram_id,
|
||||
active_group_id=user.active_group_id,
|
||||
bio=user.bio,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/me/profile", response_model=s.MeRead)
|
||||
def update_my_profile(
|
||||
body: s.ProfileUpdate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MeRead:
|
||||
# Обновляем только реально переданные поля: «О себе» и любимая фракция правятся
|
||||
# по отдельности, и частичный запрос не должен обнулять соседнее поле.
|
||||
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(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="update",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
payload={key: True for key in changed},
|
||||
ip=client_ip(request),
|
||||
)
|
||||
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()
|
||||
return build_me(session, user)
|
||||
|
||||
|
||||
@router.put("/me/avatar", response_model=s.MeRead)
|
||||
def upload_my_avatar(
|
||||
file: UploadFile = File(...),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MeRead:
|
||||
content, ext = user_service.read_capped_image(
|
||||
file, _AVATAR_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
|
||||
)
|
||||
user_service.set_avatar(session, user, content, ext)
|
||||
return build_me(session, user)
|
||||
|
||||
|
||||
@router.delete("/me/avatar", response_model=s.MeRead)
|
||||
def delete_my_avatar(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MeRead:
|
||||
user_service.clear_avatar(session, user)
|
||||
return build_me(session, user)
|
||||
|
||||
|
||||
@router.get("/{user_id}/avatar")
|
||||
def get_user_avatar(
|
||||
user_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> FileResponse:
|
||||
target = session.get(User, user_id)
|
||||
path = user_service.avatar_abs_path(target.avatar_path) if target else None
|
||||
if path is None or not path.exists():
|
||||
raise NotFoundError("Аватар не найден.")
|
||||
ext = path.suffix.lstrip(".")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=user_service.avatar_media_type(ext),
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}/profile", response_model=s.PublicProfile)
|
||||
def get_user_profile(
|
||||
user_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
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)
|
||||
def my_stats(
|
||||
session: Session = Depends(get_session),
|
||||
@@ -92,3 +267,22 @@ def nickname_available(
|
||||
) -> s.NicknameAvailable:
|
||||
available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id)
|
||||
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
|
||||
]
|
||||
|
||||
+291
-12
@@ -1,20 +1,52 @@
|
||||
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Literal
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
WinReason = Literal["objectives", "worlds", "plastic", "resources"]
|
||||
# last_standing — все соперники выбыли. Вручную не выбирается: сервер требует её ровно
|
||||
# тогда, когда невыбывший участник один (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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AuthConfig(BaseModel):
|
||||
# Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве.
|
||||
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
||||
methods: list[str] = []
|
||||
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):
|
||||
@@ -29,6 +61,12 @@ class TelegramAuthPayload(BaseModel):
|
||||
photo_url: str | None = None
|
||||
|
||||
|
||||
class TelegramRegister(TelegramAuthPayload):
|
||||
# Регистрация через Telegram с явно выбранным ником (когда тег занят/некорректен).
|
||||
# Подпись виджета проверяется по тем же полям; nickname в HMAC не входит.
|
||||
nickname: str
|
||||
|
||||
|
||||
class DevLogin(BaseModel):
|
||||
nickname: str
|
||||
|
||||
@@ -82,16 +120,35 @@ class UserRead(BaseModel):
|
||||
auth_provider: str
|
||||
telegram_id: int | None = None
|
||||
active_group_id: int | None = None
|
||||
bio: 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):
|
||||
groups: list[GroupBrief] = []
|
||||
# False — пароль ещё не задан (аккаунт из Telegram или до появления паролей):
|
||||
# фронт не пускает дальше окна установки пароля.
|
||||
has_password: bool = False
|
||||
|
||||
|
||||
class NicknameUpdate(BaseModel):
|
||||
nickname: str
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
# Оба поля необязательны и обновляются, только если реально переданы
|
||||
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
|
||||
bio: str | None = None
|
||||
favorite_faction_id: int | None = None
|
||||
history_mode: str | None = None
|
||||
history_detail: str | None = None
|
||||
|
||||
|
||||
class ActiveGroupUpdate(BaseModel):
|
||||
group_id: int | None = None
|
||||
|
||||
@@ -100,6 +157,14 @@ class NicknameAvailable(BaseModel):
|
||||
available: bool
|
||||
|
||||
|
||||
class UserSuggestion(BaseModel):
|
||||
"""Подсказка автокомплита по нику (приглашение в группу и т.п.)."""
|
||||
|
||||
user_id: int
|
||||
nickname: str
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
# ─── Группы и членство ───────────────────────────────────────────────────────
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
@@ -107,8 +172,10 @@ class GroupCreate(BaseModel):
|
||||
expansion_ids: list[int] = []
|
||||
|
||||
|
||||
class GroupRename(BaseModel):
|
||||
name: str
|
||||
class GroupUpdate(BaseModel):
|
||||
# Частичная правка: переданные поля меняются, остальные остаются как есть.
|
||||
name: str | None = None
|
||||
nine_rounds_rule: bool | None = None
|
||||
|
||||
|
||||
class GroupExpansionsUpdate(BaseModel):
|
||||
@@ -121,12 +188,15 @@ class GroupDetail(BaseModel):
|
||||
owner_id: int
|
||||
my_role: str
|
||||
expansion_ids: list[int] = []
|
||||
# Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте).
|
||||
nine_rounds_rule: bool = False
|
||||
|
||||
|
||||
class MemberRead(BaseModel):
|
||||
user_id: int
|
||||
nickname: str
|
||||
role: str
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class MemberAdd(BaseModel):
|
||||
@@ -137,6 +207,52 @@ class MemberRoleUpdate(BaseModel):
|
||||
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):
|
||||
@@ -163,31 +279,42 @@ class MatchCreate(BaseModel):
|
||||
# Этап 2 (завершение): места, комментарии, причина победы.
|
||||
class MatchFinishParticipant(BaseModel):
|
||||
user_id: int
|
||||
place: int = Field(ge=1)
|
||||
place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated)
|
||||
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
objectives: Count | None = None # маркеры целей на конец партии (необязательно)
|
||||
worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0
|
||||
|
||||
|
||||
class MatchFinish(BaseModel):
|
||||
participants: list[MatchFinishParticipant]
|
||||
win_reason: WinReason
|
||||
end_round: EndRound | None = None # раунд, в котором партия закончилась
|
||||
overall_comment: str | None = None
|
||||
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
|
||||
expected_version: str | None = None
|
||||
|
||||
|
||||
# Полный участник (правка завершённой партии админом).
|
||||
# Полный участник (правка результатов завершённой партии).
|
||||
class ParticipantInput(BaseModel):
|
||||
user_id: int
|
||||
faction_id: int
|
||||
place: int = Field(ge=1)
|
||||
place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated)
|
||||
eliminated: bool = False
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
objectives: Count | None = None
|
||||
worlds: Count | None = None
|
||||
|
||||
|
||||
class MatchUpdate(BaseModel):
|
||||
played_at: date | None = None
|
||||
overall_comment: str | None = None
|
||||
win_reason: WinReason | None = None
|
||||
end_round: EndRound | None = None
|
||||
participants: list[ParticipantInput] | None = None
|
||||
expected_version: str | None = None # оптимистичная блокировка
|
||||
|
||||
|
||||
class MatchParticipantRead(BaseModel):
|
||||
@@ -196,8 +323,44 @@ class MatchParticipantRead(BaseModel):
|
||||
faction_id: int
|
||||
faction_name: str
|
||||
place: int | None = None
|
||||
eliminated: bool = False
|
||||
was_random: bool
|
||||
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):
|
||||
@@ -209,12 +372,20 @@ class MatchRead(BaseModel):
|
||||
finished_at: str | None = None
|
||||
duration_minutes: int | None = None
|
||||
win_reason: WinReason | None = None
|
||||
end_round: int | None = None
|
||||
# Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии:
|
||||
# фронт берёт лимит отсюда, а не повторяет правило у себя.
|
||||
nine_rounds_rule: bool = False
|
||||
max_rounds: int
|
||||
player_count: int
|
||||
overall_comment: str | None = None
|
||||
created_by: int
|
||||
can_modify: bool = False # может ли текущий зритель править/завершать партию
|
||||
version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно
|
||||
participants: list[MatchParticipantRead] = []
|
||||
attachments: list[Any] = [] # задел под вложения (всегда пусто в v1)
|
||||
attachments: list[AttachmentRead] = []
|
||||
# Общий черновик формы завершения (только у незавершённой партии).
|
||||
finish_draft: MatchFinishDraftRead | None = None
|
||||
|
||||
|
||||
# ─── Статистика ──────────────────────────────────────────────────────────────
|
||||
@@ -224,13 +395,32 @@ class OverallStats(BaseModel):
|
||||
wins: int
|
||||
win_rate: float
|
||||
avg_place: float | None = None
|
||||
score: float | None = None
|
||||
# Рейтинг (Elo, старт 1500) целым числом; None — игрок ещё не сыграл ни одной партии.
|
||||
score: int | None = None
|
||||
|
||||
|
||||
class LeaderboardEntry(OverallStats):
|
||||
user_id: int
|
||||
nickname: str
|
||||
rank: int | 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):
|
||||
@@ -243,11 +433,15 @@ class FactionStat(BaseModel):
|
||||
faction_id: int
|
||||
code: str
|
||||
name_ru: str
|
||||
# Название в предложном падеже — для строки «Чаще всего играет на …».
|
||||
name_ru_prepositional: str
|
||||
expansion_code: str
|
||||
games: int
|
||||
wins: int
|
||||
win_rate: float
|
||||
avg_place: float | None = None
|
||||
# Средний результат относительно ожидания (S − E) × 100 — метрика лучшей/худшей
|
||||
# фракции: выше нуля — игрок на ней выступает лучше своих рейтинговых шансов.
|
||||
score: float | None = None
|
||||
|
||||
|
||||
@@ -263,8 +457,23 @@ class ProfileStats(BaseModel):
|
||||
factions: list[FactionStat] = []
|
||||
best_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] = []
|
||||
# Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал
|
||||
# неподтверждённый рейтинг, не дублируя константу на фронте.
|
||||
min_games: int = 0
|
||||
|
||||
|
||||
class PublicProfile(BaseModel):
|
||||
# Профиль другого игрока (read-only): шапка + глобальная статистика.
|
||||
user_id: int
|
||||
nickname: str
|
||||
bio: str | None = None
|
||||
avatar_url: str | None = None
|
||||
stats: ProfileStats
|
||||
|
||||
|
||||
class FactionMeta(BaseModel):
|
||||
@@ -282,6 +491,7 @@ class GroupStats(BaseModel):
|
||||
last_match_at: str | None = None
|
||||
leaderboard: list[LeaderboardEntry] = []
|
||||
provisional: list[LeaderboardEntry] = []
|
||||
inactive: list[LeaderboardEntry] = [] # участники без завершённых партий
|
||||
faction_meta: list[FactionMeta] = []
|
||||
min_games: int
|
||||
|
||||
@@ -292,8 +502,11 @@ class MatchListParticipant(BaseModel):
|
||||
faction_id: int
|
||||
faction_name: str
|
||||
place: int | None = None
|
||||
eliminated: bool = False
|
||||
was_random: bool
|
||||
comment: str | None = None
|
||||
objectives: int | None = None
|
||||
worlds: int | None = None
|
||||
|
||||
|
||||
class MatchListItem(BaseModel):
|
||||
@@ -308,6 +521,9 @@ class MatchListItem(BaseModel):
|
||||
overall_comment: str | None = None
|
||||
created_by: int
|
||||
participants: list[MatchListParticipant] = []
|
||||
# Изменение общего рейтинга владельца истории за эту партию (один знак после запятой).
|
||||
# Заполняется только в истории игрока (GET /users/{id}/matches); в списке группы — None.
|
||||
rating_delta: float | None = None
|
||||
|
||||
|
||||
class MatchList(BaseModel):
|
||||
@@ -361,6 +577,7 @@ class AdminUserRead(BaseModel):
|
||||
auth_provider: str
|
||||
telegram_id: int | None = None
|
||||
created_at: str
|
||||
has_password: bool = False
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
@@ -368,6 +585,11 @@ class AdminUserUpdate(BaseModel):
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AdminPasswordSet(BaseModel):
|
||||
# Новый пароль игроку от админа — способ восстановить забытый пароль.
|
||||
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||
|
||||
|
||||
class AdminGroupRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
@@ -388,6 +610,27 @@ class AdminMatchRead(BaseModel):
|
||||
created_at: str
|
||||
|
||||
|
||||
class AchievementRead(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
description: str = ""
|
||||
condition: str = "" # текст condition.py (задел; не исполняется)
|
||||
has_condition: bool = False
|
||||
icon_url: str | None = None
|
||||
|
||||
|
||||
class AchievementCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class AchievementUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class AuditLogItem(BaseModel):
|
||||
id: int
|
||||
actor_id: int | None = None
|
||||
@@ -404,3 +647,39 @@ class AuditLogList(BaseModel):
|
||||
items: list[AuditLogItem] = []
|
||||
limit: 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
|
||||
|
||||
@@ -33,7 +33,13 @@ FACTIONS: list[tuple[str, str, str, int]] = [
|
||||
|
||||
|
||||
def seed_reference_data(session: Session) -> None:
|
||||
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно."""
|
||||
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно.
|
||||
|
||||
Идёт при каждом старте (entrypoint.sh, lifespan dev), поэтому не перетирает то, что
|
||||
правят руками: имя существующей фракции меняет админка (admin_service.rename_faction),
|
||||
и после создания записи источник правды для него — БД (#72). Имя из кода получает
|
||||
только новая фракция; поправить имя существующей — админкой или миграцией.
|
||||
Служебные поля (дополнение, порядок) и всё у дополнений синхронизируются с кодом."""
|
||||
code_to_expansion: dict[str, Expansion] = {}
|
||||
|
||||
for code, name_ru, is_base, order in EXPANSIONS:
|
||||
@@ -61,7 +67,6 @@ def seed_reference_data(session: Session) -> None:
|
||||
)
|
||||
session.add(fac)
|
||||
else:
|
||||
fac.name_ru = name_ru
|
||||
fac.expansion_id = expansion.id # type: ignore[assignment]
|
||||
fac.sort_order = order
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Ачивки: определения хранятся файлами на диске (settings.achievements_dir).
|
||||
|
||||
Папка на ачивку: achievements/<slug>/
|
||||
meta.json — {"name", "description", "created_at"}
|
||||
condition.py — скрипт-условие выдачи (ЗАДЕЛ: только хранится как текст, НЕ исполняется)
|
||||
icon.<ext> — картинка-герб (png/jpg/webp), опционально
|
||||
|
||||
Источник правды — файлы. В БД (user_achievements / users.title_achievement_slug) — только
|
||||
будущая выдача игрокам (сейчас не используется). Выдача и исполнение condition.py — следующий
|
||||
этап; здесь админ лишь создаёт/именует/описывает ачивки.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.services.user_service import avatar_media_type, sniff_image_ext
|
||||
|
||||
_CONDITION_FILE = "condition.py"
|
||||
_META_FILE = "meta.json"
|
||||
_CONDITION_PLACEHOLDER = (
|
||||
"# Условие выдачи ачивки (ЗАДЕЛ на будущее — пока не исполняется).\n"
|
||||
"# Здесь будет описана логика автоматической выдачи этого титула игроку.\n"
|
||||
)
|
||||
|
||||
# Транслитерация для slug из русского названия.
|
||||
_TRANSLIT = {
|
||||
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh",
|
||||
"з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o",
|
||||
"п": "p", "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "h", "ц": "ts",
|
||||
"ч": "ch", "ш": "sh", "щ": "sch", "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu",
|
||||
"я": "ya",
|
||||
}
|
||||
|
||||
|
||||
def _root() -> Path:
|
||||
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:
|
||||
text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower())
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
||||
return slug[:48]
|
||||
|
||||
|
||||
def _dir(slug: str) -> Path:
|
||||
"""Папка ачивки. Slug приходит из URL, поэтому формат проверяем здесь: без этого
|
||||
`..` или `a/b` увели бы файловые операции (вплоть до rmtree в delete) за пределы
|
||||
каталога ачивок."""
|
||||
if not _SLUG_RE.fullmatch(slug or ""):
|
||||
raise NotFoundError("Ачивка не найдена.")
|
||||
return _root() / slug
|
||||
|
||||
|
||||
def _icon_path(slug: str) -> Path | None:
|
||||
folder = _dir(slug)
|
||||
if not folder.is_dir():
|
||||
return None
|
||||
for f in sorted(folder.glob("icon.*")):
|
||||
if f.is_file():
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _read(slug: str) -> dict:
|
||||
folder = _dir(slug)
|
||||
meta_file = folder / _META_FILE
|
||||
if not meta_file.is_file():
|
||||
raise NotFoundError("Ачивка не найдена.")
|
||||
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
condition = ""
|
||||
cond_file = folder / _CONDITION_FILE
|
||||
if cond_file.is_file():
|
||||
condition = cond_file.read_text(encoding="utf-8")
|
||||
icon = _icon_path(slug)
|
||||
icon_url = None
|
||||
if icon is not None:
|
||||
version = int(icon.stat().st_mtime)
|
||||
icon_url = f"/api/achievements/{slug}/icon?v={version}"
|
||||
return {
|
||||
"slug": slug,
|
||||
"name": meta.get("name", slug),
|
||||
"description": meta.get("description", ""),
|
||||
"condition": condition,
|
||||
"has_condition": bool(condition.strip()),
|
||||
"icon_url": icon_url,
|
||||
}
|
||||
|
||||
|
||||
def list_achievements() -> list[dict]:
|
||||
root = _root()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for folder in sorted(root.iterdir()):
|
||||
if folder.is_dir() and (folder / _META_FILE).is_file():
|
||||
out.append(_read(folder.name))
|
||||
return out
|
||||
|
||||
|
||||
def get(slug: str) -> dict:
|
||||
return _read(slug)
|
||||
|
||||
|
||||
def _validate_name(name: str) -> str:
|
||||
name = (name or "").strip()
|
||||
if not (1 <= len(name) <= 64):
|
||||
raise ValidationError("Название ачивки: 1–64 символа.")
|
||||
return name
|
||||
|
||||
|
||||
def create(name: str, description: str | None, condition: str | None) -> dict:
|
||||
name = _validate_name(name)
|
||||
slug = _slugify(name)
|
||||
if not slug:
|
||||
raise ValidationError("Не удалось сформировать идентификатор из названия.")
|
||||
folder = _dir(slug)
|
||||
if folder.exists():
|
||||
raise ConflictError("Ачивка с таким названием уже существует.")
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
_write_meta(slug, name, (description or "").strip())
|
||||
(folder / _CONDITION_FILE).write_text(
|
||||
condition if condition else _CONDITION_PLACEHOLDER, encoding="utf-8"
|
||||
)
|
||||
return _read(slug)
|
||||
|
||||
|
||||
def update(
|
||||
slug: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
condition: str | None = None,
|
||||
) -> dict:
|
||||
folder = _dir(slug)
|
||||
meta_file = folder / _META_FILE
|
||||
if not meta_file.is_file():
|
||||
raise NotFoundError("Ачивка не найдена.")
|
||||
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
# slug не меняем (он стабильный идентификатор/путь), даже если правят название.
|
||||
new_name = _validate_name(name) if name is not None else meta.get("name", slug)
|
||||
new_desc = description.strip() if description is not None else meta.get("description", "")
|
||||
_write_meta(slug, new_name, new_desc, created_at=meta.get("created_at"))
|
||||
if condition is not None:
|
||||
(folder / _CONDITION_FILE).write_text(condition, encoding="utf-8")
|
||||
return _read(slug)
|
||||
|
||||
|
||||
def set_icon(slug: str, content: bytes, ext: str) -> dict:
|
||||
folder = _dir(slug)
|
||||
if not (folder / _META_FILE).is_file():
|
||||
raise NotFoundError("Ачивка не найдена.")
|
||||
old = _icon_path(slug)
|
||||
if old is not None:
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
(folder / f"icon.{ext}").write_bytes(content)
|
||||
return _read(slug)
|
||||
|
||||
|
||||
def icon_file(slug: str) -> tuple[Path, str]:
|
||||
"""Путь к файлу иконки и его media_type; NotFound, если иконки нет."""
|
||||
icon = _icon_path(slug)
|
||||
if icon is None:
|
||||
raise NotFoundError("Иконка не найдена.")
|
||||
return icon, avatar_media_type(icon.suffix.lstrip("."))
|
||||
|
||||
|
||||
def delete(slug: str) -> None:
|
||||
folder = _dir(slug)
|
||||
if not folder.is_dir():
|
||||
raise NotFoundError("Ачивка не найдена.")
|
||||
shutil.rmtree(folder)
|
||||
|
||||
|
||||
def validate_icon(content: bytes) -> str:
|
||||
"""Проверяет, что это поддерживаемая картинка, и возвращает расширение."""
|
||||
ext = sniff_image_ext(content)
|
||||
if ext is None:
|
||||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||||
return ext
|
||||
|
||||
|
||||
def _write_meta(slug: str, name: str, description: str, created_at: str | None = None) -> None:
|
||||
meta = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"created_at": created_at or datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
(_dir(slug) / _META_FILE).write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
@@ -5,10 +5,17 @@ from typing import Any
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import InvalidCredentialsError, NotFoundError, ValidationError
|
||||
from app.core.errors import (
|
||||
ConflictError,
|
||||
InvalidCredentialsError,
|
||||
NicknameTakenError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.core.security import verify_password
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.models import AuditLog, Faction, Group, Match, MatchParticipant, User
|
||||
from app.services import user_service
|
||||
|
||||
|
||||
def authenticate_admin(session: Session, username: str, password: str) -> User:
|
||||
@@ -39,7 +46,14 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
||||
if user is None:
|
||||
raise NotFoundError("Пользователь не найден.")
|
||||
if nickname is not None:
|
||||
user.nickname = nickname.strip()
|
||||
nickname = nickname.strip()
|
||||
# Та же валидация, что и для самостоятельной смены ника игроком:
|
||||
# корректный формат и уникальность (иначе занятый ник упал бы в 500).
|
||||
if not user_service.nickname_format_ok(nickname):
|
||||
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
||||
if not user_service.nickname_available(session, nickname, exclude_user_id=user_id):
|
||||
raise NicknameTakenError()
|
||||
user.nickname = nickname
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
session.add(user)
|
||||
@@ -48,12 +62,20 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
||||
return user
|
||||
|
||||
|
||||
def delete_user(session: Session, user_id: int) -> None:
|
||||
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("Пользователь не найден.")
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
if user.role != "player":
|
||||
raise ValidationError("Пароль администратора здесь не меняется.")
|
||||
return user_service.set_password(session, user, new_password)
|
||||
|
||||
|
||||
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
||||
# логика вынесена в routers/dev_admin.py (файл исключён из прод-образа).
|
||||
|
||||
|
||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||
@@ -66,6 +88,10 @@ def delete_group(session: Session, group_id: int) -> None:
|
||||
group = session.get(Group, group_id)
|
||||
if group is None:
|
||||
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.commit()
|
||||
|
||||
@@ -125,11 +151,16 @@ def rename_faction(session: Session, faction_id: int, name_ru: str) -> Faction:
|
||||
|
||||
|
||||
def delete_match(session: Session, match_id: int) -> None:
|
||||
from app.services import attachment_service # избегаем цикла импорта
|
||||
|
||||
match = session.get(Match, match_id)
|
||||
if match is None:
|
||||
raise NotFoundError("Партия не найдена.")
|
||||
session.delete(match)
|
||||
session.commit()
|
||||
# Как и в игроцком пути (match_service.delete_match): строки вложений уходят
|
||||
# каскадом, а файлы с тома нужно убрать руками, иначе они остаются навсегда.
|
||||
attachment_service.delete_match_files(match_id)
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Объявления администрации (#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()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Вложения партии (фото). Файлы — на томе 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)
|
||||
@@ -18,3 +18,27 @@ def list_factions(session: Session) -> list[Faction]:
|
||||
select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
|
||||
).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)
|
||||
|
||||
@@ -97,6 +97,16 @@ def rename_group(session: Session, group: Group, name: str) -> 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:
|
||||
valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
|
||||
current = session.exec(
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Приглашения в группу: создание (любым участником), список, принятие, отклонение.
|
||||
|
||||
Хранятся только ожидающие приглашения; ответ удаляет строку (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()
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
@@ -16,12 +16,15 @@ from app.core.errors import (
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.core.timeutil import app_today
|
||||
from app.models import Faction, GroupMember, Match, MatchParticipant, User
|
||||
from app.core.timeutil import app_today, iso_utc, utcnow
|
||||
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
|
||||
from app.services import group_service
|
||||
from app.services.scoring import EXTENDED_ROUNDS, max_rounds
|
||||
|
||||
MAX_MATCH_PLAYERS = 6
|
||||
WIN_REASONS = ("objectives", "worlds", "plastic", "resources")
|
||||
LAST_STANDING = "last_standing"
|
||||
WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING)
|
||||
MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -38,24 +41,26 @@ class FinishInput:
|
||||
"""Результат участника на этапе завершения."""
|
||||
|
||||
user_id: int
|
||||
place: int
|
||||
place: int | None = None # None у выбывшего (eliminated) — место проставится авто
|
||||
eliminated: bool = False
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
objectives: int | None = None
|
||||
worlds: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParticipantInput:
|
||||
"""Полный участник (для правки завершённой партии админом)."""
|
||||
"""Полный участник (для правки результатов завершённой партии)."""
|
||||
|
||||
user_id: int
|
||||
faction_id: int
|
||||
place: int
|
||||
place: int | None = None
|
||||
eliminated: bool = False
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
objectives: int | None = None
|
||||
worlds: int | None = None
|
||||
|
||||
|
||||
def round_to_30(minutes: float) -> int:
|
||||
@@ -70,6 +75,31 @@ def get_match(session: Session, match_id: int) -> 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(
|
||||
session: Session, match_id: int
|
||||
) -> list[tuple[MatchParticipant, User, Faction]]:
|
||||
@@ -115,6 +145,59 @@ def _validate_ranking(places: list[int]) -> None:
|
||||
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]:
|
||||
return {
|
||||
m.user_id
|
||||
@@ -129,16 +212,28 @@ def _validate_roster_basics(
|
||||
group_id: int,
|
||||
user_ids: list[int],
|
||||
faction_ids: list[int],
|
||||
*,
|
||||
keep_user_ids: set[int] | None = None,
|
||||
keep_faction_ids: set[int] | None = None,
|
||||
) -> None:
|
||||
"""Состав партии: размер, отсутствие дублей, принадлежность группе.
|
||||
|
||||
keep_* — то, что уже записано в правимой партии: такие игроки и фракции проходят
|
||||
независимо от текущего состава группы. Иначе отключённое дополнение или ушедший из
|
||||
группы игрок делали бы старую партию неисправимой навсегда."""
|
||||
if len(user_ids) < 2:
|
||||
raise ValidationError("В партии должно быть не менее 2 участников.")
|
||||
if len(user_ids) > MAX_MATCH_PLAYERS:
|
||||
raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.")
|
||||
if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids):
|
||||
raise DuplicateParticipantError()
|
||||
if not set(user_ids).issubset(_group_member_ids(session, group_id)):
|
||||
allowed_users = _group_member_ids(session, group_id) | (keep_user_ids or set())
|
||||
if not set(user_ids).issubset(allowed_users):
|
||||
raise ValidationError("Все участники должны состоять в группе.")
|
||||
if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)):
|
||||
allowed_factions = group_service.available_faction_ids(session, group_id) | (
|
||||
keep_faction_ids or set()
|
||||
)
|
||||
if not set(faction_ids).issubset(allowed_factions):
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
|
||||
@@ -156,13 +251,16 @@ def create_match(
|
||||
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(
|
||||
group_id=group_id,
|
||||
status="in_progress",
|
||||
played_at=app_today(), # дата игры — в поясе приложения (+3)
|
||||
started_at=now,
|
||||
player_count=len(roster),
|
||||
# Снимок: смена настройки группы потом не переписывает лимит раундов этой партии.
|
||||
nine_rounds_rule=group.nine_rounds_rule,
|
||||
created_by=creator.id, # type: ignore[arg-type]
|
||||
)
|
||||
session.add(match)
|
||||
@@ -191,13 +289,17 @@ def finish_match(
|
||||
*,
|
||||
finish: list[FinishInput],
|
||||
win_reason: str,
|
||||
end_round: int | None = None,
|
||||
overall_comment: str | None = None,
|
||||
overall_comment_set: bool = False,
|
||||
expected_version: str | None = None,
|
||||
) -> Match:
|
||||
assert_version(match, expected_version)
|
||||
if match.status != "in_progress":
|
||||
raise ConflictError("Партия уже завершена.")
|
||||
if win_reason not in WIN_REASONS:
|
||||
raise ValidationError("Укажите корректную причину победы.")
|
||||
_check_end_round(end_round, match.player_count, match.nine_rounds_rule)
|
||||
|
||||
existing = {
|
||||
p.user_id: p
|
||||
@@ -219,17 +321,22 @@ def finish_match(
|
||||
if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)):
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
_validate_ranking([f.place for f in finish])
|
||||
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) 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:
|
||||
p = existing[f.user_id]
|
||||
p.place = f.place
|
||||
p.place = places[f.user_id]
|
||||
p.eliminated = f.eliminated
|
||||
p.comment = f.comment or None
|
||||
p.objectives = f.objectives
|
||||
p.worlds = worlds[f.user_id]
|
||||
if f.faction_id is not None:
|
||||
p.faction_id = f.faction_id
|
||||
session.add(p)
|
||||
|
||||
now = _utcnow()
|
||||
now = utcnow()
|
||||
match.finished_at = now
|
||||
started = match.started_at
|
||||
if started is not None:
|
||||
@@ -240,15 +347,123 @@ def finish_match(
|
||||
match.duration_minutes = round_to_30(elapsed_min)
|
||||
match.status = "finished"
|
||||
match.win_reason = win_reason
|
||||
match.end_round = end_round
|
||||
if overall_comment_set:
|
||||
match.overall_comment = overall_comment or None
|
||||
|
||||
session.add(match)
|
||||
# Содержимое черновика уже в результатах — второй источник правды не нужен.
|
||||
clear_finish_draft(session, match.id) # type: ignore[arg-type]
|
||||
session.commit()
|
||||
session.refresh(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:
|
||||
@@ -273,29 +488,74 @@ def update_match(
|
||||
overall_comment_set: bool = False,
|
||||
win_reason: str | None = None,
|
||||
win_reason_set: bool = False,
|
||||
end_round: int | None = None,
|
||||
end_round_set: bool = False,
|
||||
participants: list[ParticipantInput] | None = None,
|
||||
expected_version: str | None = None,
|
||||
) -> Match:
|
||||
"""Правка завершённой партии (админ): полный список участников с местами."""
|
||||
if played_at is not None:
|
||||
match.played_at = played_at
|
||||
if overall_comment_set:
|
||||
match.overall_comment = overall_comment or None
|
||||
if win_reason_set:
|
||||
if win_reason is not None and win_reason not in WIN_REASONS:
|
||||
raise ValidationError("Некорректная причина победы.")
|
||||
match.win_reason = win_reason
|
||||
"""Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд.
|
||||
|
||||
Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию:
|
||||
иначе они оседали бы в партии со статусом 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},
|
||||
)
|
||||
_validate_ranking([p.place for p in participants])
|
||||
for old in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all():
|
||||
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:
|
||||
match.played_at = played_at
|
||||
if overall_comment_set:
|
||||
match.overall_comment = overall_comment or None
|
||||
if win_reason_set:
|
||||
match.win_reason = win_reason
|
||||
if end_round_set:
|
||||
match.end_round = end_round
|
||||
|
||||
if participants is not None:
|
||||
for old in saved:
|
||||
session.delete(old)
|
||||
session.flush()
|
||||
for p in participants:
|
||||
@@ -304,19 +564,27 @@ def update_match(
|
||||
match_id=match.id, # type: ignore[arg-type]
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
place=places[p.user_id],
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment or None,
|
||||
objectives=p.objectives,
|
||||
worlds=worlds[p.user_id],
|
||||
)
|
||||
)
|
||||
match.player_count = len(participants)
|
||||
|
||||
session.add(match)
|
||||
touch(session, match)
|
||||
session.commit()
|
||||
session.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
def delete_match(session: Session, match: Match) -> None:
|
||||
session.delete(match) # участники удалятся каскадом (FK ON DELETE CASCADE)
|
||||
def delete_match(session: Session, match: Match, expected_version: str | None = None) -> None:
|
||||
from app.services import attachment_service # избегаем цикла импорта
|
||||
|
||||
assert_version(match, expected_version)
|
||||
match_id = match.id
|
||||
session.delete(match) # участники и вложения (БД) удалятся каскадом (FK ON DELETE CASCADE)
|
||||
session.commit()
|
||||
attachment_service.delete_match_files(match_id) # type: ignore[arg-type] # файлы с диска
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||
from app.models import Group, GroupMember, User
|
||||
from app.services import group_service
|
||||
|
||||
MAX_GROUP_SIZE = 10
|
||||
|
||||
@@ -33,11 +34,7 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
|
||||
if member_count >= MAX_GROUP_SIZE:
|
||||
raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).")
|
||||
|
||||
existing = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user.id
|
||||
)
|
||||
).first()
|
||||
existing = group_service.get_membership(session, group.id, user.id)
|
||||
if existing is not None:
|
||||
raise ConflictError("Игрок уже в группе.")
|
||||
|
||||
@@ -48,22 +45,26 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
|
||||
return member, user
|
||||
|
||||
|
||||
def remove_member(session: Session, group: Group, user_id: int) -> None:
|
||||
member = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
if member is None:
|
||||
raise NotFoundError("Игрок не состоит в группе.")
|
||||
if member.role == "owner":
|
||||
def _assert_not_last_owner(session: Session, group: Group, member: GroupMember, message: str) -> None:
|
||||
"""Группа без владельца неисправима: назначить нового становится некому."""
|
||||
if member.role != "owner":
|
||||
return
|
||||
owners = session.exec(
|
||||
select(GroupMember).where(
|
||||
select(GroupMember.id).where(
|
||||
GroupMember.group_id == group.id, GroupMember.role == "owner"
|
||||
)
|
||||
).all()
|
||||
if len(owners) <= 1:
|
||||
raise ForbiddenError("Нельзя удалить последнего владельца группы.")
|
||||
raise ForbiddenError(message)
|
||||
|
||||
|
||||
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)
|
||||
@@ -78,13 +79,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:
|
||||
if role not in ("owner", "member"):
|
||||
raise ValidationError("Недопустимая роль.")
|
||||
member = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
member = group_service.get_membership(session, group.id, user_id)
|
||||
if member is None:
|
||||
raise NotFoundError("Игрок не состоит в группе.")
|
||||
if role != "owner":
|
||||
_assert_not_last_owner(
|
||||
session, group, member, "Нельзя снять роль с последнего владельца группы."
|
||||
)
|
||||
member.role = role
|
||||
session.add(member)
|
||||
session.commit()
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Персистентные уведомления игрокам (приглашения, старт/финиш партии, титулы).
|
||||
|
||||
Запись в БД + живой сигнал по 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",
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Публикация 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"})
|
||||
+205
-17
@@ -1,32 +1,220 @@
|
||||
"""Метрика рейтинга. Вынесена отдельно — легко заменить.
|
||||
"""Метрика рейтинга: многопользовательский Elo с множителем отрыва (#22, #23).
|
||||
|
||||
По умолчанию: League Points — нормированные очки за место с учётом размера стола
|
||||
и ничьих (competition ranking). За партию из N игроков:
|
||||
points = (N - place - (tie_size - 1)/2) / (N - 1)
|
||||
1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну.
|
||||
Рейтинговый счёт игрока = AVG(points) * 100.
|
||||
Полное описание, обоснование коэффициентов и примеры — docs/rating/rating-system.md;
|
||||
эталонная реализация тех же формул — docs/rating/simulate.py (тесты сверяют с ней).
|
||||
|
||||
Партия раскладывается на пары игроков. Для пары a (выше или наравне) и b:
|
||||
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
|
||||
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 collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from itertools import combinations
|
||||
|
||||
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
|
||||
MIN_GAMES = 3
|
||||
MIN_GAMES = 10
|
||||
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
|
||||
FACTION_MIN_GAMES = 2
|
||||
|
||||
# SQL-выражение очков за участие (tie-aware). Использует поля m.player_count,
|
||||
# mp.place и t.tie_size (размер группы игроков с тем же местом в партии).
|
||||
MATCH_POINTS_SQL = (
|
||||
"CASE WHEN m.player_count > 1 "
|
||||
"THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) "
|
||||
"/ (m.player_count - 1) "
|
||||
"ELSE 1.0 END"
|
||||
)
|
||||
# ─── Правила игры ────────────────────────────────────────────────────────────
|
||||
|
||||
# Размер поля в тайлах по числу игроков (дуэль — 2×3, шестеро — 4×5).
|
||||
BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20}
|
||||
WORLDS_PER_TILE = 2.2
|
||||
BASE_ROUNDS = 8
|
||||
# Домашнее правило группы: при 5–6 игроках играется 9 раундов.
|
||||
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:
|
||||
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
|
||||
"""Ключ сортировки топа: рейтинг ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.
|
||||
|
||||
row["rating"] — рейтинг без округления: два игрока с одинаковым целым в топе
|
||||
всё равно упорядочены по настоящему значению."""
|
||||
return (
|
||||
-(row["score"] or 0.0),
|
||||
-(row["rating"] or 0.0),
|
||||
-(row["win_rate"] or 0.0),
|
||||
-(row["games"] or 0),
|
||||
(row["avg_place"] or 0.0),
|
||||
|
||||
@@ -1,95 +1,183 @@
|
||||
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен)."""
|
||||
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).
|
||||
|
||||
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
|
||||
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
|
||||
считаются игры, победы, среднее место и разбивки. Рейтинг у игрока один — по всем
|
||||
партиям приложения (#80). Страница группы берёт из него только рейтинг, а игры, победы,
|
||||
винрейт и среднее место считает по партиям группы."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.models import Group, GroupMember, Match
|
||||
from app.services import group_service
|
||||
from app.models import Expansion, Faction, Group, GroupMember, Match, MatchParticipant, User
|
||||
from app.services import faction_service, group_service, membership_service, user_service
|
||||
from app.services.scoring import (
|
||||
FACTION_MIN_GAMES,
|
||||
MATCH_POINTS_SQL,
|
||||
MIN_GAMES,
|
||||
RatedMatch,
|
||||
RatedSeat,
|
||||
Replay,
|
||||
leaderboard_sort_key,
|
||||
replay,
|
||||
)
|
||||
|
||||
# Базовый блок: одна строка на участие с tie-aware очками.
|
||||
# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят).
|
||||
SCORED_CTE = f"""
|
||||
WITH tie AS (
|
||||
SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size
|
||||
FROM match_participants mp
|
||||
JOIN matches m ON m.id = mp.match_id
|
||||
WHERE m.status = 'finished' AND mp.place IS NOT NULL
|
||||
GROUP BY mp.match_id, mp.place
|
||||
),
|
||||
scored AS (
|
||||
SELECT mp.user_id AS user_id,
|
||||
mp.faction_id AS faction_id,
|
||||
m.id AS match_id,
|
||||
m.group_id AS group_id,
|
||||
m.played_at AS played_at,
|
||||
mp.place AS place,
|
||||
m.player_count AS player_count,
|
||||
({MATCH_POINTS_SQL}) AS points,
|
||||
CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win
|
||||
FROM match_participants mp
|
||||
JOIN matches m ON m.id = mp.match_id
|
||||
JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place
|
||||
WHERE m.status = 'finished'
|
||||
|
||||
MIN_PARTICIPANTS = 2 # партия, где осталось меньше участников, партией не считается
|
||||
|
||||
|
||||
def _playable_match_ids():
|
||||
"""Подзапрос id партий, в которых не меньше MIN_PARTICIPANTS участников.
|
||||
|
||||
Партия может «опустеть» в деве: жёсткое удаление аккаунта (routers/dev_admin.py)
|
||||
вычёркивает игрока из партий и не пересчитывает их. Партия с одним участником —
|
||||
уже не игра: её нет ни в рейтинге, ни в историях и списках (админка её видит)."""
|
||||
return (
|
||||
select(MatchParticipant.match_id)
|
||||
.group_by(MatchParticipant.match_id)
|
||||
.having(func.count() >= MIN_PARTICIPANTS)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _round(value: Any, ndigits: int) -> float | None:
|
||||
return None if value is None else round(float(value), ndigits)
|
||||
def load_history(session: Session) -> list[RatedMatch]:
|
||||
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
|
||||
|
||||
Один запрос на партии с участниками. 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 _normalize(row: dict) -> dict:
|
||||
def _for_group(history: list[RatedMatch], group_id: int) -> list[RatedMatch]:
|
||||
return [m for m in history if m.group_id == group_id]
|
||||
|
||||
|
||||
def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatch, RatedSeat]]:
|
||||
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 {
|
||||
"user_id": row["user_id"],
|
||||
"nickname": row["nickname"],
|
||||
"games": int(row["games"] or 0),
|
||||
"wins": int(row["wins"] or 0),
|
||||
"win_rate": _round(row["win_rate"] or 0.0, 4),
|
||||
"avg_place": _round(row["avg_place"], 2),
|
||||
"score": _round(row["score"], 1),
|
||||
"games": games,
|
||||
"wins": wins,
|
||||
"win_rate": round(wins / games, 4),
|
||||
"avg_place": round(sum(s.place for _m, s in seats) / games, 2),
|
||||
"score": None if rating is None else round(rating),
|
||||
}
|
||||
|
||||
|
||||
def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
|
||||
where = "WHERE s.group_id = :gid" if group_id is not None else ""
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT u.id AS user_id, u.nickname AS nickname,
|
||||
COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
{where}
|
||||
GROUP BY u.id, u.nickname
|
||||
"""
|
||||
params = {"gid": group_id} if group_id is not None else {}
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
return [_normalize(dict(r)) for r in result]
|
||||
def leaderboard(
|
||||
session: Session,
|
||||
group_id: int | None = None,
|
||||
*,
|
||||
history: list[RatedMatch] | None = None,
|
||||
rep: Replay | None = None,
|
||||
member_ids: set[int] | None = None,
|
||||
) -> dict:
|
||||
"""Топ: общий или группы. history/rep — вся история и её проигрывание (home и
|
||||
group_stats их переиспользуют).
|
||||
|
||||
Рейтинг и статус «Новичок» — всегда общие: статус описывает надёжность рейтинга, а он
|
||||
считается по всем партиям. С 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)
|
||||
|
||||
def leaderboard(session: Session, group_id: int | None = None) -> dict:
|
||||
rows = _leaderboard_rows(session, group_id)
|
||||
qualified = [r for r in rows if r["games"] >= MIN_GAMES]
|
||||
provisional = [r for r in rows if r["games"] < MIN_GAMES]
|
||||
qualified.sort(key=leaderboard_sort_key)
|
||||
provisional.sort(key=leaderboard_sort_key)
|
||||
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
|
||||
for m in shown:
|
||||
for s in m.seats:
|
||||
if member_ids is None or s.user_id in member_ids:
|
||||
by_user[s.user_id].append((m, s))
|
||||
users = (
|
||||
{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):
|
||||
r["rank"] = i
|
||||
for r in provisional:
|
||||
r["rank"] = None
|
||||
for r in rows:
|
||||
del r["rating"]
|
||||
return {
|
||||
"entries": qualified,
|
||||
"provisional": provisional,
|
||||
@@ -97,180 +185,229 @@ def leaderboard(session: Session, group_id: int | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> dict:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
{cond}
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
r = session.execute(text(sql), params).mappings().first() or {}
|
||||
return {
|
||||
"games": int(r.get("games") or 0),
|
||||
"wins": int(r.get("wins") or 0),
|
||||
"win_rate": _round(r.get("win_rate") or 0.0, 4),
|
||||
"avg_place": _round(r.get("avg_place"), 2),
|
||||
"score": _round(r.get("score"), 1),
|
||||
def _faction_breakdown(
|
||||
session: Session, seats: list[tuple[RatedMatch, RatedSeat]], rep: Replay, user_id: int
|
||||
) -> list[dict]:
|
||||
by_faction: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
|
||||
for m, s in seats:
|
||||
by_faction[s.faction_id].append((m, s))
|
||||
if not by_faction:
|
||||
return []
|
||||
meta = {
|
||||
f.id: (f, code)
|
||||
for f, code in session.exec(
|
||||
select(Faction, Expansion.code)
|
||||
.join(Expansion, Expansion.id == Faction.expansion_id)
|
||||
.where(Faction.id.in_(list(by_faction)))
|
||||
).all()
|
||||
}
|
||||
|
||||
|
||||
def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> list[dict]:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||
e.code AS expansion_code,
|
||||
COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
JOIN factions f ON f.id = s.faction_id
|
||||
JOIN expansions e ON e.id = f.expansion_id
|
||||
{cond}
|
||||
GROUP BY f.id, f.code, f.name_ru, e.code
|
||||
ORDER BY games DESC, score DESC
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
out = []
|
||||
for r in result:
|
||||
for fid, group in by_faction.items():
|
||||
faction, expansion_code = meta[fid]
|
||||
perf = sum(rep.perf[(m.id, user_id)] for m, _s in group) / len(group)
|
||||
out.append(
|
||||
{
|
||||
"faction_id": r["faction_id"],
|
||||
"code": r["code"],
|
||||
"name_ru": r["name_ru"],
|
||||
"expansion_code": r["expansion_code"],
|
||||
"games": int(r["games"] or 0),
|
||||
"wins": int(r["wins"] or 0),
|
||||
"win_rate": _round(r["win_rate"] or 0.0, 4),
|
||||
"avg_place": _round(r["avg_place"], 2),
|
||||
"score": _round(r["score"], 1),
|
||||
"faction_id": fid,
|
||||
"code": faction.code,
|
||||
"name_ru": faction.name_ru,
|
||||
"expansion_code": expansion_code,
|
||||
**{k: v for k, v in _summary(group, None).items() if k != "score"},
|
||||
# Средний результат относительно ожидания (S − E) × 100: насколько игрок
|
||||
# на фракции выступает выше рейтинговых шансов — без привязки к рейтингу.
|
||||
"score": round(perf * 100, 1),
|
||||
"name_ru_prepositional": faction_service.prepositional(
|
||||
faction.code, faction.name_ru
|
||||
),
|
||||
}
|
||||
)
|
||||
out.sort(key=lambda f: (-f["games"], -(f["score"] or 0.0)))
|
||||
return out
|
||||
|
||||
|
||||
def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT s.place AS place, s.player_count AS player_count, s.played_at AS played_at
|
||||
FROM scored s
|
||||
{cond}
|
||||
ORDER BY s.played_at DESC, s.match_id DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id, "lim": limit}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
def _recent_form(seats: list[tuple[RatedMatch, RatedSeat]], limit: int = 5) -> list[dict]:
|
||||
recent = sorted(seats, key=lambda ms: (str(ms[0].played_at), ms[0].id), reverse=True)
|
||||
return [
|
||||
{"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])}
|
||||
for r in result
|
||||
{
|
||||
"place": s.place,
|
||||
"player_count": len(m.seats),
|
||||
"played_at": str(m.played_at),
|
||||
}
|
||||
for m, s in recent[:limit]
|
||||
]
|
||||
|
||||
|
||||
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
|
||||
overall = _overall_for_user(session, user_id, group_id)
|
||||
factions = _faction_breakdown(session, user_id, group_id)
|
||||
def _favorite_faction(session: Session, user_id: int) -> dict | None:
|
||||
"""Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики."""
|
||||
user = session.get(User, user_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]
|
||||
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||
most_played = max(factions, key=lambda f: f["games"]) if factions else None
|
||||
best = max(qualified, key=lambda f: f["score"]) if qualified else None
|
||||
worst = min(qualified, key=lambda f: f["score"]) if qualified else None
|
||||
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая
|
||||
# рандомные раздачи.
|
||||
main = max(factions, key=lambda f: f["games"]) if factions else None
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"overall": overall,
|
||||
"overall": _summary(seats, rep.ratings.get(user_id)),
|
||||
"factions": factions,
|
||||
"best_faction": best,
|
||||
"worst_faction": worst,
|
||||
"most_played_faction": most_played,
|
||||
"recent_form": _recent_form(session, user_id, group_id),
|
||||
"favorite_faction": _favorite_faction(session, user_id),
|
||||
"main_faction": main,
|
||||
"recent_form": _recent_form(seats),
|
||||
"min_games": MIN_GAMES,
|
||||
}
|
||||
|
||||
|
||||
def group_stats(session: Session, group_id: int) -> dict:
|
||||
board = leaderboard(session, group_id=group_id)
|
||||
total_matches = session.exec(
|
||||
select(Match).where(Match.group_id == group_id, Match.status == "finished")
|
||||
).all()
|
||||
last_at = None
|
||||
if total_matches:
|
||||
last_at = str(max(m.played_at for m in total_matches))
|
||||
history = load_history(session)
|
||||
rep = replay(history)
|
||||
group_history = _for_group(history, group_id)
|
||||
members = membership_service.list_members(session, group_id)
|
||||
# Список игроков группы — только её текущий состав: удалённый из группы в нём не висит.
|
||||
board = leaderboard(
|
||||
session,
|
||||
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)
|
||||
faction_meta = []
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||
COUNT(s.user_id) AS games, SUM(s.is_win) AS wins
|
||||
FROM factions f
|
||||
LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid
|
||||
GROUP BY f.id, f.code, f.name_ru
|
||||
ORDER BY games DESC, f.sort_order
|
||||
"""
|
||||
rows = session.execute(text(sql), {"gid": group_id}).mappings().all()
|
||||
for r in rows:
|
||||
faction_meta.append(
|
||||
factions = sorted(
|
||||
session.exec(select(Faction)).all(), key=lambda f: (-games[f.id], f.sort_order) # type: ignore[index]
|
||||
)
|
||||
faction_meta = [
|
||||
{
|
||||
"faction_id": r["faction_id"],
|
||||
"code": r["code"],
|
||||
"name_ru": r["name_ru"],
|
||||
"games": int(r["games"] or 0),
|
||||
"wins": int(r["wins"] or 0),
|
||||
"available": r["faction_id"] in available_ids,
|
||||
"faction_id": f.id,
|
||||
"code": f.code,
|
||||
"name_ru": f.name_ru,
|
||||
"games": games[f.id], # type: ignore[index]
|
||||
"wins": wins[f.id], # type: ignore[index]
|
||||
"available": f.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 {
|
||||
"group_id": group_id,
|
||||
"total_matches": len(total_matches),
|
||||
"last_match_at": last_at,
|
||||
"total_matches": len(group_history),
|
||||
"last_match_at": str(last_played) if last_played else None,
|
||||
"leaderboard": board["entries"],
|
||||
"provisional": board["provisional"],
|
||||
"inactive": inactive,
|
||||
"faction_meta": faction_meta,
|
||||
"min_games": MIN_GAMES,
|
||||
}
|
||||
|
||||
|
||||
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
||||
|
||||
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
|
||||
matches = session.exec(
|
||||
select(Match)
|
||||
.where(Match.group_id == group_id)
|
||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
items = []
|
||||
for m in matches:
|
||||
parts = []
|
||||
for p, u, f in participants_detail(session, m.id): # type: ignore[arg-type]
|
||||
parts.append(
|
||||
{
|
||||
def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
|
||||
return {
|
||||
"user_id": u.id,
|
||||
"nickname": u.nickname,
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"eliminated": p.eliminated,
|
||||
"was_random": p.was_random,
|
||||
"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(
|
||||
{
|
||||
"id": m.id,
|
||||
@@ -284,15 +421,86 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
|
||||
"overall_comment": m.overall_comment,
|
||||
"created_by": m.created_by,
|
||||
"participants": parts,
|
||||
"rating_delta": None if rating_deltas is None else rating_deltas.get(m.id),
|
||||
}
|
||||
)
|
||||
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
||||
return items
|
||||
|
||||
|
||||
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]:
|
||||
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
|
||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
||||
|
||||
group_ids = list(
|
||||
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
|
||||
)
|
||||
@@ -304,20 +512,10 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
||||
.where(Match.status == "in_progress", Match.group_id.in_(group_ids))
|
||||
.order_by(Match.started_at.desc(), Match.id.desc())
|
||||
).all()
|
||||
by_match = _participants_by_match(session, [m.id for m, _ in rows])
|
||||
out = []
|
||||
for m, gname in rows:
|
||||
parts = [
|
||||
{
|
||||
"user_id": u.id,
|
||||
"nickname": u.nickname,
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
for p, u, f in participants_detail(session, m.id) # type: ignore[arg-type]
|
||||
]
|
||||
parts = by_match.get(m.id, [])
|
||||
out.append(
|
||||
{
|
||||
"id": m.id,
|
||||
@@ -332,8 +530,12 @@ 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:
|
||||
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
|
||||
if active_group_id is not None:
|
||||
group = session.get(Group, active_group_id)
|
||||
@@ -341,7 +543,7 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
|
||||
active_group_brief = {
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
**_overall_for_user(session, user_id, active_group_id),
|
||||
**_overall(_for_group(history, active_group_id), rep, user_id),
|
||||
}
|
||||
return {
|
||||
"leaderboard": board["entries"][:leaderboard_limit],
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
"""Пользователи: создание из внешней личности, ник, активная группа."""
|
||||
"""Пользователи: создание из внешней личности, ник, активная группа, профиль."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.password import validate_new_password
|
||||
from app.auth.provider import ExternalIdentity
|
||||
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
||||
from app.models import AuthIdentity, GroupMember, User
|
||||
from app.core.config import settings
|
||||
from app.core.errors import (
|
||||
NicknameTakenError,
|
||||
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)
|
||||
_BIO_MAX = 500
|
||||
|
||||
# Аватары: файл на диске под settings.upload_dir/avatars, путь относительный.
|
||||
_AVATAR_SUBDIR = "avatars"
|
||||
_IMAGE_MEDIA = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||
|
||||
|
||||
def get_user(session: Session, user_id: int) -> User:
|
||||
@@ -25,6 +42,33 @@ def nickname_available(session: Session, nickname: str, exclude_user_id: int | N
|
||||
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:
|
||||
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
|
||||
return bool(_NICK_RE.match((nickname or "").strip()))
|
||||
|
||||
|
||||
def _unique_nickname(session: Session, base: str) -> str:
|
||||
base = (base or "Игрок").strip()[:60] or "Игрок"
|
||||
candidate = base
|
||||
@@ -35,18 +79,24 @@ def _unique_nickname(session: Session, base: str) -> str:
|
||||
return candidate
|
||||
|
||||
|
||||
def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User:
|
||||
def find_by_identity(session: Session, identity: ExternalIdentity) -> User | None:
|
||||
"""Существующий пользователь по внешней личности (provider+external_id).
|
||||
|
||||
Для Telegram external_id — это НЕИЗМЕНЯЕМЫЙ числовой id аккаунта (не @тег), поэтому
|
||||
вход с любого устройства/после смены тега ведёт к одному и тому же пользователю.
|
||||
"""
|
||||
link = session.exec(
|
||||
select(AuthIdentity).where(
|
||||
AuthIdentity.provider == identity.provider,
|
||||
AuthIdentity.external_id == identity.external_id,
|
||||
)
|
||||
).first()
|
||||
if link is not None:
|
||||
return get_user(session, link.user_id)
|
||||
return get_user(session, link.user_id) if link is not None else None
|
||||
|
||||
|
||||
def _create_from_identity(session: Session, identity: ExternalIdentity, nickname: str) -> User:
|
||||
user = User(
|
||||
nickname=_unique_nickname(session, identity.suggested_nickname or identity.external_id),
|
||||
nickname=nickname,
|
||||
role="player",
|
||||
auth_provider=identity.provider,
|
||||
telegram_id=identity.telegram_id,
|
||||
@@ -65,9 +115,98 @@ def get_or_create_from_identity(session: Session, identity: ExternalIdentity) ->
|
||||
return user
|
||||
|
||||
|
||||
def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User:
|
||||
"""Найти по личности или создать, авто-подбирая уникальный ник (dev-вход по нику)."""
|
||||
user = find_by_identity(session, identity)
|
||||
if user is not None:
|
||||
return user
|
||||
nickname = _unique_nickname(session, identity.suggested_nickname or identity.external_id)
|
||||
return _create_from_identity(session, identity, nickname)
|
||||
|
||||
|
||||
def register_from_identity(
|
||||
session: Session, identity: ExternalIdentity, nickname: str
|
||||
) -> User:
|
||||
"""Создать пользователя с ЯВНО выбранным ником (регистрация через Telegram).
|
||||
|
||||
В отличие от get_or_create — ник не подбирается автоматически: занятый/неподходящий
|
||||
ник вызывает ошибку, чтобы пользователь выбрал другой.
|
||||
"""
|
||||
nickname = (nickname or "").strip()
|
||||
if not nickname_format_ok(nickname):
|
||||
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
||||
if not nickname_available(session, nickname):
|
||||
raise NicknameTakenError()
|
||||
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:
|
||||
new_nickname = (new_nickname or "").strip()
|
||||
if not _NICK_RE.match(new_nickname):
|
||||
if not nickname_format_ok(new_nickname):
|
||||
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
||||
if not nickname_available(session, new_nickname, exclude_user_id=user.id):
|
||||
raise NicknameTakenError()
|
||||
@@ -80,15 +219,166 @@ def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
||||
|
||||
def set_active_group(session: Session, user: User, group_id: int | None) -> User:
|
||||
if group_id is not None:
|
||||
member = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group_id, GroupMember.user_id == user.id
|
||||
)
|
||||
).first()
|
||||
if member is None:
|
||||
from app.services import group_service # избегаем цикла импорта
|
||||
|
||||
if group_service.get_membership(session, group_id, user.id) is None:
|
||||
raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.")
|
||||
user.active_group_id = group_id
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
# ─── Профиль: «о себе», аватар ────────────────────────────────────────────────
|
||||
|
||||
def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime | None) -> str | None:
|
||||
"""URL аватара с кэш-бастингом по updated_at. None, если аватара нет.
|
||||
|
||||
Один источник для build_me, публичного профиля и лидерборда — чтобы клиент
|
||||
подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
|
||||
if not avatar_path:
|
||||
return None
|
||||
# В БД время наивное и хранится в UTC. .timestamp() у наивного значения считает
|
||||
# его локальным, и версия разъезжалась с лидербордом, где то же поле считает 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}"
|
||||
|
||||
|
||||
def update_bio(session: Session, user: User, bio: str | None) -> User:
|
||||
bio = (bio or "").strip()
|
||||
if len(bio) > _BIO_MAX:
|
||||
raise ValidationError(f"«О себе»: не более {_BIO_MAX} символов.")
|
||||
user.bio = bio or None
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(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:
|
||||
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
|
||||
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "png"
|
||||
if content.startswith(b"\xff\xd8\xff"):
|
||||
return "jpg"
|
||||
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
|
||||
return "webp"
|
||||
return None
|
||||
|
||||
|
||||
def avatar_media_type(ext: str) -> str:
|
||||
return _IMAGE_MEDIA.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
def avatar_abs_path(avatar_path: str | None) -> Path | None:
|
||||
"""Абсолютный путь файла аватара (или None), для отдачи через FileResponse."""
|
||||
if not avatar_path:
|
||||
return None
|
||||
return Path(settings.upload_dir) / avatar_path
|
||||
|
||||
|
||||
def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
|
||||
avatars_dir = Path(settings.upload_dir) / _AVATAR_SUBDIR
|
||||
avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Удаляем прежний файл (мог быть с другим расширением), затем пишем новый.
|
||||
old = avatar_abs_path(user.avatar_path)
|
||||
if old and old.exists():
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
|
||||
(Path(settings.upload_dir) / rel).write_bytes(content)
|
||||
user.avatar_path = rel
|
||||
# Файл перезаписывается по тому же пути, поэтому при том же расширении avatar_path
|
||||
# не меняется, UPDATE не эмитится и onupdate не срабатывает. Без явного бампа
|
||||
# кэш-бастер остаётся прежним, и браузер час показывает старую картинку.
|
||||
user.updated_at = utcnow()
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def clear_avatar(session: Session, user: User) -> User:
|
||||
path = avatar_abs_path(user.avatar_path)
|
||||
if path and path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
user.avatar_path = None
|
||||
user.updated_at = utcnow()
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def public_profile(session: Session, user_id: int) -> dict:
|
||||
"""Публичный профиль игрока: ник/«о себе»/аватар + глобальная статистика."""
|
||||
from app.services import stats_service # ленивый импорт (избегаем цикла)
|
||||
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("Пользователь не найден.")
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"nickname": user.nickname,
|
||||
"bio": user.bio,
|
||||
"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),
|
||||
}
|
||||
|
||||
+12
-2
@@ -9,6 +9,16 @@ python -m app.bootstrap
|
||||
|
||||
echo "[entrypoint] Запуск сервера…"
|
||||
# --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 \
|
||||
--proxy-headers --forwarded-allow-ips="*"
|
||||
--proxy-headers --forwarded-allow-ips="$FORWARDED_ALLOW_IPS" --timeout-graceful-shutdown 10
|
||||
|
||||
@@ -15,13 +15,15 @@ os.environ["FS_STARTUP_BOOTSTRAP"] = "0" # тесты сами поднимаю
|
||||
import pytest # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
from sqlmodel import Session, SQLModel, create_engine # noqa: E402
|
||||
from sqlmodel import Session, SQLModel, create_engine, select # noqa: 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.token_revocation import revoked_tokens # noqa: E402
|
||||
from app.db.session import get_session # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.models import User # noqa: E402
|
||||
from app.models import AuthIdentity, GroupMember, User # noqa: E402
|
||||
from app.seed.reference_data import seed_reference_data # noqa: E402
|
||||
|
||||
|
||||
@@ -48,6 +50,8 @@ def client(engine):
|
||||
yield s
|
||||
|
||||
app.dependency_overrides[get_session] = _get_session
|
||||
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
||||
revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
@@ -86,6 +90,32 @@ def login(client: TestClient, nickname: str) -> dict:
|
||||
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]):
|
||||
"""roster: [{user_id, faction_id, was_random?}] → ответ старта (in_progress)."""
|
||||
return client.post(
|
||||
@@ -114,8 +144,15 @@ def finish_match(
|
||||
def create_finished_match(
|
||||
client: TestClient, group_id: int, players: list[dict], win_reason: str = "objectives"
|
||||
) -> dict:
|
||||
"""players: [{user_id, faction_id, place, comment?}] → завершённая партия (для статистики)."""
|
||||
roster = [{"user_id": p["user_id"], "faction_id": p["faction_id"]} for p in players]
|
||||
"""players: [{user_id, faction_id, place, comment?, was_random?}] → завершённая партия."""
|
||||
roster = [
|
||||
{
|
||||
"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)
|
||||
assert started.status_code == 200, started.text
|
||||
results = [
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Ачивки: админ создаёт/правит/удаляет, грузит иконку; condition хранится как текст."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import csrf_headers
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||
|
||||
|
||||
def _admin(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 _use_tmp_achievements(monkeypatch, tmp_path) -> Path:
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "dev_achievements_dir", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_create_list_and_files(client: TestClient, make_admin, monkeypatch, tmp_path):
|
||||
root = _use_tmp_achievements(monkeypatch, tmp_path)
|
||||
_admin(client, make_admin)
|
||||
|
||||
r = client.post(
|
||||
"/api/admin/achievements",
|
||||
json={"name": "Великий полководец", "description": "10 побед подряд"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
ach = r.json()
|
||||
slug = ach["slug"]
|
||||
assert slug and ach["name"] == "Великий полководец"
|
||||
|
||||
# На диске создана папка с meta.json и condition.py (задел, не исполняется).
|
||||
folder = root / slug
|
||||
assert (folder / "meta.json").is_file()
|
||||
assert (folder / "condition.py").is_file()
|
||||
meta = json.loads((folder / "meta.json").read_text(encoding="utf-8"))
|
||||
assert meta["name"] == "Великий полководец"
|
||||
|
||||
# Виден в админском списке (редактор использует именно его).
|
||||
assert any(a["slug"] == slug for a in client.get("/api/admin/achievements").json())
|
||||
|
||||
|
||||
def test_duplicate_name_conflicts(client: TestClient, make_admin, monkeypatch, tmp_path):
|
||||
_use_tmp_achievements(monkeypatch, tmp_path)
|
||||
_admin(client, make_admin)
|
||||
body = {"name": "Берсерк"}
|
||||
assert client.post("/api/admin/achievements", json=body, headers=csrf_headers(client)).status_code == 200
|
||||
r = client.post("/api/admin/achievements", json=body, headers=csrf_headers(client))
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_icon_upload_and_serve(client: TestClient, make_admin, monkeypatch, tmp_path):
|
||||
_use_tmp_achievements(monkeypatch, tmp_path)
|
||||
_admin(client, make_admin)
|
||||
slug = client.post(
|
||||
"/api/admin/achievements", json={"name": "Герой"}, headers=csrf_headers(client)
|
||||
).json()["slug"]
|
||||
|
||||
# Не картинка → 422.
|
||||
bad = client.put(
|
||||
f"/api/admin/achievements/{slug}/icon",
|
||||
files={"file": ("x.txt", b"nope", "text/plain")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert bad.status_code == 422, bad.text
|
||||
|
||||
# Валидный PNG → icon_url, файл отдаётся.
|
||||
ok = client.put(
|
||||
f"/api/admin/achievements/{slug}/icon",
|
||||
files={"file": ("h.png", PNG, "image/png")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert ok.status_code == 200 and ok.json()["icon_url"], ok.text
|
||||
g = client.get(f"/api/achievements/{slug}/icon")
|
||||
assert g.status_code == 200 and g.content == PNG
|
||||
|
||||
|
||||
def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path):
|
||||
_use_tmp_achievements(monkeypatch, tmp_path)
|
||||
_admin(client, make_admin)
|
||||
slug = client.post(
|
||||
"/api/admin/achievements", json={"name": "Стратег"}, headers=csrf_headers(client)
|
||||
).json()["slug"]
|
||||
|
||||
# Правка описания и текста условия (condition хранится как текст).
|
||||
r = client.patch(
|
||||
f"/api/admin/achievements/{slug}",
|
||||
json={"description": "Победа по целям", "condition": "wins_by_objectives >= 5"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["description"] == "Победа по целям"
|
||||
assert r.json()["has_condition"] is True
|
||||
assert "wins_by_objectives" in r.json()["condition"]
|
||||
|
||||
# Удаление.
|
||||
assert client.delete(
|
||||
f"/api/admin/achievements/{slug}", headers=csrf_headers(client)
|
||||
).status_code == 200
|
||||
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()
|
||||
@@ -2,8 +2,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from tests.conftest import create_finished_match, csrf_headers, login, start_match
|
||||
from app.models import Faction
|
||||
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:
|
||||
@@ -20,26 +24,25 @@ def _admin_login(client: TestClient, make_admin) -> None:
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_group_size_limit(client: TestClient):
|
||||
def test_group_invite_capacity_limit(client: TestClient):
|
||||
login(client, "Owner")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Большая", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
# owner = 1; добавляем до 10, 10-й участник сверх лимита → 409.
|
||||
for i in range(1, 11):
|
||||
client.post("/api/auth/dev/users", json={"nickname": f"U{i}"}, headers=csrf_headers(client))
|
||||
statuses = []
|
||||
for i in range(1, 11):
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": f"U{i}"}, headers=csrf_headers(client)
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": f"U{i}"}, headers=csrf_headers(client)
|
||||
)
|
||||
statuses.append(r.status_code)
|
||||
# 9 успешных (итого 10 с владельцем), 10-й — отказ.
|
||||
# owner(1) + 9 приглашений = 10 (лимит мест); 10-е приглашение сверх лимита → 409.
|
||||
assert statuses[:9] == [200] * 9
|
||||
assert statuses[9] == 409
|
||||
|
||||
|
||||
def test_match_player_limit(client: TestClient):
|
||||
def test_match_player_limit(client: TestClient, engine):
|
||||
me = login(client, "Owner")
|
||||
fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids")
|
||||
gid = client.post(
|
||||
@@ -47,12 +50,7 @@ def test_match_player_limit(client: TestClient):
|
||||
).json()["id"]
|
||||
ids = [me["id"]]
|
||||
for i in range(1, 7):
|
||||
client.post("/api/auth/dev/users", json={"nickname": f"P{i}"}, headers=csrf_headers(client))
|
||||
ids.append(
|
||||
client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": f"P{i}"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
)
|
||||
ids.append(add_group_member(engine, gid, f"P{i}"))
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
fids = [f["id"] for f in factions]
|
||||
# 7 игроков на старте → 422 (максимум 6).
|
||||
@@ -62,15 +60,12 @@ def test_match_player_limit(client: TestClient):
|
||||
assert "6" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_admin_edit_match_and_filters(client: TestClient, make_admin):
|
||||
def test_admin_edit_match_and_filters(client: TestClient, make_admin, engine):
|
||||
me = login(client, "Аня")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
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,
|
||||
@@ -106,15 +101,90 @@ def test_admin_edit_match_and_filters(client: TestClient, make_admin):
|
||||
assert bp["faction_name"] == "Хаоситы"
|
||||
|
||||
|
||||
def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
|
||||
def test_admin_rename_account(client: TestClient, make_admin):
|
||||
"""Админ переименовывает аккаунт: валидируется формат и уникальность ника."""
|
||||
a = login(client, "Старое")
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Занятое"}, headers=csrf_headers(client))
|
||||
_admin_login(client, make_admin)
|
||||
|
||||
r = client.patch(
|
||||
f"/api/admin/users/{a['id']}", json={"nickname": "Новое"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["nickname"] == "Новое"
|
||||
|
||||
# Занятый ник → 409, некорректный формат → 422.
|
||||
r = client.patch(
|
||||
f"/api/admin/users/{a['id']}", json={"nickname": "Занятое"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 409 and r.json()["error"]["code"] == "NICKNAME_TAKEN", r.text
|
||||
r = client.patch(
|
||||
f"/api/admin/users/{a['id']}", json={"nickname": "x"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_dev_delete_account_keeps_matches(client: TestClient, make_admin, engine):
|
||||
"""DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается),
|
||||
его созданные партии и группы переназначаются на админа."""
|
||||
me = login(client, "Аня") # владелец группы и создатель партии
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, 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["eldar"], "place": 2},
|
||||
],
|
||||
)["id"]
|
||||
|
||||
admin_id = 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
|
||||
|
||||
# Удаляем участника Борю → партия жива, его строка участия вычеркнута.
|
||||
r = client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client))
|
||||
assert r.status_code == 200, r.text
|
||||
assert all(u["id"] != b for u in client.get("/api/admin/users").json())
|
||||
detail = client.get(f"/api/admin/matches/{mid}").json()
|
||||
assert [p["user_id"] for p in detail["participants"]] == [me["id"]] # Боря вычеркнут
|
||||
assert detail["player_count"] == 2 # player_count НЕ пересчитывается (осознанно)
|
||||
|
||||
# Удаляем создателя/владельца Аню → партия и группа сохраняются, переходят к админу.
|
||||
r = client.delete(f"/api/admin/dev/users/{me['id']}", headers=csrf_headers(client))
|
||||
assert r.status_code == 200, r.text
|
||||
assert client.get(f"/api/admin/matches/{mid}").json()["created_by"] == admin_id
|
||||
grp = next(g for g in client.get("/api/admin/groups").json() if g["id"] == gid)
|
||||
assert grp["owner_id"] == admin_id
|
||||
|
||||
|
||||
def test_dev_delete_account_rejects_admin(client: TestClient, make_admin):
|
||||
"""Админа удалить нельзя."""
|
||||
admin_id = 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
|
||||
r = client.delete(f"/api/admin/dev/users/{admin_id}", headers=csrf_headers(client))
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine):
|
||||
me = login(client, "Кто-то")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Друг"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Друг"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
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,
|
||||
@@ -137,3 +207,106 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
|
||||
detail = client.get(f"/api/admin/matches/{mid}").json()
|
||||
ap = next(p for p in detail["participants"] if p["user_id"] == me["id"])
|
||||
assert ap["faction_name"] == "Орки WAAAGH"
|
||||
|
||||
|
||||
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]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Ротация пароля администратора из .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)
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Объявления администрации (#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><b>"
|
||||
)
|
||||
html, text = sanitize_body(raw)
|
||||
assert html == (
|
||||
"<p>Привет <b>мир</b></p>"
|
||||
'<p><em>курсив</em> <mark class="red">красный</mark> <mark>золотой</mark></p>'
|
||||
"ссылка<b>"
|
||||
)
|
||||
# Содержимое 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"]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Хардненинг 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
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Медиа партии (фото): загрузка/просмотр/удаление, валидация, лимит, запрет после финиша."""
|
||||
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"
|
||||
+107
-8
@@ -10,8 +10,9 @@ from fastapi.testclient import TestClient
|
||||
from tests.conftest import csrf_headers
|
||||
|
||||
|
||||
def test_auth_config_dev_has_both_methods(client: TestClient):
|
||||
def test_auth_config_dev_has_all_methods(client: TestClient):
|
||||
cfg = client.get("/api/auth/config").json()
|
||||
assert "password" in cfg["methods"]
|
||||
assert "telegram" in cfg["methods"]
|
||||
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
||||
|
||||
@@ -21,15 +22,13 @@ def test_enabled_methods_by_env(monkeypatch):
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "app_env", "development")
|
||||
assert set(enabled_methods()) == {"telegram", "stub"}
|
||||
monkeypatch.setattr(settings, "app_env", "test")
|
||||
assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram
|
||||
assert set(enabled_methods()) == {"password", "telegram", "stub"}
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
assert enabled_methods() == ["telegram"] # prod → только Telegram
|
||||
assert enabled_methods() == ["password", "telegram"] # prod → без stub
|
||||
|
||||
|
||||
def test_env_flags_and_db_path(monkeypatch):
|
||||
"""dev → файл дева; test и prod → том /data (общая ветвь is_development)."""
|
||||
"""dev → файл дева; prod → том /data."""
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db")
|
||||
@@ -37,8 +36,6 @@ def test_env_flags_and_db_path(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(settings, "app_env", "development")
|
||||
assert settings.is_development and settings.database_url == "sqlite:///dev.db"
|
||||
monkeypatch.setattr(settings, "app_env", "test")
|
||||
assert settings.is_test and settings.database_url == "sqlite:////data/prod.db"
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
|
||||
|
||||
@@ -58,6 +55,47 @@ def _telegram_payload(token: str, **fields) -> dict:
|
||||
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):
|
||||
from app.core.config import settings
|
||||
|
||||
@@ -79,3 +117,64 @@ def test_telegram_login_bad_signature_rejected(client: TestClient, monkeypatch):
|
||||
r = client.post("/api/auth/telegram", json=payload)
|
||||
assert r.status_code == 401
|
||||
_ = csrf_headers # (для единообразия импорта)
|
||||
|
||||
|
||||
def test_telegram_same_id_no_duplicate_account(client: TestClient, monkeypatch):
|
||||
"""Вход с двух устройств / после смены тега — один аккаунт (ключ = числовой id)."""
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||
r1 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="firsttag"))
|
||||
assert r1.status_code == 200, r1.text
|
||||
|
||||
client.cookies.clear() # «другое устройство» — без сессии
|
||||
# Тот же telegram id (777), но другой тег и свежий auth_date.
|
||||
r2 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="changedtag"))
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["id"] == r1.json()["id"] # тот же пользователь
|
||||
assert r2.json()["nickname"] == r1.json()["nickname"] # ник не пересоздаётся
|
||||
|
||||
|
||||
def test_telegram_register_nickname_collision_then_pick(client: TestClient, engine, monkeypatch):
|
||||
"""Первый вход через ТГ, тег совпал с чужим ником → просим выбрать ник → регистрируем."""
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models import User
|
||||
|
||||
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||
with Session(engine) as s: # существующий игрок с ником = тегу из payload (ivan_tg)
|
||||
s.add(User(nickname="ivan_tg", role="player", auth_provider="stub"))
|
||||
s.commit()
|
||||
|
||||
payload = _telegram_payload("TEST_BOT_TOKEN") # id=777, username=ivan_tg
|
||||
r = client.post("/api/auth/telegram", json=payload)
|
||||
assert r.status_code == 409, r.text
|
||||
err = r.json()["error"]
|
||||
assert err["code"] == "TELEGRAM_NICKNAME_REQUIRED"
|
||||
assert err["details"]["suggested"] == "ivan_tg"
|
||||
|
||||
# Повтор с выбранным свободным ником (та же подпись виджета).
|
||||
r2 = client.post("/api/auth/telegram/register", json={**payload, "nickname": "Иван-новый"})
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["nickname"] == "Иван-новый"
|
||||
assert r2.json()["telegram_id"] == 777
|
||||
|
||||
|
||||
def test_telegram_register_rejects_taken_nickname(client: TestClient, engine, monkeypatch):
|
||||
"""На шаге выбора ника всё ещё занятый ник отклоняется (NICKNAME_TAKEN)."""
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models import User
|
||||
|
||||
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||
with Session(engine) as s:
|
||||
s.add(User(nickname="ivan_tg", role="player", auth_provider="stub"))
|
||||
s.add(User(nickname="занятый", role="player", auth_provider="stub"))
|
||||
s.commit()
|
||||
|
||||
payload = _telegram_payload("TEST_BOT_TOKEN")
|
||||
r = client.post("/api/auth/telegram/register", json={**payload, "nickname": "занятый"})
|
||||
assert r.status_code == 409, r.text
|
||||
assert r.json()["error"]["code"] == "NICKNAME_TAKEN"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Оптимистичная блокировка партии: устаревшие правки/удаление отклоняются (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"
|
||||
@@ -0,0 +1,159 @@
|
||||
"""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
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import csrf_headers, finish_match, login, start_match
|
||||
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def test_full_flow(client: TestClient, make_admin):
|
||||
def test_full_flow(client: TestClient, make_admin, engine):
|
||||
# 1) Вход игрока A — групп ещё нет.
|
||||
me = login(client, "Иван")
|
||||
assert me["groups"] == []
|
||||
@@ -44,18 +44,8 @@ def test_full_flow(client: TestClient, make_admin):
|
||||
me = client.get("/api/users/me").json()
|
||||
assert me["active_group_id"] == gid
|
||||
|
||||
# 4) Создать игрока B и добавить в группу по нику.
|
||||
rb = client.post(
|
||||
"/api/auth/dev/users", json={"nickname": "Олег"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert rb.status_code == 200, rb.text
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/members",
|
||||
json={"nickname": "Олег"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
b_id = r.json()["user_id"]
|
||||
# 4) Игрок B в группе (прямое добавление — подготовка данных; UX-путь теперь приглашения).
|
||||
b_id = add_group_member(engine, gid, "Олег")
|
||||
|
||||
# 5) Доступные фракции = база (4) + Forgotten Worlds (4) = 8.
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
@@ -150,15 +140,12 @@ def test_full_flow(client: TestClient, make_admin):
|
||||
assert {"Иван", "Олег"}.issubset(nicks)
|
||||
|
||||
|
||||
def test_in_progress_excluded_and_win_reason_required(client: TestClient):
|
||||
def test_in_progress_excluded_and_win_reason_required(client: TestClient, engine):
|
||||
me = login(client, "Хост")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Гость2"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Гость2"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Гость2")
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
f1, f2 = factions[0]["id"], factions[1]["id"]
|
||||
|
||||
@@ -202,16 +189,13 @@ def test_in_progress_excluded_and_win_reason_required(client: TestClient):
|
||||
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):
|
||||
def test_owner_can_finish_member_started_match(client: TestClient, engine):
|
||||
"""Владелец группы может завершить партию, начатую другим участником (can_modify с бэкенда)."""
|
||||
a = login(client, "Хозяин")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Согрупник"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Согрупник"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Согрупник")
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
f1, f2 = factions[0]["id"], factions[1]["id"]
|
||||
|
||||
@@ -236,16 +220,14 @@ def test_owner_can_finish_member_started_match(client: TestClient):
|
||||
assert fin.status_code == 200, fin.text
|
||||
|
||||
|
||||
def test_any_member_can_finish_and_manage(client: TestClient):
|
||||
def test_any_member_can_finish_and_manage(client: TestClient, engine):
|
||||
"""Рядовой участник (не создатель, не владелец) может завершать партии и менять дополнения."""
|
||||
a = login(client, "Овнер")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Компания", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
for nick in ("Бэ", "Цэ"):
|
||||
client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client))
|
||||
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "Бэ"}, headers=csrf_headers(client)).json()["user_id"]
|
||||
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "Цэ"}, headers=csrf_headers(client)).json()["user_id"]
|
||||
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()]
|
||||
|
||||
# Владелец стартует партию A+B+C.
|
||||
@@ -305,18 +287,47 @@ def test_disabled_account_cannot_login(client: TestClient, make_admin):
|
||||
assert guest_dev["is_active"] is False
|
||||
|
||||
|
||||
def test_tie_ranking_and_points(client: TestClient):
|
||||
def test_csrf_required_for_session_mutations(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 валидны; очки делятся; некорректная расстановка отклоняется."""
|
||||
me = login(client, "A")
|
||||
r = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []},
|
||||
headers=csrf_headers(client))
|
||||
gid = r.json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "B"}, headers=csrf_headers(client))
|
||||
client.post("/api/auth/dev/users", json={"nickname": "C"}, headers=csrf_headers(client))
|
||||
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "B"},
|
||||
headers=csrf_headers(client)).json()["user_id"]
|
||||
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "C"},
|
||||
headers=csrf_headers(client)).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "B")
|
||||
c = add_group_member(engine, gid, "C")
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
f1, f2, f3 = (factions[0]["id"], factions[1]["id"], factions[2]["id"])
|
||||
a = me["id"]
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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]]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Выбывшие игроки: красный крестик = последнее место (засчитывается, без отд. «проигрыша»)."""
|
||||
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 # должен остаться хотя бы один с местом
|
||||
@@ -0,0 +1,43 @@
|
||||
"""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
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Адресаты событий партии. Рейтинг общий (#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")
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Черновик формы завершения: совместное заполнение результатов партии.
|
||||
|
||||
Плюс запрет правки незавершённой партии (места без завершения — «результат есть,
|
||||
а игры как бы не было»)."""
|
||||
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
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Список игроков группы — только текущий состав (#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)]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Приглашения в группу: создание, просмотр, принятие/отклонение, права участника."""
|
||||
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
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Правка завершённой партии игроком: история чинится после изменений в группе."""
|
||||
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
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Уведомления: сервис (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
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
|
||||
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)
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Профиль: «о себе» (bio), любимая фракция, история партий, аватар
|
||||
(загрузка/отдача/удаление), публичный профиль."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models import User
|
||||
from tests.conftest import (
|
||||
add_group_member,
|
||||
create_finished_match,
|
||||
csrf_headers,
|
||||
login,
|
||||
start_match,
|
||||
)
|
||||
|
||||
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
|
||||
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 _finished_match_for(client: TestClient, engine, me: dict) -> int:
|
||||
"""Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд)."""
|
||||
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},
|
||||
{"user_id": p2, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
return gid
|
||||
|
||||
|
||||
def test_update_bio(client: TestClient):
|
||||
login(client, "Игрок")
|
||||
r = client.patch("/api/users/me/profile", json={"bio": "Люблю орков"}, headers=csrf_headers(client))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["bio"] == "Люблю орков"
|
||||
# Слишком длинное «о себе» → 422.
|
||||
r = client.patch(
|
||||
"/api/users/me/profile", json={"bio": "x" * 501}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_avatar_upload_get_delete(client: TestClient, monkeypatch, tmp_path):
|
||||
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||
me = login(client, "Аватарыч")
|
||||
|
||||
# Загрузка валидного PNG → avatar_url выставлен.
|
||||
r = client.put(
|
||||
"/api/users/me/avatar",
|
||||
files={"file": ("a.png", PNG, "image/png")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
avatar_url = r.json()["avatar_url"]
|
||||
assert avatar_url and "/avatar?v=" in avatar_url
|
||||
|
||||
# Отдача файла.
|
||||
g = client.get(f"/api/users/{me['id']}/avatar")
|
||||
assert g.status_code == 200, g.text
|
||||
assert g.content == PNG
|
||||
assert g.headers["content-type"] == "image/png"
|
||||
|
||||
# Удаление → avatar_url пропадает, файл больше не отдаётся.
|
||||
r = client.delete("/api/users/me/avatar", headers=csrf_headers(client))
|
||||
assert r.status_code == 200 and r.json()["avatar_url"] is None, r.text
|
||||
assert client.get(f"/api/users/{me['id']}/avatar").status_code == 404
|
||||
|
||||
|
||||
def test_avatar_rejects_non_image_and_oversize(client: TestClient, monkeypatch, tmp_path):
|
||||
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||
login(client, "Игрок")
|
||||
|
||||
r = client.put(
|
||||
"/api/users/me/avatar",
|
||||
files={"file": ("x.txt", b"not an image", "text/plain")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
big = PNG + b"\x00" * (2 * 1024 * 1024 + 10)
|
||||
r = client.put(
|
||||
"/api/users/me/avatar",
|
||||
files={"file": ("big.png", big, "image/png")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_public_profile(client: TestClient):
|
||||
me = login(client, "Публичный")
|
||||
client.patch("/api/users/me/profile", json={"bio": "Привет"}, headers=csrf_headers(client))
|
||||
|
||||
r = client.get(f"/api/users/{me['id']}/profile")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["nickname"] == "Публичный"
|
||||
assert body["bio"] == "Привет"
|
||||
assert body["avatar_url"] is None
|
||||
assert "overall" in body["stats"]
|
||||
|
||||
assert client.get("/api/users/999999/profile").status_code == 404
|
||||
|
||||
|
||||
def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch, tmp_path):
|
||||
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||
me = login(client, "Топчик")
|
||||
_finished_match_for(client, engine, me)
|
||||
|
||||
def entry_for(uid: int) -> dict:
|
||||
board = client.get("/api/stats/leaderboard").json()
|
||||
return next(e for e in board["entries"] + board["provisional"] if e["user_id"] == uid)
|
||||
|
||||
assert entry_for(me["id"])["avatar_url"] is None # без аватара — null
|
||||
|
||||
client.put(
|
||||
"/api/users/me/avatar",
|
||||
files={"file": ("a.png", PNG, "image/png")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
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"])
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Движок рейтинга: примеры 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)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Итоги партии для рейтинга (#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
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Рейтинг в витринах (#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
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Статистика профиля: цифры сходятся с лидербордом, а главная не грузит историю лишний раз."""
|
||||
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
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Поиск игроков для автокомплита по нику: 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
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Ошибки валидации запроса: всегда 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
|
||||
+43
-23
@@ -1,6 +1,6 @@
|
||||
# Публикация: домены, VPS, туннели
|
||||
|
||||
Приложение крутится дома (Pi — прод) и на твоём ПК (dev/test). Дома белого IP нет
|
||||
Приложение крутится дома (Pi — прод) и на твоём ПК (dev). Дома белого IP нет
|
||||
(CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует
|
||||
HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые
|
||||
приложение само открывает к VPS.
|
||||
@@ -11,37 +11,57 @@ HTTPS твоими сертификатами и проксирует трафи
|
||||
│ ▲ туннель-КОНТЕЙНЕР │
|
||||
│ └── Pi : app:8000 PROD │
|
||||
forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │
|
||||
│ ▲ контейнер (test) ИЛИ │
|
||||
│ ▲ ssh с ПК (dev) │
|
||||
│ ├── ПК test : app:8000 │
|
||||
│ └── ПК dev : vite:5173 │
|
||||
│ ▲ ssh с ПК (по требованию) │
|
||||
│ └── ПК dev : vite:5173 DEV │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **PROD** — Pi. `docker compose up -d` поднимает два сервиса: `app` + `tunnel`. У `app`
|
||||
**портов на хост нет** — наружу его выставляет только туннель-контейнер
|
||||
(`ssh -R 9000:app:8000` к VPS). Постоянно, Docker сам переподключает. См. [`pi/`](pi/README.md).
|
||||
- **TEST** — ПК. То же самое: `docker compose -f docker-compose.test.yml up` поднимает
|
||||
`app` + `tunnel` (`ssh -R 9001:app:8000`). Портов на хост нет — тест виден только на
|
||||
`forbidden-stars.ru`. Обычно запускается лаунчером при `APP_ENV=test`.
|
||||
- **PROD** — Pi. `docker compose up -d` поднимает три сервиса: `app` + `tunnel` + `backup`
|
||||
(образы из Gitea-реестра, собираются на ПК `scripts/build-push.ps1`). У `app` **портов на
|
||||
хост нет** — наружу его выставляет только туннель-контейнер (`ssh -R 9000:app:8000` к VPS).
|
||||
Работает постоянно: при обрыве `ssh` завершается, и Docker перезапускает контейнер
|
||||
(`restart: unless-stopped`). См. [`pi/`](pi/README.md).
|
||||
- **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при
|
||||
`LOCAL_PUBLIC=vps` лаунчер (`run.ps1`) дополнительно поднимает SSH-туннель с ПК
|
||||
(`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru`.
|
||||
- DEV и TEST делят слот **9001** (`forbidden-stars.ru`) → поднимай что-то **одно за раз**.
|
||||
PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо.
|
||||
`LOCAL_PUBLIC=vps` лаунчер (`run.ps1` / `run.sh`) дополнительно поднимает SSH-туннель с ПК
|
||||
(`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru` (слот **9001**).
|
||||
- PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо от dev.
|
||||
Временный прод на ПК (`docker-compose.temp.yml`) тоже занимает **9000** — одновременно с Pi нельзя.
|
||||
|
||||
Ключ туннеля — **`deploy/tunnel/id_tunnel`** (приватный, в git не идёт). Его публичную
|
||||
часть добавь в `authorized_keys` пользователя `tunnel` на VPS. Один и тот же ключ годится
|
||||
для контейнерного туннеля (Pi/ПК) и для dev-туннеля `run.ps1`.
|
||||
Ключи туннеля (приватные, в git не идут; публичные части — в `authorized_keys` пользователя
|
||||
`tunnel` на VPS):
|
||||
- **Pi** — `TUNNEL_KEY_B64` (base64 приватного ключа) в `.env`; файла ключа на Pi нет.
|
||||
- **ПК, временный прод** — файл `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`.
|
||||
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ в `deploy/tunnel/id_tunnel`, `.env`, `docker compose up -d`.
|
||||
3. **ПК (dev/test)** — тот же ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или
|
||||
ключ по умолчанию для dev (`run.ps1`); pubkey — в `authorized_keys` у `tunnel@VPS`.
|
||||
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ туннеля в `TUNNEL_KEY_B64`, `.env`, `docker compose up -d`.
|
||||
3. **ПК (dev)** — ключ по умолчанию в `~/.ssh` (для dev-туннеля) и, если нужен временный прод,
|
||||
файл `deploy/tunnel/id_tunnel`; pubkey — в `authorized_keys` у `tunnel@VPS`.
|
||||
4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает
|
||||
снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их на ПК.
|
||||
|
||||
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`) живут на VPS/Pi/ПК,
|
||||
в репозитории только `Caddyfile`, `deploy/tunnel/` (образ туннеля) и шаблоны.
|
||||
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`, `id_backup*`) живут на
|
||||
VPS/Pi/ПК, в репозитории только `Caddyfile`, страница-заглушка, образы `deploy/tunnel/` и
|
||||
`deploy/backup/` и шаблоны.
|
||||
|
||||
> Telegram-вход требует HTTPS-домен: у BotFather `/setdomain` укажи оба домена
|
||||
> (`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`.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# В контекст сборки попадают ТОЛЬКО файлы образа. Всё остальное (в первую очередь приватный
|
||||
# ключ deploy/backup/id_backup, если он лежит здесь на ПК) в образ не попадёт.
|
||||
*
|
||||
!Dockerfile
|
||||
!fs-backup.sh
|
||||
!entrypoint.sh
|
||||
@@ -0,0 +1,34 @@
|
||||
# Контейнер бэкапов: 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
@@ -0,0 +1,60 @@
|
||||
#!/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
+164
-25
@@ -1,46 +1,185 @@
|
||||
# Raspberry Pi — ПРОД (`forbiddenstars.ru`)
|
||||
|
||||
Pi запускает приложение **и туннель** в Docker: `docker compose up` поднимает два сервиса —
|
||||
`app` (FastAPI + SPA) и `tunnel` (ssh-контейнер). У `app` **портов на хост нет**, наружу он
|
||||
доступен только через туннель-контейнер (`ssh -R 9000:app:8000` к VPS). Публичная точка —
|
||||
VPS, домен `forbiddenstars.ru`. Снаружи Pi недоступен (CGNAT) — туннель сам стучится наружу.
|
||||
На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл
|
||||
ключа туннеля не нужны:
|
||||
- образы (`app` + `tunnel` + `backup`) тянутся из Gitea-реестра (`pull_policy: always`);
|
||||
- приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64).
|
||||
|
||||
## 1. Ключ для туннеля
|
||||
Туннель-контейнер ходит на `tunnel@VPS` по ключу из `deploy/tunnel/id_tunnel` (gitignored).
|
||||
Создай ключ и добавь его pubkey в `authorized_keys` пользователя `tunnel` на VPS:
|
||||
`docker compose up` поднимает три контейнера: `app` (FastAPI+SPA, портов на хост нет),
|
||||
`tunnel` (`ssh -R 9000:app:8000` к VPS, стартует после `healthy` у `app`) и `backup` (restic,
|
||||
см. раздел «Бэкапы»). Публичная точка — VPS, домен `forbiddenstars.ru` (Pi за CGNAT — туннель
|
||||
стучится наружу сам).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
ssh-keygen -t ed25519 -f deploy/tunnel/id_tunnel -N ""
|
||||
ssh-copy-id -i deploy/tunnel/id_tunnel.pub tunnel@186.246.51.17
|
||||
# (или вручную: содержимое deploy/tunnel/id_tunnel.pub -> /home/tunnel/.ssh/authorized_keys на VPS)
|
||||
ssh pi@<ip-пая>
|
||||
sudo apt update && sudo apt -y full-upgrade
|
||||
sudo apt -y install curl openssh-client # base64/ssh-keygen уже есть в системе
|
||||
|
||||
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 # проверка
|
||||
```
|
||||
|
||||
## 2. .env
|
||||
### Лимиты памяти (memory cgroup)
|
||||
Прошивка 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
|
||||
cp .env.example .env # заполни секреты; APP_ENV контейнер форсит в production
|
||||
docker info 2>&1 | grep -i "no memory limit"
|
||||
cat /sys/fs/cgroup/cgroup.controllers # в списке должно быть слово memory
|
||||
```
|
||||
Проверь, что заданы параметры VPS (их читает сервис `tunnel`):
|
||||
Включить — дописать параметр **в ту же единственную строку** `cmdline.txt` (перевод строки
|
||||
в этом файле ломает загрузку) и перезагрузить 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_USER=tunnel
|
||||
TUNNEL_KEY_B64=... # длинная строка base64 из шага 2 (целиком, одной строкой)
|
||||
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost # уже значение по умолчанию
|
||||
IMAGE_TAG=latest
|
||||
```
|
||||
`APP_ENV` (форсится в `production`) и `VPS_TUNNEL_PORT` (прод → 9000) не трогать.
|
||||
Блок `BACKUP_*` можно оставить пустым: бэкапы включаются позже, по
|
||||
[`deploy/backup/README.md`](../backup/README.md).
|
||||
|
||||
## 3. Запуск
|
||||
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
|
||||
> символов, а `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
|
||||
docker compose up -d --build # поднимет app + tunnel
|
||||
docker compose ps # оба up; tunnel стартует после healthy app
|
||||
docker login gitea.arseniev.info # один раз, доступ к реестру
|
||||
docker compose up -d # pull_policy: always → тянет образы из реестра, без сборки
|
||||
docker compose ps # app healthy → поднимется tunnel
|
||||
docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@...
|
||||
```
|
||||
Открой `https://forbiddenstars.ru` — должен отдать прод.
|
||||
На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env`
|
||||
(если админа ещё нет).
|
||||
|
||||
## 5. Проверка
|
||||
- Открой `https://forbiddenstars.ru` — должно отдать приложение (не заглушку).
|
||||
- У @BotFather `/setdomain` → добавь `forbiddenstars.ru` (иначе Telegram-вход не заработает).
|
||||
- Админка: удержать «Меню» 10 с → `/admin/login`, войти логином/паролём из `.env`.
|
||||
|
||||
## Обновление
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
# Pi: docker compose exec backup fs-backup run --tag before-update # по желанию, если бэкапы включены
|
||||
# ПК: .\scripts\build-push.ps1
|
||||
# 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 # хронология снимков
|
||||
```
|
||||
|
||||
## Бэкап БД
|
||||
`scripts/backup.sh` (консистентная онлайн-копия SQLite; по cron на хосте). Том БД — `db-data`.
|
||||
> Не выполняйте `docker compose down -v`: флаг `-v` удаляет тома с данными и локальными бэкапами.
|
||||
|
||||
Логи: `docker compose logs -f app` / `... tunnel`. Если `https://forbiddenstars.ru` отдаёт
|
||||
502 — значит туннель не поднят: смотри `docker compose logs tunnel` (чаще всего ключ не в
|
||||
`authorized_keys` на VPS или не заданы `VPS_TUNNEL_*` в `.env`).
|
||||
## Если что-то не так
|
||||
- `https://forbiddenstars.ru` отдаёт заглушку/502 → туннель не поднят: `docker compose logs tunnel`
|
||||
(чаще: pubkey не в `authorized_keys` на VPS, пустой/битый `TUNNEL_KEY_B64`, либо на VPS занят
|
||||
слот 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,
|
||||
«Лимиты памяти».
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user