v1.35 — полировка мелких ошибок вёрстки, оптимизация сетевого кода, унификация и доработка элементов интерфейса
This commit is contained in:
@@ -38,6 +38,9 @@ backend/openapi.json
|
||||
# Бэкапы (создаёт scripts/backup.sh на Pi)
|
||||
backups/
|
||||
|
||||
# Фронт-макеты для проработки UI (локальные прототипы, не для репозитория)
|
||||
mockups/
|
||||
|
||||
# AI-ассистенты (локальные, в репозиторий не идут)
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
@@ -49,7 +49,7 @@ pip install -e ".[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)
|
||||
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs)
|
||||
```
|
||||
|
||||
**Windows cmd.exe** (здесь `&&` поддерживается):
|
||||
@@ -61,7 +61,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 +72,13 @@ 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
|
||||
```
|
||||
|
||||
> **`--timeout-graceful-shutdown` обязателен.** Открытая вкладка держит SSE-поток
|
||||
> `/api/events`, и без лимита `--reload` ждёт его закрытия вечно — сайт висит на загрузке,
|
||||
> а в логе только `Reloading...`.
|
||||
|
||||
> **Единый `.env` — в корне репозитория** (`ForbidenStarsApp/.env`), рядом с `.env.example`.
|
||||
> Его читают и бэкенд (через абсолютный путь, независимо от рабочей папки), и `docker compose`.
|
||||
> Файл — локальный, на каждой машине свой (dev/prod различаются строкой `APP_ENV`).
|
||||
|
||||
@@ -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")
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -29,7 +30,7 @@ def establish_session(
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
payload={"provider": provider},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
@@ -6,7 +6,7 @@ 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
|
||||
|
||||
@@ -118,3 +118,11 @@ 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 client_ip(request: Request) -> str | None:
|
||||
"""IP клиента для журнала аудита.
|
||||
|
||||
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из
|
||||
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант."""
|
||||
return request.client.host if request.client else None
|
||||
|
||||
+78
-29
@@ -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)
|
||||
@@ -97,14 +101,34 @@ 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"))
|
||||
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
|
||||
# (выдача ачивок игрокам — следующий этап).
|
||||
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=_utcnow,
|
||||
sa_column_kwargs={"onupdate": _utcnow},
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
@@ -126,7 +150,7 @@ class UserAchievement(SQLModel, table=True):
|
||||
)
|
||||
)
|
||||
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
|
||||
earned_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
earned_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
class AuthIdentity(SQLModel, table=True):
|
||||
@@ -143,7 +167,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)
|
||||
|
||||
|
||||
# ─── Группы и членство ───────────────────────────────────────────────────────
|
||||
@@ -158,10 +182,10 @@ 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,
|
||||
)
|
||||
|
||||
@@ -185,7 +209,7 @@ 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):
|
||||
@@ -213,7 +237,7 @@ class GroupInvitation(SQLModel, table=True):
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
class GroupExpansion(SQLModel, table=True):
|
||||
@@ -233,7 +257,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)
|
||||
|
||||
|
||||
# ─── Партии и участники ──────────────────────────────────────────────────────
|
||||
@@ -271,10 +295,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,
|
||||
)
|
||||
|
||||
@@ -309,7 +333,7 @@ class MatchParticipant(SQLModel, table=True):
|
||||
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)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
class MatchAttachment(SQLModel, table=True):
|
||||
@@ -335,9 +359,40 @@ class MatchAttachment(SQLModel, table=True):
|
||||
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)
|
||||
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):
|
||||
@@ -363,7 +418,7 @@ class Notification(SQLModel, table=True):
|
||||
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)
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
@@ -387,12 +442,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; в БД только метаданные и относительный путь.
|
||||
|
||||
@@ -7,12 +7,14 @@ from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_admin
|
||||
from app.core import security
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.core.security import client_ip
|
||||
from app.core.errors import 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 attachment_read, build_match_read
|
||||
from app.schemas import api as s
|
||||
from app.services.match_service import ParticipantInput
|
||||
from app.services import (
|
||||
achievement_service,
|
||||
admin_service,
|
||||
@@ -25,8 +27,6 @@ from app.services import (
|
||||
)
|
||||
|
||||
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
|
||||
_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ
|
||||
from app.services.match_service import ParticipantInput
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -48,7 +48,7 @@ def admin_login(
|
||||
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()
|
||||
@@ -104,7 +104,7 @@ 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(
|
||||
@@ -114,7 +114,7 @@ def update_user(
|
||||
is_active=u.is_active,
|
||||
auth_provider=u.auth_provider,
|
||||
telegram_id=u.telegram_id,
|
||||
created_at=u.created_at.isoformat(),
|
||||
created_at=iso_utc(u.created_at),
|
||||
)
|
||||
|
||||
|
||||
@@ -148,7 +148,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()
|
||||
@@ -239,7 +239,7 @@ 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)
|
||||
@@ -277,7 +277,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(
|
||||
@@ -293,13 +293,15 @@ def delete_match(
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
group_id = match_service.get_match(session, match_id).group_id # для уведомления
|
||||
# До удаления: каскад унесёт участников вместе с партией.
|
||||
participant_ids = notify.match_participant_ids(session, match_id)
|
||||
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)
|
||||
notify.match_removed(session, match_id, group_id, participant_ids)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@@ -326,12 +328,9 @@ def admin_add_attachment(
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.AttachmentRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
content = file.file.read(_ATTACHMENT_MAX_BYTES + 1)
|
||||
if len(content) > _ATTACHMENT_MAX_BYTES:
|
||||
raise ValidationError("Файл слишком большой (макс. 10 МБ).")
|
||||
ext = user_service.sniff_image_ext(content)
|
||||
if ext is None:
|
||||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||||
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)
|
||||
)
|
||||
@@ -389,7 +388,7 @@ def create_achievement(
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="create", entity_type="achievement",
|
||||
payload={"slug": ach["slug"], "name": ach["name"]},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return ach
|
||||
@@ -408,7 +407,7 @@ def update_achievement(
|
||||
)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="update", entity_type="achievement",
|
||||
payload={"slug": slug}, ip=request.client.host if request.client else None,
|
||||
payload={"slug": slug}, ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return ach
|
||||
@@ -420,10 +419,9 @@ def upload_achievement_icon(
|
||||
file: UploadFile = File(...),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1)
|
||||
if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES:
|
||||
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
|
||||
ext = achievement_service.validate_icon(content)
|
||||
content, ext = user_service.read_capped_image(
|
||||
file, _ACHIEVEMENT_ICON_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
|
||||
)
|
||||
return achievement_service.set_icon(slug, content, ext)
|
||||
|
||||
|
||||
@@ -437,7 +435,7 @@ def delete_achievement(
|
||||
achievement_service.delete(slug)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="achievement",
|
||||
payload={"slug": slug}, ip=request.client.host if request.client else None,
|
||||
payload={"slug": slug}, ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
@@ -63,7 +64,7 @@ def delete_user_hard(
|
||||
entity_type="user",
|
||||
entity_id=user_id,
|
||||
payload={"hard": True, "nickname": nickname},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -61,7 +62,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()
|
||||
@@ -157,7 +158,7 @@ def invite_member(
|
||||
entity_type="group_invitation",
|
||||
entity_id=group_id,
|
||||
payload={"invited_user_id": invited.id, "nickname": invited.nickname},
|
||||
ip=request.client.host if request.client else None,
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
@@ -6,7 +6,8 @@ from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.core.errors import ConflictError, NoGroupError, NotFoundError, ValidationError
|
||||
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, MatchAttachment, User
|
||||
@@ -24,7 +25,6 @@ from app.services.match_service import FinishInput, ParticipantInput, RosterInpu
|
||||
|
||||
router = APIRouter(prefix="/matches", tags=["matches"])
|
||||
|
||||
_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ
|
||||
|
||||
|
||||
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
|
||||
@@ -60,6 +60,16 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
)
|
||||
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,
|
||||
@@ -79,6 +89,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -119,7 +130,7 @@ 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()
|
||||
@@ -165,7 +176,7 @@ 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()
|
||||
@@ -225,7 +236,7 @@ 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()
|
||||
@@ -233,6 +244,26 @@ def update_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:
|
||||
@@ -250,12 +281,9 @@ def add_attachment(
|
||||
) -> s.AttachmentRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
_assert_can_attach(session, match, user)
|
||||
content = file.file.read(_ATTACHMENT_MAX_BYTES + 1)
|
||||
if len(content) > _ATTACHMENT_MAX_BYTES:
|
||||
raise ValidationError("Файл слишком большой (макс. 10 МБ).")
|
||||
ext = user_service.sniff_image_ext(content)
|
||||
if ext is None:
|
||||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||||
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)
|
||||
)
|
||||
@@ -307,6 +335,9 @@ def delete_match(
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
match_id_val = match.id
|
||||
group_id_val = match.group_id
|
||||
# Участников читаем до удаления: каскад унесёт их строки вместе с партией,
|
||||
# а событию они нужны, чтобы клиент знал, чьи витрины протухли.
|
||||
participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type]
|
||||
match_service.delete_match(session, match, expected_version=expected_version)
|
||||
audit_service.record(
|
||||
session,
|
||||
@@ -315,9 +346,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) # type: ignore[arg-type]
|
||||
notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type]
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -6,7 +6,8 @@ from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.core.security import client_ip
|
||||
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
|
||||
@@ -31,6 +32,9 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -58,7 +62,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()
|
||||
@@ -71,6 +75,9 @@ def update_me(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,15 +88,28 @@ def update_my_profile(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MeRead:
|
||||
user_service.update_bio(session, user, body.bio)
|
||||
# Обновляем только реально переданные поля: «О себе» и любимая фракция правятся
|
||||
# по отдельности, и частичный запрос не должен обнулять соседнее поле.
|
||||
changed = body.model_dump(exclude_unset=True)
|
||||
if "bio" in changed:
|
||||
user_service.update_bio(session, user, changed["bio"])
|
||||
if "favorite_faction_id" in changed:
|
||||
user_service.update_favorite_faction(session, user, changed["favorite_faction_id"])
|
||||
if "history_mode" in changed or "history_detail" in changed:
|
||||
user_service.update_history_prefs(
|
||||
session,
|
||||
user,
|
||||
mode=changed.get("history_mode"),
|
||||
detail=changed.get("history_detail"),
|
||||
)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="update",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
payload={"bio": True},
|
||||
ip=request.client.host if request.client else None,
|
||||
payload={key: True for key in changed},
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
return build_me(session, user)
|
||||
@@ -101,12 +121,9 @@ def upload_my_avatar(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MeRead:
|
||||
content = file.file.read(_AVATAR_MAX_BYTES + 1)
|
||||
if len(content) > _AVATAR_MAX_BYTES:
|
||||
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
|
||||
ext = user_service.sniff_image_ext(content)
|
||||
if ext is None:
|
||||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||||
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)
|
||||
|
||||
@@ -147,6 +164,26 @@ def get_user_profile(
|
||||
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),
|
||||
|
||||
@@ -90,6 +90,11 @@ class UserRead(BaseModel):
|
||||
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):
|
||||
@@ -101,7 +106,12 @@ class NicknameUpdate(BaseModel):
|
||||
|
||||
|
||||
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):
|
||||
@@ -266,6 +276,25 @@ class AttachmentRead(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
class MatchFinishDraftRead(BaseModel):
|
||||
data: MatchFinishDraftData
|
||||
updated_by: int | None = None
|
||||
updated_by_nickname: str | None = None
|
||||
updated_at: str
|
||||
|
||||
|
||||
class MatchRead(BaseModel):
|
||||
id: int
|
||||
group_id: int
|
||||
@@ -282,6 +311,8 @@ class MatchRead(BaseModel):
|
||||
version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно
|
||||
participants: list[MatchParticipantRead] = []
|
||||
attachments: list[AttachmentRead] = []
|
||||
# Общий черновик формы завершения (только у незавершённой партии).
|
||||
finish_draft: MatchFinishDraftRead | None = None
|
||||
|
||||
|
||||
# ─── Статистика ──────────────────────────────────────────────────────────────
|
||||
@@ -301,6 +332,20 @@ class LeaderboardEntry(OverallStats):
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class MatchHistory(BaseModel):
|
||||
"""История партий игрока плюс настройки витрины его профиля.
|
||||
|
||||
Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так,
|
||||
как выбрал её владелец, не делая второго запроса за профилем."""
|
||||
|
||||
items: list[MatchListItem] = []
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
mode: str
|
||||
detail: str
|
||||
|
||||
|
||||
class Leaderboard(BaseModel):
|
||||
entries: list[LeaderboardEntry] = []
|
||||
provisional: list[LeaderboardEntry] = []
|
||||
@@ -311,6 +356,8 @@ class FactionStat(BaseModel):
|
||||
faction_id: int
|
||||
code: str
|
||||
name_ru: str
|
||||
# Название в предложном падеже — для строки «Чаще всего играет на …».
|
||||
name_ru_prepositional: str
|
||||
expansion_code: str
|
||||
games: int
|
||||
wins: int
|
||||
@@ -331,8 +378,9 @@ 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 единообразно подсвечивал
|
||||
|
||||
@@ -42,6 +42,10 @@ 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("-")
|
||||
@@ -49,6 +53,11 @@ def _slugify(name: str) -> str:
|
||||
|
||||
|
||||
def _dir(slug: str) -> Path:
|
||||
"""Папка ачивки. Slug приходит из URL, поэтому формат проверяем здесь: без этого
|
||||
`..` или `a/b` увели бы файловые операции (вплоть до rmtree в delete) за пределы
|
||||
каталога ачивок."""
|
||||
if not _SLUG_RE.fullmatch(slug or ""):
|
||||
raise NotFoundError("Ачивка не найдена.")
|
||||
return _root() / slug
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import (
|
||||
ConflictError,
|
||||
InvalidCredentialsError,
|
||||
NicknameTakenError,
|
||||
NotFoundError,
|
||||
@@ -75,6 +76,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()
|
||||
|
||||
@@ -134,11 +139,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)
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,6 +14,7 @@ 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"
|
||||
|
||||
|
||||
@@ -42,6 +43,8 @@ def file_path(att: MatchAttachment) -> 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(
|
||||
@@ -60,6 +63,7 @@ def add_photo(
|
||||
abs_path.write_bytes(content)
|
||||
att.storage_path = rel
|
||||
session.add(att)
|
||||
match_service.touch(session, match)
|
||||
session.commit()
|
||||
session.refresh(att)
|
||||
return att
|
||||
@@ -73,6 +77,8 @@ def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachme
|
||||
|
||||
|
||||
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():
|
||||
@@ -81,6 +87,7 @@ def delete(session: Session, match: Match, att_id: int) -> None:
|
||||
except OSError:
|
||||
pass
|
||||
session.delete(att)
|
||||
match_service.touch(session, match)
|
||||
session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,6 +5,7 @@ GroupMember). Лимит участников — общий с membership_servi
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
||||
@@ -70,17 +71,17 @@ def create_invitation(
|
||||
|
||||
|
||||
def list_for_user(session: Session, user_id: int) -> list[dict]:
|
||||
inviter = aliased(User)
|
||||
rows = session.exec(
|
||||
select(GroupInvitation, Group.name).join(Group, Group.id == GroupInvitation.group_id)
|
||||
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 in rows:
|
||||
inviter_nick = None
|
||||
if inv.invited_by_id is not None:
|
||||
inviter = session.get(User, inv.invited_by_id)
|
||||
inviter_nick = inviter.nickname if inviter else None
|
||||
for inv, gname, inviter_nick in rows:
|
||||
out.append(
|
||||
{
|
||||
"id": inv.id,
|
||||
|
||||
@@ -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,8 +16,8 @@ from app.core.errors import (
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.core.timeutil import app_today, iso_utc
|
||||
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
|
||||
|
||||
MAX_MATCH_PLAYERS = 6
|
||||
@@ -56,10 +56,6 @@ class ParticipantInput:
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def round_to_30(minutes: float) -> int:
|
||||
"""Округление длительности до получаса, минимум 30 минут."""
|
||||
return max(30, int(round(minutes / 30.0)) * 30)
|
||||
@@ -77,6 +73,17 @@ 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):
|
||||
@@ -161,16 +168,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()
|
||||
|
||||
|
||||
@@ -188,7 +207,7 @@ def create_match(
|
||||
session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster]
|
||||
)
|
||||
|
||||
now = _utcnow()
|
||||
now = utcnow()
|
||||
match = Match(
|
||||
group_id=group_id,
|
||||
status="in_progress",
|
||||
@@ -264,7 +283,7 @@ def finish_match(
|
||||
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:
|
||||
@@ -279,11 +298,93 @@ def finish_match(
|
||||
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 _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")
|
||||
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("Некорректная причина победы.")
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
@@ -311,7 +412,18 @@ def update_match(
|
||||
participants: list[ParticipantInput] | None = None,
|
||||
expected_version: str | None = None,
|
||||
) -> Match:
|
||||
"""Правка завершённой партии (админ): полный список участников с местами."""
|
||||
"""Правка партии: состав с местами, дата, комментарий, причина победы.
|
||||
|
||||
Результаты (места и причина победы) пишутся только в завершённую партию: иначе они
|
||||
оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не
|
||||
попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished').
|
||||
Дату и общий комментарий править можно и по ходу партии — двойственного состояния
|
||||
они не создают."""
|
||||
results_touched = participants is not None or win_reason_set
|
||||
if results_touched and match.status != "finished":
|
||||
raise ConflictError(
|
||||
"Результаты незавершённой партии нельзя править — сначала завершите её."
|
||||
)
|
||||
assert_version(match, expected_version)
|
||||
if played_at is not None:
|
||||
match.played_at = played_at
|
||||
@@ -323,18 +435,23 @@ def update_match(
|
||||
match.win_reason = win_reason
|
||||
|
||||
if participants is not None:
|
||||
saved = session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all()
|
||||
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
||||
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
||||
_validate_roster_basics(
|
||||
session,
|
||||
match.group_id,
|
||||
[p.user_id for p in participants],
|
||||
[p.faction_id for p in participants],
|
||||
keep_user_ids={p.user_id for p in saved},
|
||||
keep_faction_ids={p.faction_id for p in saved},
|
||||
)
|
||||
places = _resolve_finish_places(
|
||||
[(p.user_id, p.place, p.eliminated) for p in participants]
|
||||
)
|
||||
for old in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all():
|
||||
for old in saved:
|
||||
session.delete(old)
|
||||
session.flush()
|
||||
for p in participants:
|
||||
@@ -351,7 +468,7 @@ def update_match(
|
||||
)
|
||||
match.player_count = len(participants)
|
||||
|
||||
session.add(match)
|
||||
touch(session, match)
|
||||
session.commit()
|
||||
session.refresh(match)
|
||||
return match
|
||||
|
||||
@@ -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
|
||||
def _assert_not_last_owner(session: Session, group: Group, member: GroupMember, message: str) -> None:
|
||||
"""Группа без владельца неисправима: назначить нового становится некому."""
|
||||
if member.role != "owner":
|
||||
return
|
||||
owners = session.exec(
|
||||
select(GroupMember.id).where(
|
||||
GroupMember.group_id == group.id, GroupMember.role == "owner"
|
||||
)
|
||||
).first()
|
||||
).all()
|
||||
if len(owners) <= 1:
|
||||
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("Игрок не состоит в группе.")
|
||||
if member.role == "owner":
|
||||
owners = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.role == "owner"
|
||||
)
|
||||
).all()
|
||||
if len(owners) <= 1:
|
||||
raise ForbiddenError("Нельзя удалить последнего владельца группы.")
|
||||
_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()
|
||||
|
||||
@@ -137,6 +137,9 @@ def mark_read(session: Session, user_id: int, ids: list[int] | None = None) -> i
|
||||
session.add(row)
|
||||
if rows:
|
||||
session.commit()
|
||||
# Счётчик непрочитанных изменился — толкаем тот же сигнал, что и create_for,
|
||||
# иначе вкладка на другом устройстве держит устаревший бейдж до перезагрузки.
|
||||
notify.notifications_changed(user_id)
|
||||
return len(rows)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.events import hub
|
||||
from app.models import GroupMember, Match
|
||||
from app.models import GroupMember, Match, MatchParticipant
|
||||
|
||||
|
||||
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||
@@ -17,19 +17,57 @@ def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||
)
|
||||
|
||||
|
||||
def match_changed(session: Session, match: Match) -> None:
|
||||
"""Партия изменилась — уведомить всех участников её группы."""
|
||||
hub.publish(
|
||||
_group_member_ids(session, match.group_id),
|
||||
{"type": "match", "match_id": match.id, "group_id": match.group_id},
|
||||
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 match_removed(session: Session, match_id: int, group_id: int) -> None:
|
||||
"""Партия удалена — уведомить участников группы (обновить списки)."""
|
||||
def match_changed(session: Session, match: Match) -> None:
|
||||
"""Партия изменилась — уведомить всех участников её группы.
|
||||
|
||||
Адресат — вся группа: списки партий и статистика группы меняются у всех. А вот
|
||||
история и профили протухают только у игравших, поэтому их id едут в событии."""
|
||||
hub.publish(
|
||||
_group_member_ids(session, match.group_id),
|
||||
{
|
||||
"type": "match",
|
||||
"match_id": match.id,
|
||||
"group_id": match.group_id,
|
||||
"participant_ids": match_participant_ids(session, match.id), # type: ignore[arg-type]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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, participant_ids: list[int] | None = None
|
||||
) -> None:
|
||||
"""Партия удалена — уведомить участников группы (обновить списки).
|
||||
|
||||
participant_ids передаются снаружи: к этому моменту партии уже нет, а её участники
|
||||
ушли каскадом, и собрать их из базы невозможно."""
|
||||
hub.publish(
|
||||
_group_member_ids(session, group_id),
|
||||
{"type": "match", "match_id": match_id, "group_id": group_id},
|
||||
{
|
||||
"type": "match",
|
||||
"match_id": match_id,
|
||||
"group_id": group_id,
|
||||
"participant_ids": participant_ids or [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,17 @@ MATCH_POINTS_SQL = (
|
||||
)
|
||||
|
||||
|
||||
def smoothed_score(points_sum: float, games: int) -> float | None:
|
||||
"""Тот же сглаженный рейтинг, что и SMOOTHED_SCORE_SQL, но в Python.
|
||||
|
||||
Нужен там, где строки уже вытащены и агрегировать в SQL нечего (профиль игрока).
|
||||
Держим рядом с SQL-версией и на одних константах: разъехавшиеся реализации одной
|
||||
формулы — источник расхождений, который потом ловится только глазами."""
|
||||
if games <= 0:
|
||||
return None # рейтинга без игр нет — как SUM(...) = NULL в SQL
|
||||
return (PRIOR_GAMES * PRIOR_MEAN + points_sum) / (PRIOR_GAMES + games) * 100
|
||||
|
||||
|
||||
def leaderboard_sort_key(row: dict) -> tuple:
|
||||
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
|
||||
return (
|
||||
|
||||
@@ -3,18 +3,19 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import func, text
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.models import Group, GroupMember, Match
|
||||
from app.services import group_service, membership_service, user_service
|
||||
from app.models import 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,
|
||||
SMOOTHED_SCORE_SQL,
|
||||
leaderboard_sort_key,
|
||||
smoothed_score,
|
||||
)
|
||||
|
||||
# Базовый блок: одна строка на участие с tie-aware очками.
|
||||
@@ -106,107 +107,123 @@ 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 "")
|
||||
def _user_scored_rows(session: Session, user_id: int) -> list[dict]:
|
||||
"""Строки участия игрока со всеми полями, нужными витринам профиля.
|
||||
|
||||
Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма
|
||||
последних партий считаются из одного и того же набора строк. CTE джойнит участия
|
||||
со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с
|
||||
общим числом партий, а не с числом партий игрока."""
|
||||
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,
|
||||
{SMOOTHED_SCORE_SQL} AS score
|
||||
FROM scored s
|
||||
{cond}
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
r = session.execute(text(sql), params).mappings().first() or {}
|
||||
return {
|
||||
"games": int(r.get("games") or 0),
|
||||
"wins": int(r.get("wins") or 0),
|
||||
"win_rate": _round(r.get("win_rate") or 0.0, 4),
|
||||
"avg_place": _round(r.get("avg_place"), 2),
|
||||
"score": _round(r.get("score"), 1),
|
||||
}
|
||||
|
||||
|
||||
def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> list[dict]:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||
e.code AS expansion_code,
|
||||
COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score, -- фракции: чистое среднее (служебная метрика
|
||||
-- «лучшая/худшая», сглаживание задавило бы её к 50)
|
||||
SUM(CASE WHEN s.was_random THEN 0 ELSE 1 END) AS chosen_games
|
||||
SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at,
|
||||
s.place AS place, s.player_count AS player_count,
|
||||
s.points AS points, s.is_win AS is_win,
|
||||
f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||
e.code AS expansion_code
|
||||
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
|
||||
WHERE s.user_id = :uid
|
||||
"""
|
||||
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()
|
||||
rows = session.execute(text(sql), {"uid": user_id}).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _for_group(rows: list[dict], group_id: int | None) -> list[dict]:
|
||||
return rows if group_id is None else [r for r in rows if r["group_id"] == group_id]
|
||||
|
||||
|
||||
def _overall_from_rows(rows: list[dict]) -> dict:
|
||||
"""Тот же итог, что раньше считал SQL: COUNT/SUM/AVG плюс сглаженный рейтинг."""
|
||||
games = len(rows)
|
||||
if games == 0:
|
||||
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
|
||||
wins = sum(int(r["is_win"]) for r in rows)
|
||||
return {
|
||||
"games": games,
|
||||
"wins": wins,
|
||||
"win_rate": _round(wins / games, 4),
|
||||
"avg_place": _round(sum(r["place"] for r in rows) / games, 2),
|
||||
"score": _round(smoothed_score(sum(float(r["points"]) for r in rows), games), 1),
|
||||
}
|
||||
|
||||
|
||||
def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]:
|
||||
by_faction: dict[int, list[dict]] = {}
|
||||
for r in rows:
|
||||
by_faction.setdefault(r["faction_id"], []).append(r)
|
||||
out = []
|
||||
for r in result:
|
||||
for fid, group in by_faction.items():
|
||||
games = len(group)
|
||||
wins = sum(int(r["is_win"]) for r in group)
|
||||
meta = group[0]
|
||||
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),
|
||||
# Служебное (в API-схему не входит): партии, где фракция выбрана
|
||||
# вручную, — для «любимой фракции» рандомные раздачи не в счёт.
|
||||
"chosen_games": int(r["chosen_games"] or 0),
|
||||
"faction_id": fid,
|
||||
"code": meta["code"],
|
||||
"name_ru": meta["name_ru"],
|
||||
"expansion_code": meta["expansion_code"],
|
||||
"games": games,
|
||||
"wins": wins,
|
||||
"win_rate": _round(wins / games, 4),
|
||||
"avg_place": _round(sum(r["place"] for r in group) / games, 2),
|
||||
# Фракции: чистое среднее (служебная метрика «лучшая/худшая»,
|
||||
# сглаживание задавило бы её к 50).
|
||||
"score": _round(sum(float(r["points"]) for r in group) / games * 100, 1),
|
||||
"name_ru_prepositional": faction_service.prepositional(
|
||||
meta["code"], meta["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_from_rows(rows: list[dict], limit: int = 5) -> list[dict]:
|
||||
recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True)
|
||||
return [
|
||||
{"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])}
|
||||
for r in result
|
||||
{
|
||||
"place": r["place"],
|
||||
"player_count": r["player_count"],
|
||||
"played_at": str(r["played_at"]),
|
||||
}
|
||||
for r 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 profile_stats(
|
||||
session: Session,
|
||||
user_id: int,
|
||||
group_id: int | None = None,
|
||||
*,
|
||||
rows: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Витрина профиля. rows — уже вытащенные строки игрока (home() их переиспользует)."""
|
||||
scoped = _for_group(rows if rows is not None else _user_scored_rows(session, user_id), group_id)
|
||||
overall = _overall_from_rows(scoped)
|
||||
factions = _faction_breakdown_from_rows(scoped)
|
||||
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
|
||||
# Любимая — по числу партий, где игрок выбрал фракцию САМ (was_random=false):
|
||||
# рандомная раздача о предпочтениях не говорит. Нет ручных выборов — нет любимой.
|
||||
chosen = [f for f in factions if f["chosen_games"] > 0]
|
||||
most_played = max(chosen, key=lambda f: f["chosen_games"]) if chosen else None
|
||||
# Основная — просто самая игранная, включая рандомные раздачи.
|
||||
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая
|
||||
# рандомные раздачи.
|
||||
main = max(factions, key=lambda f: f["games"]) if factions else None
|
||||
return {
|
||||
"user_id": user_id,
|
||||
@@ -214,21 +231,22 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
|
||||
"factions": factions,
|
||||
"best_faction": best,
|
||||
"worst_faction": worst,
|
||||
"most_played_faction": most_played,
|
||||
"favorite_faction": _favorite_faction(session, user_id),
|
||||
"main_faction": main,
|
||||
"recent_form": _recent_form(session, user_id, group_id),
|
||||
"recent_form": _recent_form_from_rows(scoped),
|
||||
"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))
|
||||
# Нужны только счётчик и дата последней партии — тянуть строки целиком незачем.
|
||||
games_count, last_played = session.exec(
|
||||
select(func.count(), func.max(Match.played_at)).where(
|
||||
Match.group_id == group_id, Match.status == "finished"
|
||||
)
|
||||
).one()
|
||||
last_at = str(last_played) if last_played else None
|
||||
|
||||
available_ids = group_service.available_faction_ids(session, group_id)
|
||||
faction_meta = []
|
||||
@@ -278,7 +296,7 @@ def group_stats(session: Session, group_id: int) -> dict:
|
||||
|
||||
return {
|
||||
"group_id": group_id,
|
||||
"total_matches": len(total_matches),
|
||||
"total_matches": games_count,
|
||||
"last_match_at": last_at,
|
||||
"leaderboard": board["entries"],
|
||||
"provisional": board["provisional"],
|
||||
@@ -288,34 +306,44 @@ def group_stats(session: Session, group_id: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
||||
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,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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) -> list[dict]:
|
||||
"""Элементы списка партий (общее для списка группы и истории игрока)."""
|
||||
by_match = _participants_by_match(session, [m.id for m in matches])
|
||||
|
||||
items = []
|
||||
for m in matches:
|
||||
parts = []
|
||||
for p, u, f in participants_detail(session, m.id): # type: ignore[arg-type]
|
||||
parts.append(
|
||||
{
|
||||
"user_id": u.id,
|
||||
"nickname": u.nickname,
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"eliminated": p.eliminated,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
)
|
||||
parts = by_match.get(m.id, [])
|
||||
items.append(
|
||||
{
|
||||
"id": m.id,
|
||||
@@ -331,13 +359,82 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
|
||||
"participants": parts,
|
||||
}
|
||||
)
|
||||
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:
|
||||
total = session.exec(
|
||||
select(func.count()).select_from(Match).where(Match.group_id == group_id)
|
||||
).one()
|
||||
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()
|
||||
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:
|
||||
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
|
||||
|
||||
best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает
|
||||
место и размер стола); при равных очках берём более свежую."""
|
||||
if best_only:
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT s.match_id AS match_id
|
||||
FROM scored s
|
||||
WHERE s.user_id = :uid
|
||||
ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
row = session.execute(text(sql), {"uid": user_id}).mappings().first()
|
||||
matches = [session.get(Match, row["match_id"])] if row else []
|
||||
return {
|
||||
"items": _match_items(session, matches),
|
||||
"total": len(matches),
|
||||
"limit": 1,
|
||||
"offset": 0,
|
||||
}
|
||||
|
||||
where = (MatchParticipant.user_id == user_id, Match.status == "finished")
|
||||
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),
|
||||
"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()
|
||||
)
|
||||
@@ -349,21 +446,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,
|
||||
"eliminated": p.eliminated,
|
||||
"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,
|
||||
@@ -379,7 +465,9 @@ 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)
|
||||
# Строки игрока тянем один раз: из них считается и профиль, и итог по активной группе.
|
||||
rows = _user_scored_rows(session, user_id)
|
||||
profile = profile_stats(session, user_id, group_id=None, rows=rows)
|
||||
active_group_brief = None
|
||||
if active_group_id is not None:
|
||||
group = session.get(Group, active_group_id)
|
||||
@@ -387,7 +475,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_from_rows(_for_group(rows, active_group_id)),
|
||||
}
|
||||
return {
|
||||
"leaderboard": board["entries"][:leaderboard_limit],
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Пользователи: создание из внешней личности, ник, активная группа, профиль."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import Session, select
|
||||
@@ -11,7 +10,8 @@ from sqlmodel import Session, select
|
||||
from app.auth.provider import ExternalIdentity
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
||||
from app.models import AuthIdentity, GroupMember, User
|
||||
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
|
||||
@@ -134,7 +134,7 @@ def register_from_identity(
|
||||
|
||||
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()
|
||||
@@ -147,12 +147,9 @@ 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)
|
||||
@@ -170,7 +167,13 @@ def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime |
|
||||
подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
|
||||
if not avatar_path:
|
||||
return None
|
||||
version = int(updated_at.timestamp()) if updated_at else 0
|
||||
# В БД время наивное и хранится в 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}"
|
||||
|
||||
|
||||
@@ -185,6 +188,54 @@ def update_bio(session: Session, user: User, bio: str | None) -> 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"):
|
||||
@@ -220,6 +271,10 @@ def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
|
||||
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)
|
||||
@@ -234,6 +289,7 @@ def clear_avatar(session: Session, user: User) -> User:
|
||||
except OSError:
|
||||
pass
|
||||
user.avatar_path = None
|
||||
user.updated_at = utcnow()
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
@@ -10,5 +10,7 @@ python -m app.bootstrap
|
||||
echo "[entrypoint] Запуск сервера…"
|
||||
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS):
|
||||
# чтобы приложение знало, что снаружи запрос пришёл по HTTPS.
|
||||
# --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита
|
||||
# остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown.
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \
|
||||
--proxy-headers --forwarded-allow-ips="*"
|
||||
--proxy-headers --forwarded-allow-ips="*" --timeout-graceful-shutdown 10
|
||||
|
||||
@@ -111,3 +111,24 @@ def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path
|
||||
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()
|
||||
|
||||
@@ -203,3 +203,75 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine
|
||||
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_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]
|
||||
|
||||
@@ -138,3 +138,23 @@ def test_non_member_cannot_view(client: TestClient, engine, monkeypatch, tmp_pat
|
||||
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"
|
||||
|
||||
@@ -75,3 +75,39 @@ def test_stale_finish_rejected(client: TestClient, engine):
|
||||
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,77 @@
|
||||
"""Событие партии несёт список участников: по нему клиент решает, чьи витрины протухли."""
|
||||
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 _match_events(published: list[tuple[list[int], dict]]) -> list[dict]:
|
||||
return [e for _ids, e in published if e.get("type") == "match"]
|
||||
|
||||
|
||||
def test_match_event_carries_participants(client: TestClient, engine, monkeypatch):
|
||||
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")
|
||||
# Третий в группе, но НЕ в партии: его история от этой партии не меняется.
|
||||
p3 = add_group_member(engine, gid, "Зритель")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
|
||||
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
|
||||
mid = started.json()["id"]
|
||||
|
||||
ev = _match_events(published)[-1]
|
||||
assert sorted(ev["participant_ids"]) == sorted([me["id"], p2])
|
||||
assert p3 not in ev["participant_ids"]
|
||||
|
||||
published.clear()
|
||||
fin = finish_match(
|
||||
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
||||
)
|
||||
assert fin.status_code == 200, fin.text
|
||||
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
||||
|
||||
|
||||
def test_delete_event_carries_participants(client: TestClient, engine, monkeypatch):
|
||||
"""Удаление — главный случай: строки участников уже уничтожены каскадом.
|
||||
|
||||
Если собирать их после удаления, список всегда окажется пустым, и клиент не
|
||||
обновит историю тем, кто в этой партии играл."""
|
||||
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()]
|
||||
mid = start_match(
|
||||
client, gid,
|
||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||
).json()["id"]
|
||||
|
||||
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 sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
||||
@@ -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,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
|
||||
+277
-15
@@ -1,9 +1,18 @@
|
||||
"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль."""
|
||||
"""Профиль: «о себе» (bio), любимая фракция, история партий, аватар
|
||||
(загрузка/отдача/удаление), публичный профиль."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session
|
||||
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||
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
|
||||
@@ -126,8 +135,8 @@ def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch
|
||||
assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка
|
||||
|
||||
|
||||
def test_most_played_faction_ignores_random(client: TestClient, engine):
|
||||
"""Любимая фракция — только по сознательному выбору: рандомные раздачи не в счёт."""
|
||||
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(
|
||||
@@ -156,17 +165,15 @@ def test_most_played_faction_ignores_random(client: TestClient, engine):
|
||||
)
|
||||
|
||||
stats = client.get("/api/users/me/stats").json()
|
||||
assert stats["most_played_faction"]["faction_id"] == fids[2] # B, хоть партий с A больше
|
||||
assert stats["main_faction"]["faction_id"] == fids[0] # основная — по всем партиям
|
||||
|
||||
# У соперника все фракции выбраны вручную — любимая считается как раньше.
|
||||
login(client, "Партнёр")
|
||||
stats2 = client.get("/api/users/me/stats").json()
|
||||
assert stats2["most_played_faction"]["faction_id"] == fids[1]
|
||||
# Условия выбора не важны: A сыграна чаще, пусть и по рандому.
|
||||
assert stats["main_faction"]["faction_id"] == fids[0]
|
||||
assert stats["main_faction"]["name_ru_prepositional"] # падежная форма проставлена
|
||||
# Любимая больше не выводится из истории — она задаётся игроком вручную.
|
||||
assert stats["favorite_faction"] is None
|
||||
|
||||
|
||||
def test_most_played_faction_absent_when_all_random(client: TestClient, engine):
|
||||
"""Если игрок ни разу не выбирал фракцию сам — любимой нет вовсе."""
|
||||
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(
|
||||
@@ -183,5 +190,260 @@ def test_most_played_faction_absent_when_all_random(client: TestClient, engine):
|
||||
],
|
||||
)
|
||||
stats = client.get("/api/users/me/stats").json()
|
||||
assert stats["most_played_faction"] is None
|
||||
assert stats["main_faction"]["faction_id"] == fids[0] # основная есть и у рандомщика
|
||||
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 берёт партию с максимальными League Points, а не самую свежую.
|
||||
|
||||
Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0."""
|
||||
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,80 @@
|
||||
"""Статистика профиля: цифры сходятся с лидербордом, а главная не гоняет CTE лишний раз."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import event
|
||||
|
||||
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):
|
||||
"""Профиль считает в Python, лидерборд — в SQL: цифры обязаны совпадать.
|
||||
|
||||
Формула сглаженного рейтинга живёт в двух видах (SMOOTHED_SCORE_SQL и
|
||||
scoring.smoothed_score); этот тест ловит их расхождение."""
|
||||
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_does_not_repeat_scored_cte(client: TestClient, engine):
|
||||
"""Главная делает не больше двух проходов по SCORED_CTE.
|
||||
|
||||
Было пять: лидерборд, три запроса профиля и итог по активной группе. Без этой
|
||||
проверки оптимизация тихо отъедет назад при следующей правке витрин."""
|
||||
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)
|
||||
)
|
||||
|
||||
seen: list[str] = []
|
||||
|
||||
def before_execute(conn, cursor, statement, params, context, executemany):
|
||||
if "WITH tie AS" in statement:
|
||||
seen.append(statement)
|
||||
|
||||
event.listen(engine, "before_cursor_execute", before_execute)
|
||||
try:
|
||||
r = client.get("/api/home")
|
||||
assert r.status_code == 200, r.text
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", before_execute)
|
||||
|
||||
# Сейчас ровно два: лидерборд и один проход по строкам игрока. Нижняя граница не
|
||||
# для красоты — без неё тест пройдёт и когда счётчик молча перестанет что-либо
|
||||
# ловить (сменился путь, переименован CTE).
|
||||
assert 1 <= len(seen) <= 2, f"ожидали 1–2 прохода, получили {len(seen)}"
|
||||
# Главная всё ещё показывает и профиль, и блок активной группы.
|
||||
body = r.json()
|
||||
assert body["profile"]["overall"]["games"] == 2
|
||||
assert body["active_group"]["games"] == 2
|
||||
@@ -57,3 +57,8 @@ HTTPS твоими сертификатами и проксирует трафи
|
||||
Шина событий — **in-memory**, рассчитана на один процесс (uvicorn `--workers 1`, как в
|
||||
контейнере). Если когда-нибудь поднимешь несколько воркеров/реплик — шину нужно вынести во
|
||||
внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию.
|
||||
|
||||
SSE-поток сам не закрывается, поэтому uvicorn запускается с `--timeout-graceful-shutdown`
|
||||
(`entrypoint.sh`, `run.*`). Без него остановка ждёт закрытия всех соединений: в dev
|
||||
`--reload` при открытой вкладке висит вечно, в контейнере остановку обрывает только SIGKILL
|
||||
по `stop_grace_period`.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#0f1115" />
|
||||
<title>Forbidden Stars — учёт партий</title>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "./schema";
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
export function readCookie(name: string): string | null {
|
||||
const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
@@ -53,3 +53,13 @@ export function unwrap<T>(res: FetchResult<T>): T {
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторы для проб сессии (`/users/me`, `/admin/me`). «Не авторизован» — это
|
||||
* только ответ сервера (401/403, обрабатывается в самих хуках); обрыв связи
|
||||
* ответом не является, и без повторов гварды приняли бы его за разлогин и
|
||||
* увели на страницу входа.
|
||||
*/
|
||||
export const authProbeRetry = (count: number, err: unknown): boolean =>
|
||||
!(err instanceof ApiError) && count < 2;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export const qk = {
|
||||
factions: ["factions"] as const,
|
||||
myStats: ["myStats"] as const,
|
||||
publicProfile: (id: number) => ["publicProfile", id] as const,
|
||||
userMatches: (id: number) => ["userMatches", id] as const,
|
||||
userSearch: (q: string, limit: number) => ["userSearch", q, limit] as const,
|
||||
groups: ["groups"] as const,
|
||||
group: (id: number) => ["group", id] as const,
|
||||
@@ -25,3 +26,17 @@ export const qk = {
|
||||
adminLogs: ["adminLogs"] as const,
|
||||
adminAchievements: ["adminAchievements"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем
|
||||
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один
|
||||
* список на SSE-обработчик и на завершение партии — иначе переименование ключа
|
||||
* в этом файле тихо разойдётся с местами, где он написан строкой.
|
||||
*/
|
||||
export const matchAffectedKeys = [
|
||||
qk.home,
|
||||
qk.leaderboard,
|
||||
qk.myStats,
|
||||
["userMatches"],
|
||||
["publicProfile"],
|
||||
] as const;
|
||||
|
||||
Vendored
+209
-1
@@ -174,6 +174,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/{user_id}/matches": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* User Matches
|
||||
* @description История партий игрока. Режим — витрина владельца профиля: её видят и гости.
|
||||
*/
|
||||
get: operations["user_matches_api_users__user_id__matches_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/me/stats": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -528,6 +548,29 @@ export interface paths {
|
||||
patch: operations["update_match_api_matches__match_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/matches/{match_id}/finish-draft": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Save Finish Draft
|
||||
* @description Общий черновик формы завершения: то, что видят все, кто заполняет партию.
|
||||
*
|
||||
* Права те же, что у самой формы. Версию партии запись черновика не двигает —
|
||||
* иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку.
|
||||
*/
|
||||
put: operations["save_finish_draft_api_matches__match_id__finish_draft_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/matches/{match_id}/attachments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1357,6 +1400,8 @@ export interface components {
|
||||
code: string;
|
||||
/** Name Ru */
|
||||
name_ru: string;
|
||||
/** Name Ru Prepositional */
|
||||
name_ru_prepositional: string;
|
||||
/** Expansion Code */
|
||||
expansion_code: string;
|
||||
/** Games */
|
||||
@@ -1581,6 +1626,45 @@ export interface components {
|
||||
/** Expected Version */
|
||||
expected_version?: string | null;
|
||||
};
|
||||
/**
|
||||
* MatchFinishDraftData
|
||||
* @description Состояние формы завершения: блоки мест (внутри блока — ничья), выбывшие,
|
||||
* комментарии об игроках и причина победы. Промежуточное состояние, поэтому
|
||||
* места не валидируются — человек раскладывает их постепенно.
|
||||
*/
|
||||
MatchFinishDraftData: {
|
||||
/**
|
||||
* Blocks
|
||||
* @default []
|
||||
*/
|
||||
blocks: number[][];
|
||||
/**
|
||||
* Eliminated
|
||||
* @default []
|
||||
*/
|
||||
eliminated: number[];
|
||||
/**
|
||||
* Comments
|
||||
* @default {}
|
||||
*/
|
||||
comments: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/** Win Reason */
|
||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
||||
/** Overall Comment */
|
||||
overall_comment?: string | null;
|
||||
};
|
||||
/** MatchFinishDraftRead */
|
||||
MatchFinishDraftRead: {
|
||||
data: components["schemas"]["MatchFinishDraftData"];
|
||||
/** Updated By */
|
||||
updated_by?: number | null;
|
||||
/** Updated By Nickname */
|
||||
updated_by_nickname?: string | null;
|
||||
/** Updated At */
|
||||
updated_at: string;
|
||||
};
|
||||
/** MatchFinishParticipant */
|
||||
MatchFinishParticipant: {
|
||||
/** User Id */
|
||||
@@ -1597,6 +1681,30 @@ export interface components {
|
||||
/** Faction Id */
|
||||
faction_id?: number | null;
|
||||
};
|
||||
/**
|
||||
* MatchHistory
|
||||
* @description История партий игрока плюс настройки витрины его профиля.
|
||||
*
|
||||
* Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так,
|
||||
* как выбрал её владелец, не делая второго запроса за профилем.
|
||||
*/
|
||||
MatchHistory: {
|
||||
/**
|
||||
* Items
|
||||
* @default []
|
||||
*/
|
||||
items: components["schemas"]["MatchListItem"][];
|
||||
/** Total */
|
||||
total: number;
|
||||
/** Limit */
|
||||
limit: number;
|
||||
/** Offset */
|
||||
offset: number;
|
||||
/** Mode */
|
||||
mode: string;
|
||||
/** Detail */
|
||||
detail: string;
|
||||
};
|
||||
/** MatchList */
|
||||
MatchList: {
|
||||
/**
|
||||
@@ -1729,6 +1837,7 @@ export interface components {
|
||||
* @default []
|
||||
*/
|
||||
attachments: components["schemas"]["AttachmentRead"][];
|
||||
finish_draft?: components["schemas"]["MatchFinishDraftRead"] | null;
|
||||
};
|
||||
/** MatchUpdate */
|
||||
MatchUpdate: {
|
||||
@@ -1761,6 +1870,18 @@ export interface components {
|
||||
bio?: string | null;
|
||||
/** Avatar Url */
|
||||
avatar_url?: string | null;
|
||||
/** Favorite Faction Id */
|
||||
favorite_faction_id?: number | null;
|
||||
/**
|
||||
* History Mode
|
||||
* @default all
|
||||
*/
|
||||
history_mode: string;
|
||||
/**
|
||||
* History Detail
|
||||
* @default compact
|
||||
*/
|
||||
history_detail: string;
|
||||
/**
|
||||
* Groups
|
||||
* @default []
|
||||
@@ -1881,7 +2002,7 @@ export interface components {
|
||||
factions: components["schemas"]["FactionStat"][];
|
||||
best_faction?: components["schemas"]["FactionStat"] | null;
|
||||
worst_faction?: components["schemas"]["FactionStat"] | null;
|
||||
most_played_faction?: components["schemas"]["FactionStat"] | null;
|
||||
favorite_faction?: components["schemas"]["FactionRead"] | null;
|
||||
main_faction?: components["schemas"]["FactionStat"] | null;
|
||||
/**
|
||||
* Recent Form
|
||||
@@ -1898,6 +2019,12 @@ export interface components {
|
||||
ProfileUpdate: {
|
||||
/** Bio */
|
||||
bio?: string | null;
|
||||
/** Favorite Faction Id */
|
||||
favorite_faction_id?: number | null;
|
||||
/** History Mode */
|
||||
history_mode?: string | null;
|
||||
/** History Detail */
|
||||
history_detail?: string | null;
|
||||
};
|
||||
/** PublicProfile */
|
||||
PublicProfile: {
|
||||
@@ -2004,6 +2131,18 @@ export interface components {
|
||||
bio?: string | null;
|
||||
/** Avatar Url */
|
||||
avatar_url?: string | null;
|
||||
/** Favorite Faction Id */
|
||||
favorite_faction_id?: number | null;
|
||||
/**
|
||||
* History Mode
|
||||
* @default all
|
||||
*/
|
||||
history_mode: string;
|
||||
/**
|
||||
* History Detail
|
||||
* @default compact
|
||||
*/
|
||||
history_detail: string;
|
||||
};
|
||||
/**
|
||||
* UserSuggestion
|
||||
@@ -2346,6 +2485,40 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
user_matches_api_users__user_id__matches_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
user_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MatchHistory"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
my_stats_api_users_me_stats_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3128,6 +3301,41 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
save_finish_draft_api_matches__match_id__finish_draft_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
match_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["MatchFinishDraftData"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MatchRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
add_attachment_api_matches__match_id__attachments_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -5,25 +5,33 @@ import { Spinner } from "../components/Spinner";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import { useAdminMe } from "../hooks/admin";
|
||||
|
||||
/** Запрос упал, а не ответил «не авторизован»: связи нет — это не повод разлогинивать. */
|
||||
function OfflineNotice() {
|
||||
return <div className="muted">Нет связи с сервером. Проверьте подключение и обновите страницу.</div>;
|
||||
}
|
||||
|
||||
export function RequireAuth({ children }: PropsWithChildren) {
|
||||
const { data: me, isLoading } = useMe();
|
||||
const { data: me, isLoading, isError } = useMe();
|
||||
const location = useLocation();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError) return <OfflineNotice />;
|
||||
if (!me) return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function RequireGroup({ children }: PropsWithChildren) {
|
||||
const { data: me, isLoading } = useMe();
|
||||
const { data: me, isLoading, isError } = useMe();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError) return <OfflineNotice />;
|
||||
if (!me) return <Navigate to="/login" replace />;
|
||||
if (me.active_group_id == null) return <Navigate to="/" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function RequireAdmin({ children }: PropsWithChildren) {
|
||||
const { data: admin, isLoading } = useAdminMe();
|
||||
const { data: admin, isLoading, isError } = useAdminMe();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError) return <OfflineNotice />;
|
||||
if (!admin) return <Navigate to="/admin/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,16 @@ import { useNavigate } from "react-router-dom";
|
||||
import { formatTime, plural } from "../domain/format";
|
||||
import type { HomeInProgressMatch } from "../domain/types";
|
||||
|
||||
export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
||||
/** Блок незавершённых партий. На главной партии приходят из разных групп, поэтому
|
||||
* название группы нужно; на странице самой группы оно дублирует заголовок — там
|
||||
* блок вызывается с showGroupName={false}. */
|
||||
export function InProgressMatches({
|
||||
items,
|
||||
showGroupName = true,
|
||||
}: {
|
||||
items: HomeInProgressMatch[];
|
||||
showGroupName?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
@@ -20,10 +29,7 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
||||
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<b>{m.group_name}</b>
|
||||
<span className="badge badge-live">идёт</span>
|
||||
</div>
|
||||
{showGroupName && <b>{m.group_name}</b>}
|
||||
<div className="muted small">
|
||||
{formatTime(m.started_at)} · {m.player_count}{" "}
|
||||
{plural(m.player_count, "игрок", "игрока", "игроков")}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { formatDate, formatDuration } from "../domain/format";
|
||||
import type { MatchListItem } from "../domain/types";
|
||||
import { MatchListView } from "./MatchList";
|
||||
|
||||
/**
|
||||
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
|
||||
* (MatchListView со всеми участниками); компактный — строка с результатом самого
|
||||
* владельца профиля, поэтому компонент и принимает его userId.
|
||||
*/
|
||||
export function MatchHistory({
|
||||
items,
|
||||
userId,
|
||||
detail,
|
||||
hasMore = false,
|
||||
loadingMore = false,
|
||||
onMore,
|
||||
}: {
|
||||
items: MatchListItem[];
|
||||
userId: number;
|
||||
detail: string;
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
onMore?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const more = hasMore && onMore && (
|
||||
<button
|
||||
className="btn btn-ghost btn-block"
|
||||
style={{ marginTop: 10 }}
|
||||
onClick={onMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? "Загружаю…" : "Показать ещё"}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
||||
if (detail === "full")
|
||||
return (
|
||||
<>
|
||||
<MatchListView items={items} />
|
||||
{more}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{items.map((m) => {
|
||||
const mine = m.participants.find((p) => p.user_id === userId);
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
className="card row-between"
|
||||
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
||||
onClick={() => navigate(`/match/${m.id}`)}
|
||||
>
|
||||
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
||||
<span
|
||||
className={
|
||||
"place-badge" +
|
||||
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
|
||||
(mine?.eliminated ? " eliminated" : "")
|
||||
}
|
||||
>
|
||||
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
|
||||
</span>
|
||||
<span
|
||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{mine?.faction_name ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="muted small" style={{ flexShrink: 0 }}>
|
||||
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{more}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import { formatDate, formatDuration } from "../domain/format";
|
||||
import { winReasonLabel } from "../domain/winReasons";
|
||||
import type { MatchListItem } from "../domain/types";
|
||||
|
||||
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
|
||||
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
|
||||
* отфильтрованной по status="finished". */
|
||||
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
const navigate = useNavigate();
|
||||
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
||||
@@ -11,7 +14,6 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
return (
|
||||
<div className="stack">
|
||||
{items.map((m) => {
|
||||
const inProgress = m.status === "in_progress";
|
||||
const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
|
||||
return (
|
||||
<button
|
||||
@@ -22,13 +24,9 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
>
|
||||
<div className="row-between">
|
||||
<span className="muted small">{formatDate(m.played_at)}</span>
|
||||
{inProgress ? (
|
||||
<span className="badge badge-live">идёт</span>
|
||||
) : (
|
||||
<span className="muted small">
|
||||
{formatDuration(m.duration_minutes)} · {winReasonLabel(m.win_reason)}
|
||||
</span>
|
||||
)}
|
||||
<span className="muted small">
|
||||
{formatDuration(m.duration_minutes)} · {winReasonLabel(m.win_reason)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
|
||||
{/* Ники здесь — просто текст: карточка целиком ведёт в партию,
|
||||
|
||||
@@ -53,6 +53,7 @@ export function PlaceEditor({
|
||||
const elimRef = useRef<HTMLDivElement>(null);
|
||||
const indRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const hintRef = useRef<string | null>(null);
|
||||
|
||||
const byId = new Map(players.map((p) => [p.user_id, p]));
|
||||
const placeOf = (i: number) =>
|
||||
@@ -80,6 +81,7 @@ export function PlaceEditor({
|
||||
};
|
||||
|
||||
const clearHints = () => {
|
||||
hintRef.current = null;
|
||||
if (indRef.current) indRef.current.style.display = "none";
|
||||
listRef.current
|
||||
?.querySelectorAll(".merge-target")
|
||||
@@ -88,7 +90,12 @@ export function PlaceEditor({
|
||||
};
|
||||
|
||||
const showHint = (t: Target, d: DragState) => {
|
||||
// pointermove срабатывает десятки раз в секунду, а цель меняется намного реже:
|
||||
// без этой проверки на каждое движение шёл querySelectorAll и перестановка классов.
|
||||
const key = `${t.type}:${t.type === "elim" ? "" : t.idx}`;
|
||||
if (key === hintRef.current) return;
|
||||
clearHints();
|
||||
hintRef.current = key;
|
||||
if (t.type === "elim") {
|
||||
elimRef.current?.classList.add("highlight");
|
||||
return;
|
||||
@@ -182,6 +189,10 @@ export function PlaceEditor({
|
||||
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => startDrag(e, opts),
|
||||
onPointerMove: moveDrag,
|
||||
onPointerUp: endDrag,
|
||||
// Браузер отменяет указатель (перехват прокрутки, входящий звонок) — без этого
|
||||
// блок остаётся с классом dragging и сдвигом: они выставлены в обход React,
|
||||
// и перерендер их не снимет.
|
||||
onPointerCancel: endDrag,
|
||||
});
|
||||
|
||||
// ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё.
|
||||
|
||||
@@ -38,16 +38,17 @@ export function ProfileStatsCard({
|
||||
<div>Очки (рейтинг)</div>
|
||||
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
|
||||
</div>
|
||||
{stats.most_played_faction && (
|
||||
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
|
||||
{stats.favorite_faction && (
|
||||
<div className="row-between">
|
||||
<div>Любимая фракция</div>
|
||||
<b>{stats.most_played_faction.name_ru}</b>
|
||||
<b>{stats.favorite_faction.name_ru}</b>
|
||||
</div>
|
||||
)}
|
||||
{stats.main_faction && (
|
||||
<div className="row-between">
|
||||
<div>Основная фракция</div>
|
||||
<b>{stats.main_faction.name_ru}</b>
|
||||
<div>Чаще всего играет на</div>
|
||||
<b>{stats.main_faction.name_ru_prepositional}</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
interface ToastCtx {
|
||||
@@ -17,8 +17,13 @@ export function ToastProvider({ children }: PropsWithChildren) {
|
||||
timer.current = setTimeout(() => setMessage(null), 2800);
|
||||
}, []);
|
||||
|
||||
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост:
|
||||
// без useMemo новое значение контекста заставляло бы перерисоваться и всех
|
||||
// потребителей, к самому тосту отношения не имеющих.
|
||||
const value = useMemo(() => ({ show }), [show]);
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ show }}>
|
||||
<Ctx.Provider value={value}>
|
||||
{children}
|
||||
{message && <div className="toast">{message}</div>}
|
||||
</Ctx.Provider>
|
||||
|
||||
@@ -29,12 +29,9 @@ export function formatDuration(minutes: number | null | undefined): string {
|
||||
|
||||
export function formatTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = shifted(iso);
|
||||
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
const d = shifted(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
// «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части,
|
||||
@@ -47,10 +44,7 @@ export function formatDate(iso: string | null | undefined): string {
|
||||
|
||||
export function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = shifted(iso);
|
||||
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
const d = shifted(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
@@ -33,3 +33,4 @@ export type ParticipantInput = S["ParticipantInput"];
|
||||
export type MatchUpdate = S["MatchUpdate"];
|
||||
export type MatchCreate = S["MatchCreate"];
|
||||
export type MatchFinish = S["MatchFinish"];
|
||||
export type MatchFinishDraftData = S["MatchFinishDraftData"];
|
||||
|
||||
+10
-51
@@ -1,14 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ApiError, api, unwrap } from "../api/client";
|
||||
import { api, authProbeRetry, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import type { AdminMe, MatchUpdate } from "../domain/types";
|
||||
import { resizeImage } from "../lib/image";
|
||||
|
||||
function readCsrfToken(): string | null {
|
||||
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
import { uploadFile } from "../lib/upload";
|
||||
|
||||
export function useAdminMe() {
|
||||
return useQuery({
|
||||
@@ -18,6 +13,7 @@ export function useAdminMe() {
|
||||
if (r.response.status === 401 || r.response.status === 403) return null;
|
||||
return unwrap(r);
|
||||
},
|
||||
retry: authProbeRetry,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,29 +102,8 @@ export function useAdminMatchAttachments(matchId: number | null) {
|
||||
export function useAdminUploadAttachment(matchId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const blob = await resizeImage(file);
|
||||
const form = new FormData();
|
||||
form.append("file", blob, "photo.jpg");
|
||||
const csrf = readCsrfToken();
|
||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const r = await fetch(`${base}/api/admin/matches/${matchId}/attachments`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
credentials: "include",
|
||||
headers: csrf ? { "X-CSRF-Token": csrf } : {},
|
||||
});
|
||||
if (!r.ok) {
|
||||
let env: { code?: string; message?: string } | undefined;
|
||||
try {
|
||||
env = ((await r.json()) as { error?: typeof env }).error;
|
||||
} catch {
|
||||
/* тело без JSON */
|
||||
}
|
||||
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
|
||||
}
|
||||
return await r.json();
|
||||
},
|
||||
mutationFn: async (file: File) =>
|
||||
uploadFile(`/api/admin/matches/${matchId}/attachments`, file),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }),
|
||||
});
|
||||
}
|
||||
@@ -283,28 +258,12 @@ export function useDeleteAchievement() {
|
||||
export function useUploadAchievementIcon() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ slug, file }: { slug: string; file: File }) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const csrf = readCsrfToken();
|
||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, {
|
||||
// Иконку шлём оригиналом: ресайз в JPEG убил бы прозрачность герба.
|
||||
mutationFn: async ({ slug, file }: { slug: string; file: File }) =>
|
||||
uploadFile(`/api/admin/achievements/${slug}/icon`, file, {
|
||||
method: "PUT",
|
||||
body: form,
|
||||
credentials: "include",
|
||||
headers: csrf ? { "X-CSRF-Token": csrf } : {},
|
||||
});
|
||||
if (!r.ok) {
|
||||
let env: { code?: string; message?: string } | undefined;
|
||||
try {
|
||||
env = ((await r.json()) as { error?: typeof env }).error;
|
||||
} catch {
|
||||
/* тело без JSON */
|
||||
}
|
||||
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
|
||||
}
|
||||
return await r.json();
|
||||
},
|
||||
maxSide: null,
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
||||
});
|
||||
}
|
||||
|
||||
+42
-61
@@ -1,7 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ApiError, api, unwrap } from "../api/client";
|
||||
import { api, authProbeRetry, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import { uploadFile } from "../lib/upload";
|
||||
import type { AuthConfig, Me } from "../domain/types";
|
||||
|
||||
export function useMe() {
|
||||
@@ -12,6 +13,7 @@ export function useMe() {
|
||||
if (r.response.status === 401) return null;
|
||||
return unwrap(r);
|
||||
},
|
||||
retry: authProbeRetry,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,74 +119,53 @@ export function useUpdateBio() {
|
||||
});
|
||||
}
|
||||
|
||||
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным
|
||||
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
|
||||
function readCsrfToken(): string | null {
|
||||
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
// Любимая фракция — личный выбор игрока (в статистике не вычисляется). null снимает выбор.
|
||||
// Шлём только это поле: роутер обновляет лишь пришедшие, «о себе» не затирается.
|
||||
export function useUpdateFavoriteFaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (factionId: number | null): Promise<Me> =>
|
||||
unwrap(
|
||||
await api.PATCH("/api/users/me/profile", {
|
||||
body: { favorite_faction_id: factionId },
|
||||
}),
|
||||
),
|
||||
onSuccess: (me) => {
|
||||
qc.setQueryData(qk.me, me);
|
||||
// Карточка статистики показывает любимую фракцию — её тоже перезапрашиваем.
|
||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resizeImage(file: File, max = 512): Promise<Blob> {
|
||||
try {
|
||||
const dataUrl = await new Promise<string>((res, rej) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => res(fr.result as string);
|
||||
fr.onerror = () => rej(fr.error);
|
||||
fr.readAsDataURL(file);
|
||||
});
|
||||
const img = await new Promise<HTMLImageElement>((res, rej) => {
|
||||
const i = new Image();
|
||||
i.onload = () => res(i);
|
||||
i.onerror = () => rej(new Error("image load failed"));
|
||||
i.src = dataUrl;
|
||||
});
|
||||
let { width, height } = img;
|
||||
if (width > max || height > max) {
|
||||
const scale = Math.min(max / width, max / height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return file;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
const blob = await new Promise<Blob | null>((res) =>
|
||||
canvas.toBlob(res, "image/jpeg", 0.85),
|
||||
);
|
||||
return blob ?? file;
|
||||
} catch {
|
||||
return file; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер)
|
||||
}
|
||||
// Витрина истории партий: режим (all/best) и подробность (compact/full).
|
||||
// Шлём только изменившееся поле — роутер обновляет лишь пришедшие.
|
||||
export function useUpdateHistoryPrefs() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (prefs: {
|
||||
history_mode?: string;
|
||||
history_detail?: string;
|
||||
}): Promise<Me> => unwrap(await api.PATCH("/api/users/me/profile", { body: prefs })),
|
||||
onSuccess: (me) => {
|
||||
qc.setQueryData(qk.me, me);
|
||||
// Список истории приходит вместе с режимом — перезапрашиваем его целиком.
|
||||
qc.invalidateQueries({ queryKey: qk.userMatches(me.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Аватар: картинку уменьшаем на клиенте (≤512px) и грузим общим загрузчиком.
|
||||
export function useUploadAvatar() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (file: File): Promise<Me> => {
|
||||
const blob = await resizeImage(file);
|
||||
const form = new FormData();
|
||||
form.append("file", blob, "avatar.jpg");
|
||||
const csrf = readCsrfToken();
|
||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const r = await fetch(`${base}/api/users/me/avatar`, {
|
||||
mutationFn: async (file: File): Promise<Me> =>
|
||||
uploadFile<Me>("/api/users/me/avatar", file, {
|
||||
method: "PUT",
|
||||
body: form,
|
||||
credentials: "include",
|
||||
headers: csrf ? { "X-CSRF-Token": csrf } : {},
|
||||
});
|
||||
if (!r.ok) {
|
||||
let env: { code?: string; message?: string; details?: unknown } | undefined;
|
||||
try {
|
||||
env = ((await r.json()) as { error?: typeof env }).error;
|
||||
} catch {
|
||||
/* тело без JSON */
|
||||
}
|
||||
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status, env?.details);
|
||||
}
|
||||
return (await r.json()) as Me;
|
||||
},
|
||||
fieldName: "avatar.jpg",
|
||||
maxSide: 512,
|
||||
}),
|
||||
onSuccess: (me) => {
|
||||
qc.setQueryData(qk.me, me);
|
||||
qc.invalidateQueries();
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ApiError, api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types";
|
||||
import { resizeImage } from "../lib/image";
|
||||
|
||||
function readCsrfToken(): string | null {
|
||||
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
||||
import type {
|
||||
FactionRead,
|
||||
MatchCreate,
|
||||
MatchFinish,
|
||||
MatchFinishDraftData,
|
||||
MatchRead,
|
||||
MatchUpdate,
|
||||
} from "../domain/types";
|
||||
import { uploadFile } from "../lib/upload";
|
||||
|
||||
export function useMatch(matchId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: matchId ? qk.match(matchId) : ["match", "none"],
|
||||
// Ключ и enabled должны сходиться: иначе запрос без валидного id кэшировал бы
|
||||
// свою ошибку под общим ключом-заглушкой.
|
||||
queryKey: matchId != null ? qk.match(matchId) : ["match", "none"],
|
||||
enabled: matchId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
@@ -23,6 +27,26 @@ export function useMatch(matchId: number | null) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Общий черновик формы завершения: то, что видят все, кто заполняет партию.
|
||||
* Ответ — свежая партия целиком, поэтому кладём его прямо в кэш, без инвалидации:
|
||||
* перезапрос на каждое движение тайла был бы расточительным.
|
||||
*/
|
||||
export function useSaveFinishDraft(matchId: number | null) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: MatchFinishDraftData): Promise<MatchRead> =>
|
||||
unwrap(
|
||||
await api.PUT("/api/matches/{match_id}/finish-draft", {
|
||||
params: { path: { match_id: matchId as number } },
|
||||
body: data,
|
||||
}),
|
||||
),
|
||||
onSuccess: (m) => qc.setQueryData(qk.match(m.id), m),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function useRandomizeFaction() {
|
||||
return useMutation({
|
||||
mutationFn: async (args: { group_id: number; exclude_faction_ids: number[] }): Promise<FactionRead> => {
|
||||
@@ -60,13 +84,33 @@ export function useFinishMatch() {
|
||||
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Правка завершённой партии участником группы (у админки свой эндпойнт). */
|
||||
export function useUpdateMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { matchId: number; body: MatchUpdate }): Promise<MatchRead> =>
|
||||
unwrap(
|
||||
await api.PATCH("/api/matches/{match_id}", {
|
||||
params: { path: { match_id: args.matchId } },
|
||||
body: args.body,
|
||||
}),
|
||||
),
|
||||
onSuccess: (m) => {
|
||||
qc.setQueryData(qk.match(m.id), m);
|
||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
||||
// Места изменились — значит изменились лидерборд, история и профили.
|
||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function useDeleteMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -88,29 +132,8 @@ export function useDeleteMatch() {
|
||||
export function useUploadMatchAttachment(matchId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const blob = await resizeImage(file);
|
||||
const form = new FormData();
|
||||
form.append("file", blob, "photo.jpg");
|
||||
const csrf = readCsrfToken();
|
||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const r = await fetch(`${base}/api/matches/${matchId}/attachments`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
credentials: "include",
|
||||
headers: csrf ? { "X-CSRF-Token": csrf } : {},
|
||||
});
|
||||
if (!r.ok) {
|
||||
let env: { code?: string; message?: string } | undefined;
|
||||
try {
|
||||
env = ((await r.json()) as { error?: typeof env }).error;
|
||||
} catch {
|
||||
/* тело без JSON */
|
||||
}
|
||||
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
|
||||
}
|
||||
return await r.json();
|
||||
},
|
||||
mutationFn: async (file: File) =>
|
||||
uploadFile(`/api/matches/${matchId}/attachments`, file),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ export function useExpansions() {
|
||||
});
|
||||
}
|
||||
|
||||
// Весь справочник фракций (для выбора любимой в профиле — он не зависит от группы).
|
||||
export function useFactions() {
|
||||
return useQuery({
|
||||
queryKey: qk.factions,
|
||||
queryFn: async () => unwrap(await api.GET("/api/factions")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupFactions(groupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"],
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { qk } from "../api/queryKeys";
|
||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
||||
import { useMe } from "./auth";
|
||||
|
||||
interface ServerEvent {
|
||||
type: "match" | "group" | "invitations" | "notifications";
|
||||
type: "match" | "match_draft" | "group" | "invitations" | "notifications";
|
||||
match_id?: number;
|
||||
group_id?: number;
|
||||
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
|
||||
participant_ids?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,6 +19,11 @@ interface ServerEvent {
|
||||
*/
|
||||
export function useServerEvents(enabled: boolean) {
|
||||
const qc = useQueryClient();
|
||||
const { data: me } = useMe();
|
||||
// Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали
|
||||
// SSE-соединение каждый раз, когда профиль перезапрашивается.
|
||||
const myId = useRef<number | null>(null);
|
||||
myId.current = me?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||
@@ -32,14 +40,34 @@ export function useServerEvents(enabled: boolean) {
|
||||
qc.invalidateQueries({ queryKey: qk.invitations });
|
||||
} else if (ev.type === "notifications") {
|
||||
qc.invalidateQueries({ queryKey: qk.notifications });
|
||||
} else if (ev.type === "match_draft") {
|
||||
// Черновик меняется на каждое движение тайла: трогаем только саму партию,
|
||||
// без лидерборда и историй — иначе группа захлебнётся перезапросами.
|
||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||
} else if (ev.type === "match") {
|
||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||
if (ev.group_id != null) {
|
||||
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
|
||||
}
|
||||
// Общее меняется от любой партии: рейтинг глобальный, и чужая игра двигает топ.
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
||||
if (ev.participant_ids) {
|
||||
// Личные витрины — только у игравших: иначе каждая партия в группе
|
||||
// заставляла бы всех остальных перезапрашивать свою историю.
|
||||
for (const pid of ev.participant_ids) {
|
||||
qc.invalidateQueries({ queryKey: qk.userMatches(pid) });
|
||||
qc.invalidateQueries({ queryKey: qk.publicProfile(pid) });
|
||||
}
|
||||
if (myId.current != null && ev.participant_ids.includes(myId.current)) {
|
||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
||||
}
|
||||
} else {
|
||||
// Событие от бэкенда без списка участников (вкладка открыта до обновления
|
||||
// сервера) — ведём себя как раньше, широко.
|
||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
} else if (ev.type === "group") {
|
||||
if (ev.group_id != null) {
|
||||
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
@@ -15,6 +15,34 @@ function useDebounced<T>(value: T, ms = 100): T {
|
||||
return debounced;
|
||||
}
|
||||
|
||||
const HISTORY_PAGE = 20;
|
||||
|
||||
/** История партий игрока страницами по 20 («Показать ещё»).
|
||||
*
|
||||
* Режим и подробность приходят вместе со списком: это витрина ВЛАДЕЛЬЦА профиля,
|
||||
* её видят и гости. Постранично, а не одним большим запросом: у эндпоинта потолок
|
||||
* limit=100, и на 101-й партии обрезка вернулась бы. */
|
||||
export function useUserMatches(userId: number | null) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: userId != null ? qk.userMatches(userId) : ["userMatches", "none"],
|
||||
enabled: userId != null,
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }) =>
|
||||
unwrap(
|
||||
await api.GET("/api/users/{user_id}/matches", {
|
||||
params: {
|
||||
path: { user_id: userId as number },
|
||||
query: { limit: HISTORY_PAGE, offset: pageParam as number },
|
||||
},
|
||||
}),
|
||||
),
|
||||
getNextPageParam: (last, pages) => {
|
||||
const loaded = pages.reduce((n, p) => n + p.items.length, 0);
|
||||
return loaded < last.total ? loaded : undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Подсказки по нику среди всех активных игроков (GET /api/users/search). */
|
||||
export function useUserSearch(q: string, limit = 3) {
|
||||
const dq = useDebounced(q.trim());
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ApiError, readCookie } from "../api/client";
|
||||
|
||||
import { resizeImage } from "./image";
|
||||
|
||||
/**
|
||||
* Загрузка файла multipart'ом. openapi-fetch неудобен для файлов, поэтому идём
|
||||
* обычным fetch — а значит CSRF-заголовок, базовый URL и разбор конверта ошибки
|
||||
* приходится ставить руками. Один хелпер на все четыре загрузки (аватар, фото
|
||||
* партии, фото партии из админки, иконка ачивки): иначе правка вроде таймаута
|
||||
* или прогресса расходится по копиям.
|
||||
*
|
||||
* maxSide задаёт ресайз перед отправкой; `null` отправляет файл как есть
|
||||
* (иконки ачивок грузятся оригиналом, у них свой лимит на сервере).
|
||||
*/
|
||||
export async function uploadFile<T>(
|
||||
url: string,
|
||||
file: File,
|
||||
opts: {
|
||||
method?: "POST" | "PUT";
|
||||
fieldName?: string;
|
||||
maxSide?: number | null;
|
||||
errorMessage?: string;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
method = "POST",
|
||||
maxSide = 1600,
|
||||
fieldName = maxSide == null ? file.name : "photo.jpg",
|
||||
errorMessage = "Не удалось загрузить",
|
||||
} = opts;
|
||||
const body = maxSide == null ? file : await resizeImage(file, maxSide);
|
||||
const form = new FormData();
|
||||
form.append("file", body, fieldName);
|
||||
const csrf = readCookie("csrf_token");
|
||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const r = await fetch(`${base}${url}`, {
|
||||
method,
|
||||
body: form,
|
||||
credentials: "include",
|
||||
headers: csrf ? { "X-CSRF-Token": csrf } : {},
|
||||
});
|
||||
if (!r.ok) {
|
||||
let env: { code?: string; message?: string; details?: unknown } | undefined;
|
||||
try {
|
||||
env = ((await r.json()) as { error?: typeof env }).error;
|
||||
} catch {
|
||||
/* тело без JSON */
|
||||
}
|
||||
throw new ApiError(env?.message || errorMessage, env?.code || "ERROR", r.status, env?.details);
|
||||
}
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
@@ -3,24 +3,41 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { MatchHistory } from "../components/MatchHistory";
|
||||
import { PickerSelect } from "../components/PickerSelect";
|
||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { Switch } from "../components/Switch";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import {
|
||||
useDeleteAvatar,
|
||||
useLogout,
|
||||
useMe,
|
||||
useUpdateBio,
|
||||
useUpdateFavoriteFaction,
|
||||
useUpdateHistoryPrefs,
|
||||
useUpdateNickname,
|
||||
useUploadAvatar,
|
||||
} from "../hooks/auth";
|
||||
import { useExpansions, useFactions } from "../hooks/reference";
|
||||
import { useMyStats } from "../hooks/stats";
|
||||
import { useUserMatches } from "../hooks/users";
|
||||
|
||||
export function AccountPage() {
|
||||
const { data: me, isLoading } = useMe();
|
||||
const { data: stats } = useMyStats();
|
||||
const updateNick = useUpdateNickname();
|
||||
const updateBio = useUpdateBio();
|
||||
const updateFavorite = useUpdateFavoriteFaction();
|
||||
const updateHistory = useUpdateHistoryPrefs();
|
||||
const { data: factions } = useFactions();
|
||||
const { data: expansions } = useExpansions();
|
||||
const {
|
||||
data: history,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useUserMatches(me?.id ?? null);
|
||||
const uploadAvatar = useUploadAvatar();
|
||||
const deleteAvatar = useDeleteAvatar();
|
||||
const logout = useLogout();
|
||||
@@ -35,6 +52,28 @@ export function AccountPage() {
|
||||
// bio редактируется локально; до первого ввода берём значение из профиля.
|
||||
const bioValue = bio ?? me.bio ?? "";
|
||||
|
||||
// Любимая фракция выбирается из ПОЛНОГО справочника: это свойство игрока, а не
|
||||
// группы, и не должно зависеть от подключённых у неё дополнений.
|
||||
const favorite = factions?.find((f) => f.id === me.favorite_faction_id) ?? null;
|
||||
const expansionName = (id: number) => expansions?.find((e) => e.id === id)?.name_ru ?? "";
|
||||
|
||||
const saveFavorite = async (factionId: number | null) => {
|
||||
try {
|
||||
await updateFavorite.mutateAsync(factionId);
|
||||
toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const saveHistory = async (prefs: { history_mode?: string; history_detail?: string }) => {
|
||||
try {
|
||||
await updateHistory.mutateAsync(prefs);
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const saveNick = async () => {
|
||||
const nick = nickname.trim();
|
||||
if (!nick) return;
|
||||
@@ -90,6 +129,7 @@ export function AccountPage() {
|
||||
<Avatar url={me.avatar_url} nickname={me.nickname} size={64} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<b style={{ fontSize: 18 }}>{me.nickname}</b>
|
||||
{stats && <div className="muted small">Партий: {stats.overall.games}</div>}
|
||||
<div className="row" style={{ gap: 8, marginTop: 8 }}>
|
||||
<button
|
||||
className="btn small"
|
||||
@@ -140,6 +180,36 @@ export function AccountPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ marginTop: 10 }}>
|
||||
<label className="label">Любимая фракция</label>
|
||||
<div className="row">
|
||||
<PickerSelect
|
||||
selected={favorite}
|
||||
options={factions ?? []}
|
||||
placeholder="Не выбрана"
|
||||
renderOption={(f) => (
|
||||
<>
|
||||
<span className="nick">{f.name_ru}</span>
|
||||
<span className="muted small" style={{ marginLeft: "auto" }}>
|
||||
{expansionName(f.expansion_id)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
renderValue={(f) => <span className="nick">{f.name_ru}</span>}
|
||||
onPick={(f) => saveFavorite(f.id)}
|
||||
/>
|
||||
{favorite && (
|
||||
<button
|
||||
className="btn btn-ghost small"
|
||||
onClick={() => saveFavorite(null)}
|
||||
disabled={updateFavorite.isPending}
|
||||
>
|
||||
Убрать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ marginTop: 10 }}>
|
||||
<label className="label">Сменить никнейм</label>
|
||||
<div className="row">
|
||||
@@ -153,6 +223,39 @@ export function AccountPage() {
|
||||
|
||||
{stats && <ProfileStatsCard stats={stats} />}
|
||||
|
||||
<div className="card">
|
||||
<h3>История игр</h3>
|
||||
{/* Настройки — витрина профиля: в этом же виде историю видят и гости. */}
|
||||
<div className="row-between">
|
||||
<div>Только лучшая партия</div>
|
||||
<Switch
|
||||
checked={me.history_mode === "best"}
|
||||
disabled={updateHistory.isPending}
|
||||
onChange={(v) => saveHistory({ history_mode: v ? "best" : "all" })}
|
||||
/>
|
||||
</div>
|
||||
<div className="row-between" style={{ marginTop: 10 }}>
|
||||
<div>Подробные карточки</div>
|
||||
<Switch
|
||||
checked={me.history_detail === "full"}
|
||||
disabled={updateHistory.isPending}
|
||||
onChange={(v) => saveHistory({ history_detail: v ? "full" : "compact" })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{history && (
|
||||
<MatchHistory
|
||||
items={history.pages.flatMap((p) => p.items)}
|
||||
userId={me.id}
|
||||
detail={history.pages[0].detail}
|
||||
hasMore={hasNextPage}
|
||||
loadingMore={isFetchingNextPage}
|
||||
onMore={() => fetchNextPage()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-danger btn-block" onClick={doLogout}>
|
||||
Выйти
|
||||
</button>
|
||||
|
||||
@@ -15,13 +15,21 @@ import { useGroupMembers } from "../hooks/groups";
|
||||
import { useRandomizeFaction, useStartMatch } from "../hooks/matches";
|
||||
|
||||
interface Draft {
|
||||
// Стабильный ключ строки: индексы сдвигаются при удалении, а барабан рандома
|
||||
// отложенно дописывает результат в свою строку и промахнулся бы по соседа.
|
||||
id: number;
|
||||
user_id: number | "";
|
||||
faction_id: number | "";
|
||||
was_random: boolean;
|
||||
}
|
||||
|
||||
let draftSeq = 0;
|
||||
|
||||
function emptyDraft(): Draft {
|
||||
return { user_id: "", faction_id: "", was_random: false };
|
||||
// Счётчик монотонный на весь модуль: id нужны лишь уникальные в пределах
|
||||
// страницы, а сквозная нумерация избавляет от коллизий при перемонтировании.
|
||||
draftSeq += 1;
|
||||
return { id: draftSeq, user_id: "", faction_id: "", was_random: false };
|
||||
}
|
||||
|
||||
export function CreateMatchPage() {
|
||||
@@ -51,30 +59,39 @@ export function CreateMatchPage() {
|
||||
if (lm || lf) return <Spinner />;
|
||||
if (!groupId) return <div className="muted">Нет активной группы.</div>;
|
||||
|
||||
const takenFactionIds = (exceptIdx: number) =>
|
||||
const takenFactionIds = (exceptId: number) =>
|
||||
rows
|
||||
.filter((_, i) => i !== exceptIdx)
|
||||
.filter((r) => r.id !== exceptId)
|
||||
.map((r) => r.faction_id)
|
||||
.filter((x): x is number => typeof x === "number");
|
||||
|
||||
// Игроки, уже выбранные в других строках, — в списке не предлагаются.
|
||||
const takenUserIds = (exceptIdx: number) =>
|
||||
const takenUserIds = (exceptId: number) =>
|
||||
rows
|
||||
.filter((_, i) => i !== exceptIdx)
|
||||
.filter((r) => r.id !== exceptId)
|
||||
.map((r) => r.user_id)
|
||||
.filter((x): x is number => typeof x === "number");
|
||||
|
||||
const update = (idx: number, patch: Partial<Draft>) =>
|
||||
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
const update = (id: number, patch: Partial<Draft>) =>
|
||||
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
|
||||
const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx));
|
||||
const removeRow = (id: number) => {
|
||||
const timer = reelTimers.current[id];
|
||||
if (timer) clearTimeout(timer);
|
||||
delete reelTimers.current[id];
|
||||
setSpin((sp) => {
|
||||
const { [id]: _dropped, ...rest } = sp;
|
||||
return rest;
|
||||
});
|
||||
setRows((rs) => rs.filter((r) => r.id !== id));
|
||||
};
|
||||
|
||||
// «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление),
|
||||
// затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только
|
||||
// после этого фракция «выбирается» (onReveal: показываем select с результатом).
|
||||
const animateReel = (
|
||||
idx: number,
|
||||
id: number,
|
||||
names: string[],
|
||||
finalName: string,
|
||||
onReveal: () => void,
|
||||
@@ -82,16 +99,16 @@ export function CreateMatchPage() {
|
||||
new Promise((resolve) => {
|
||||
let delay = 50;
|
||||
const tick = () => {
|
||||
setSpin((s) => ({ ...s, [idx]: names[Math.floor(Math.random() * names.length)] }));
|
||||
setSpin((s) => ({ ...s, [id]: names[Math.floor(Math.random() * names.length)] }));
|
||||
delay *= 1.18;
|
||||
if (delay < 300) {
|
||||
reelTimers.current[idx] = setTimeout(tick, delay);
|
||||
reelTimers.current[id] = setTimeout(tick, delay);
|
||||
} else {
|
||||
reelTimers.current[idx] = setTimeout(() => {
|
||||
setSpin((s) => ({ ...s, [idx]: finalName })); // остановка на выбранной
|
||||
reelTimers.current[idx] = setTimeout(() => {
|
||||
reelTimers.current[id] = setTimeout(() => {
|
||||
setSpin((s) => ({ ...s, [id]: finalName })); // остановка на выбранной
|
||||
reelTimers.current[id] = setTimeout(() => {
|
||||
onReveal();
|
||||
setSpin((s) => ({ ...s, [idx]: null }));
|
||||
setSpin((s) => ({ ...s, [id]: null }));
|
||||
resolve();
|
||||
}, 500); // пауза перед «выбором»
|
||||
}, delay);
|
||||
@@ -100,20 +117,20 @@ export function CreateMatchPage() {
|
||||
tick();
|
||||
});
|
||||
|
||||
const randomizeRow = async (idx: number) => {
|
||||
const randomizeRow = async (id: number) => {
|
||||
const pool = factions ?? [];
|
||||
if (pool.length === 0 || spin[idx] != null) return;
|
||||
if (pool.length === 0 || spin[id] != null) return;
|
||||
try {
|
||||
const faction = await randomize.mutateAsync({
|
||||
group_id: groupId,
|
||||
exclude_faction_ids: takenFactionIds(idx),
|
||||
exclude_faction_ids: takenFactionIds(id),
|
||||
});
|
||||
await animateReel(idx, pool.map((f) => f.name_ru), faction.name_ru, () =>
|
||||
update(idx, { faction_id: faction.id, was_random: true }),
|
||||
await animateReel(id, pool.map((f) => f.name_ru), faction.name_ru, () =>
|
||||
update(id, { faction_id: faction.id, was_random: true }),
|
||||
);
|
||||
} catch (e) {
|
||||
if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]);
|
||||
setSpin((s) => ({ ...s, [idx]: null }));
|
||||
if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
|
||||
setSpin((s) => ({ ...s, [id]: null }));
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
||||
}
|
||||
};
|
||||
@@ -130,10 +147,10 @@ export function CreateMatchPage() {
|
||||
const names = all.map((f) => f.name_ru);
|
||||
// Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
|
||||
await Promise.all(
|
||||
rows.map((_, idx) => {
|
||||
rows.map((row, idx) => {
|
||||
const f = pool[idx % pool.length];
|
||||
return animateReel(idx, names, f.name_ru, () =>
|
||||
update(idx, { faction_id: f.id, was_random: true }),
|
||||
return animateReel(row.id, names, f.name_ru, () =>
|
||||
update(row.id, { faction_id: f.id, was_random: true }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -194,11 +211,11 @@ export function CreateMatchPage() {
|
||||
</div>
|
||||
|
||||
{rows.map((row, idx) => (
|
||||
<div className="participant-row" key={idx}>
|
||||
<div className="participant-row" key={row.id}>
|
||||
<div className="row-between">
|
||||
<b>Игрок {idx + 1}</b>
|
||||
{rows.length > 2 && (
|
||||
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(idx)}>
|
||||
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(row.id)}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
@@ -206,29 +223,29 @@ export function CreateMatchPage() {
|
||||
|
||||
<PickerSelect
|
||||
selected={playerOptions.find((m) => m.id === row.user_id) ?? null}
|
||||
options={playerOptions.filter((m) => !takenUserIds(idx).includes(m.id))}
|
||||
options={playerOptions.filter((m) => !takenUserIds(row.id).includes(m.id))}
|
||||
placeholder="— игрок —"
|
||||
renderOption={(m) => playerLabel(m, 28)}
|
||||
renderValue={(m) => playerLabel(m, 24)}
|
||||
onPick={(m) => update(idx, { user_id: m.id })}
|
||||
onPick={(m) => update(row.id, { user_id: m.id })}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
{spin[idx] != null ? (
|
||||
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div>
|
||||
{spin[row.id] != null ? (
|
||||
<div className="faction-spin" style={{ flex: 1 }}>{spin[row.id]}</div>
|
||||
) : (
|
||||
<PickerSelect
|
||||
selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null}
|
||||
options={(factions ?? []).filter((f) => !takenFactionIds(idx).includes(f.id))}
|
||||
options={(factions ?? []).filter((f) => !takenFactionIds(row.id).includes(f.id))}
|
||||
placeholder="— фракция —"
|
||||
renderOption={(f) => f.name_ru}
|
||||
onPick={(f) => update(idx, { faction_id: f.id, was_random: false })}
|
||||
onPick={(f) => update(row.id, { faction_id: f.id, was_random: false })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => randomizeRow(idx)}
|
||||
disabled={spin[idx] != null}
|
||||
onClick={() => randomizeRow(row.id)}
|
||||
disabled={spin[row.id] != null}
|
||||
title="Случайная фракция"
|
||||
>
|
||||
<Dices size={18} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ApiError } from "../api/client";
|
||||
import { CreateGroupForm } from "../components/CreateGroupForm";
|
||||
import { GroupInvitations } from "../components/GroupInvitations";
|
||||
import { GroupSwitcher } from "../components/GroupSwitcher";
|
||||
import { InProgressMatches } from "../components/InProgressMatches";
|
||||
import { Leaderboard } from "../components/Leaderboard";
|
||||
import { MatchListView } from "../components/MatchList";
|
||||
import { PlayerSuggestions } from "../components/PlayerSuggestions";
|
||||
@@ -83,6 +84,14 @@ export function GroupPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Идущие партии выносим тем же блоком, что и на главной; из списка «Партии»
|
||||
// их исключаем, иначе одна партия показалась бы на странице дважды.
|
||||
const allMatches = matches?.items ?? [];
|
||||
const inProgress = allMatches
|
||||
.filter((m) => m.status === "in_progress")
|
||||
.map((m) => ({ ...m, group_id: groupId, group_name: group?.name ?? "" }));
|
||||
const finishedMatches = allMatches.filter((m) => m.status !== "in_progress");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
@@ -152,8 +161,11 @@ export function GroupPage() {
|
||||
|
||||
<GroupSwitcher />
|
||||
|
||||
{/* Название группы не показываем: оно уже есть в заголовке страницы. */}
|
||||
<InProgressMatches items={inProgress} showGroupName={false} />
|
||||
|
||||
<h3 style={{ marginTop: 8 }}>Партии</h3>
|
||||
{matches && <MatchListView items={matches.items} />}
|
||||
{matches && <MatchListView items={finishedMatches} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
@@ -9,23 +9,34 @@ import { PlaceEditor } from "../components/PlaceEditor";
|
||||
import { PlayerLink } from "../components/PlayerLink";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { formatDate, formatDuration, formatTime } from "../domain/format";
|
||||
import type { MatchFinishDraftData } from "../domain/types";
|
||||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import {
|
||||
useDeleteMatch,
|
||||
useDeleteMatchAttachment,
|
||||
useFinishMatch,
|
||||
useMatch,
|
||||
useSaveFinishDraft,
|
||||
useUpdateMatch,
|
||||
useUploadMatchAttachment,
|
||||
} from "../hooks/matches";
|
||||
import { useGroupFactions } from "../hooks/reference";
|
||||
|
||||
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
||||
|
||||
export function MatchDetailPage() {
|
||||
const { matchId } = useParams();
|
||||
const id = matchId ? Number(matchId) : null;
|
||||
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||||
const parsed = matchId ? Number(matchId) : NaN;
|
||||
const id = Number.isInteger(parsed) ? parsed : null;
|
||||
const { data: match, isLoading, refetch } = useMatch(id);
|
||||
const { data: me } = useMe();
|
||||
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
|
||||
const finish = useFinishMatch();
|
||||
const saveDraft = useSaveFinishDraft(id);
|
||||
const updateMatch = useUpdateMatch();
|
||||
const del = useDeleteMatch();
|
||||
const uploadAtt = useUploadMatchAttachment(id ?? 0);
|
||||
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
|
||||
@@ -40,6 +51,53 @@ export function MatchDetailPage() {
|
||||
const [overall, setOverall] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
// Правка завершённой партии: тот же редактор мест, что и при завершении.
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editFactions, setEditFactions] = useState<Record<number, number>>({});
|
||||
|
||||
// ─── Совместное заполнение формы ───
|
||||
// Отложенная запись черновика: он меняется на каждое движение тайла, слать
|
||||
// каждое было бы перебором. Актуальное состояние держим в ref — таймер иначе
|
||||
// отправил бы то, что было на момент его постановки.
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const draftPayload = useRef<MatchFinishDraftData | null>(null);
|
||||
const lastLocalEdit = useRef(0);
|
||||
const appliedDraftAt = useRef<string | null>(null);
|
||||
|
||||
const sendDraft = () => {
|
||||
draftTimer.current = null;
|
||||
if (draftPayload.current && id) saveDraft.mutate(draftPayload.current);
|
||||
};
|
||||
|
||||
const queueDraft = (payload: MatchFinishDraftData) => {
|
||||
draftPayload.current = payload;
|
||||
lastLocalEdit.current = Date.now();
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
draftTimer.current = setTimeout(sendDraft, 600);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Чужой черновик применяем, только если человек сейчас ничего не двигает:
|
||||
// иначе правка соседа перетёрла бы тайл прямо под рукой.
|
||||
const incoming = match?.finish_draft;
|
||||
useEffect(() => {
|
||||
if (!incoming || match?.status !== "in_progress") return;
|
||||
if (incoming.updated_by === me?.id) return;
|
||||
if (incoming.updated_at === appliedDraftAt.current) return;
|
||||
if (Date.now() - lastLocalEdit.current < 1500) return;
|
||||
setBlocks(incoming.data.blocks);
|
||||
setElim(incoming.data.eliminated);
|
||||
setComments(incoming.data.comments);
|
||||
setWinReason((incoming.data.win_reason ?? "objectives") as WinReason);
|
||||
setOverall(incoming.data.overall_comment ?? "");
|
||||
appliedDraftAt.current = incoming.updated_at;
|
||||
}, [incoming, match?.status, me?.id]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
||||
@@ -56,9 +114,27 @@ export function MatchDetailPage() {
|
||||
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
|
||||
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
|
||||
|
||||
// Снимок формы для общего черновика: то, что увидят остальные заполняющие.
|
||||
const draftOf = (patch: Partial<MatchFinishDraftData> = {}): MatchFinishDraftData => ({
|
||||
blocks: finishBlocks,
|
||||
eliminated: elim,
|
||||
comments: Object.fromEntries(
|
||||
Object.entries(finishComments).map(([uid, text]) => [String(uid), text]),
|
||||
),
|
||||
win_reason: winReason,
|
||||
overall_comment: overall.trim() || null,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const submitFinish = async () => {
|
||||
if (!id || !match) return;
|
||||
setError(null);
|
||||
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
|
||||
// но не в черновик, и второй участник увидел бы не то, что записалось.
|
||||
if (draftTimer.current) {
|
||||
clearTimeout(draftTimer.current);
|
||||
sendDraft();
|
||||
}
|
||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
||||
let place = 1;
|
||||
const survivors = finishBlocks.flatMap((ids) => {
|
||||
@@ -100,12 +176,85 @@ export function MatchDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Раскладка для правки восстанавливается из сохранённых результатов: одинаковое
|
||||
// место = ничья, выбывшие уходят отдельным списком.
|
||||
const startEditing = () => {
|
||||
const byPlace = new Map<number, number[]>();
|
||||
for (const p of match.participants.filter((x) => !x.eliminated)) {
|
||||
const key = p.place ?? 99;
|
||||
byPlace.set(key, [...(byPlace.get(key) ?? []), p.user_id]);
|
||||
}
|
||||
setBlocks([...byPlace.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids));
|
||||
setElim(match.participants.filter((p) => p.eliminated).map((p) => p.user_id));
|
||||
setComments(
|
||||
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
|
||||
);
|
||||
setWinReason((match.win_reason ?? "objectives") as WinReason);
|
||||
setOverall(match.overall_comment ?? "");
|
||||
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
|
||||
setError(null);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditing(false);
|
||||
setBlocks(null);
|
||||
setComments(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!id || !match) return;
|
||||
setError(null);
|
||||
const wasRandom = Object.fromEntries(
|
||||
match.participants.map((p) => [p.user_id, p.was_random]),
|
||||
);
|
||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
||||
const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({
|
||||
user_id: uid,
|
||||
faction_id: editFactions[uid],
|
||||
place,
|
||||
eliminated,
|
||||
was_random: wasRandom[uid] ?? false,
|
||||
comment: commentOf(uid),
|
||||
});
|
||||
let place = 1;
|
||||
const survivors = finishBlocks.flatMap((ids) => {
|
||||
const rows = ids.map((uid) => rowOf(uid, place, false));
|
||||
place += ids.length; // competition ranking: ничья съедает следующие места
|
||||
return rows;
|
||||
});
|
||||
try {
|
||||
await updateMatch.mutateAsync({
|
||||
matchId: id,
|
||||
body: {
|
||||
participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))],
|
||||
win_reason: winReason,
|
||||
overall_comment: overall.trim() || null,
|
||||
expected_version: match.version,
|
||||
},
|
||||
});
|
||||
toast.show("Партия обновлена");
|
||||
cancelEditing();
|
||||
} catch (e) {
|
||||
if (isStale(e)) {
|
||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||||
refetch();
|
||||
cancelEditing();
|
||||
return;
|
||||
}
|
||||
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!id || !match) return;
|
||||
try {
|
||||
await del.mutateAsync({ matchId: id, expectedVersion: match.version });
|
||||
toast.show(inProgress ? "Партия отменена" : "Партия удалена");
|
||||
navigate(-1);
|
||||
// Не navigate(-1): партию часто открывают по прямой ссылке, и «назад»
|
||||
// уводит из приложения вместо возврата к группе.
|
||||
navigate("/group", { replace: true });
|
||||
} catch (e) {
|
||||
if (isStale(e)) {
|
||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||||
@@ -120,6 +269,15 @@ export function MatchDetailPage() {
|
||||
(a, b) => (a.place ?? 99) - (b.place ?? 99),
|
||||
);
|
||||
|
||||
// К доступным группе фракциям добавляем те, что уже стоят в партии: дополнение
|
||||
// могли отключить после игры, но менять её фракцию это мешать не должно.
|
||||
const factionOptions = [
|
||||
...(groupFactions ?? []),
|
||||
...match.participants
|
||||
.filter((p) => !(groupFactions ?? []).some((f) => f.id === p.faction_id))
|
||||
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
@@ -143,11 +301,104 @@ export function MatchDetailPage() {
|
||||
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
|
||||
</div>
|
||||
|
||||
{/* Завершённая партия — правка результатов */}
|
||||
{!inProgress && editing && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3>Правка результатов</h3>
|
||||
<p className="muted small">
|
||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||
игрока, чтобы разделить место (ничья).
|
||||
</p>
|
||||
<PlaceEditor
|
||||
players={match.participants}
|
||||
blocks={finishBlocks}
|
||||
eliminated={elim}
|
||||
comments={finishComments}
|
||||
onChange={(b, e) => {
|
||||
setBlocks(b);
|
||||
setElim(e);
|
||||
}}
|
||||
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Фракции</h3>
|
||||
<div className="stack">
|
||||
{match.participants.map((p) => (
|
||||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||||
<b style={{ flex: "0 0 40%" }}>{p.nickname}</b>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PickerSelect
|
||||
selected={factionOptions.find((f) => f.id === editFactions[p.user_id]) ?? null}
|
||||
options={factionOptions.filter(
|
||||
(f) =>
|
||||
f.id === editFactions[p.user_id] ||
|
||||
!Object.values(editFactions).includes(f.id),
|
||||
)}
|
||||
placeholder="— фракция —"
|
||||
renderOption={(f) => f.name_ru}
|
||||
onPick={(f) => setEditFactions({ ...editFactions, [p.user_id]: f.id })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Причина победы</h3>
|
||||
<PickerSelect
|
||||
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
|
||||
options={REASON_OPTIONS}
|
||||
placeholder="— причина —"
|
||||
renderOption={(o) => o.label}
|
||||
onPick={(o) => setWinReason(o.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>О партии</h3>
|
||||
<textarea
|
||||
rows={2}
|
||||
style={{ resize: "none" }}
|
||||
placeholder="Комментарий о партии"
|
||||
value={overall}
|
||||
onChange={(e) => setOverall(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={submitEdit}
|
||||
disabled={updateMatch.isPending}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={cancelEditing}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Завершённая партия — результаты */}
|
||||
{!inProgress && (
|
||||
{!inProgress && !editing && (
|
||||
<div className="card">
|
||||
<h3>Результаты</h3>
|
||||
<div className="stack">
|
||||
<div className="row-between">
|
||||
<h3 style={{ margin: 0 }}>Результаты</h3>
|
||||
{canModify && (
|
||||
<button className="btn btn-ghost small" onClick={startEditing}>
|
||||
Редактировать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="stack" style={{ marginTop: 10 }}>
|
||||
{sorted.map((p) => (
|
||||
<div key={p.user_id}>
|
||||
<PlayerLink userId={p.user_id} className="row" style={{ gap: 8 }}>
|
||||
@@ -203,6 +454,13 @@ export function MatchDetailPage() {
|
||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||
игрока, чтобы разделить место (ничья).
|
||||
</p>
|
||||
{match.finish_draft && match.finish_draft.updated_by !== me?.id && (
|
||||
<p className="small" style={{ color: "var(--accent-2)" }}>
|
||||
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
|
||||
{" · "}
|
||||
{formatTime(match.finish_draft.updated_at)}
|
||||
</p>
|
||||
)}
|
||||
<PlaceEditor
|
||||
players={match.participants}
|
||||
blocks={finishBlocks}
|
||||
@@ -211,10 +469,19 @@ export function MatchDetailPage() {
|
||||
onChange={(b, e) => {
|
||||
setBlocks(b);
|
||||
setElim(e);
|
||||
queueDraft(draftOf({ blocks: b, eliminated: e }));
|
||||
}}
|
||||
onComment={(uid, text) => {
|
||||
const next = { ...finishComments, [uid]: text };
|
||||
setComments(next);
|
||||
queueDraft(
|
||||
draftOf({
|
||||
comments: Object.fromEntries(
|
||||
Object.entries(next).map(([k, v]) => [String(k), v]),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onComment={(uid, text) =>
|
||||
setComments({ ...finishComments, [uid]: text })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -225,7 +492,10 @@ export function MatchDetailPage() {
|
||||
options={REASON_OPTIONS}
|
||||
placeholder="— причина —"
|
||||
renderOption={(o) => o.label}
|
||||
onPick={(o) => setWinReason(o.id)}
|
||||
onPick={(o) => {
|
||||
setWinReason(o.id);
|
||||
queueDraft(draftOf({ win_reason: o.id }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -236,7 +506,10 @@ export function MatchDetailPage() {
|
||||
style={{ resize: "none" }}
|
||||
placeholder="Комментарий о партии"
|
||||
value={overall}
|
||||
onChange={(e) => setOverall(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setOverall(e.target.value);
|
||||
queueDraft(draftOf({ overall_comment: e.target.value.trim() || null }));
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<MatchMedia
|
||||
|
||||
@@ -3,12 +3,14 @@ import { useParams } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { MatchHistory } from "../components/MatchHistory";
|
||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import { useInviteMember } from "../hooks/groups";
|
||||
import { usePublicProfile } from "../hooks/stats";
|
||||
import { useUserMatches } from "../hooks/users";
|
||||
|
||||
/**
|
||||
* Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от
|
||||
@@ -19,6 +21,12 @@ export function PublicProfilePage() {
|
||||
const id = Number(userId);
|
||||
const { data, isLoading, isError } = usePublicProfile(id);
|
||||
const { data: me } = useMe();
|
||||
const {
|
||||
data: history,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useUserMatches(Number.isFinite(id) ? id : null);
|
||||
const invite = useInviteMember();
|
||||
const toast = useToast();
|
||||
const [pickOpen, setPickOpen] = useState(false);
|
||||
@@ -26,6 +34,7 @@ export function PublicProfilePage() {
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError || !data) return <div className="muted">Профиль не найден.</div>;
|
||||
|
||||
const historyItems = history?.pages.flatMap((p) => p.items) ?? [];
|
||||
const groups = me?.groups ?? [];
|
||||
const isSelf = me?.id === data.user_id;
|
||||
|
||||
@@ -46,6 +55,7 @@ export function PublicProfilePage() {
|
||||
<Avatar url={data.avatar_url} nickname={data.nickname} size={64} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<b style={{ fontSize: 18 }}>{data.nickname}</b>
|
||||
<div className="muted small">Партий: {data.stats.overall.games}</div>
|
||||
{data.bio ? (
|
||||
<div className="muted" style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>
|
||||
{data.bio}
|
||||
@@ -98,6 +108,21 @@ export function PublicProfilePage() {
|
||||
)}
|
||||
|
||||
<ProfileStatsCard stats={data.stats} title="Статистика" />
|
||||
|
||||
{/* История в том виде, который выбрал владелец профиля; контролов здесь нет. */}
|
||||
{historyItems.length > 0 && (
|
||||
<div className="card">
|
||||
<h3>{history!.pages[0].mode === "best" ? "Лучшая партия" : "История игр"}</h3>
|
||||
<MatchHistory
|
||||
items={historyItems}
|
||||
userId={data.user_id}
|
||||
detail={history!.pages[0].detail}
|
||||
hasMore={hasNextPage}
|
||||
loadingMore={isFetchingNextPage}
|
||||
onMore={() => fetchNextPage()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,8 +36,12 @@ export function AdminAccountsPage() {
|
||||
};
|
||||
|
||||
const toggleActive = async (id: number, isActive: boolean) => {
|
||||
await update.mutateAsync({ userId: id, is_active: !isActive }).catch(() => {});
|
||||
toast.show("Сохранено");
|
||||
try {
|
||||
await update.mutateAsync({ userId: id, is_active: !isActive });
|
||||
toast.show("Сохранено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,10 +11,17 @@ export function AdminFactionsPage() {
|
||||
const toast = useToast();
|
||||
const [names, setNames] = useState<Record<number, string>>({});
|
||||
|
||||
// Подставляем серверные названия только там, где поле ещё не трогали: refetch после
|
||||
// сохранения одной фракции иначе затирал бы несохранённый ввод в остальных.
|
||||
useEffect(() => {
|
||||
if (factions) {
|
||||
setNames(Object.fromEntries(factions.map((f) => [f.id, f.name_ru])));
|
||||
}
|
||||
if (!factions) return;
|
||||
setNames((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const f of factions) {
|
||||
if (next[f.id] === undefined) next[f.id] = f.name_ru;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [factions]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../../api/client";
|
||||
import { MatchMedia } from "../../components/MatchMedia";
|
||||
@@ -33,6 +34,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
const uploadAtt = useAdminUploadAttachment(matchId);
|
||||
const deleteAtt = useAdminDeleteAttachment(matchId);
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [playedAt, setPlayedAt] = useState("");
|
||||
@@ -110,6 +112,20 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
|
||||
{isLoading || !match ? (
|
||||
<Spinner />
|
||||
) : match.status === "in_progress" ? (
|
||||
// Результаты идущей партии сервер не примет (409): места без завершения
|
||||
// оставляли бы партию вне статистики. Объясняем это здесь, а не ошибкой.
|
||||
<div className="card">
|
||||
<h3>Партия ещё идёт</h3>
|
||||
<p className="muted small">
|
||||
Результаты незавершённой партии правкой не вносятся — их проставляют
|
||||
при завершении, на странице самой партии. Там же участники заполняют
|
||||
места совместно.
|
||||
</p>
|
||||
<button className="btn btn-block" onClick={() => navigate(`/match/${matchId}`)}>
|
||||
Открыть страницу партии
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="field">
|
||||
|
||||
@@ -11,6 +11,11 @@ html, body, #root {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Высота каркаса — по видимой области (dvh): на мобильных 100% берётся от layout
|
||||
viewport, который меняется при сворачивании адресной строки. Правило выше остаётся
|
||||
фоллбэком для движков без dvh. */
|
||||
#root { height: 100dvh; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
@@ -18,6 +23,7 @@ body {
|
||||
Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overscroll-behavior-y: none;
|
||||
overflow: hidden; /* прокручивается только .app-main */
|
||||
}
|
||||
|
||||
a { color: var(--accent-2); text-decoration: none; }
|
||||
@@ -190,7 +196,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(var(--bottombar-height) + 80px);
|
||||
bottom: calc(var(--bottombar-height) + 16px);
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
@@ -206,7 +212,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
/* ─── Уведомления ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */
|
||||
.top-bar-left { display: flex; align-items: center; min-width: 0; }
|
||||
.top-bar-left { display: flex; align-items: center; min-width: 0; flex: 0 1 auto; }
|
||||
.top-bar-center {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
@@ -215,7 +221,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
white-space: nowrap;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.top-bar-right { display: flex; align-items: center; }
|
||||
.top-bar-right { display: flex; align-items: center; flex: none; }
|
||||
.top-bar-home {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -223,6 +229,11 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
/* На узких экранах длинное название иначе распирает шапку по горизонтали. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notif-bell-wrap { position: relative; }
|
||||
@@ -258,7 +269,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: min(320px, 86vw);
|
||||
max-height: 70vh;
|
||||
max-height: 70dvh;
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
@@ -437,7 +448,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
/* Всплывашки сверху экрана в момент прихода */
|
||||
.toast-top-wrap {
|
||||
position: fixed;
|
||||
top: calc(var(--safe-top) + 10px);
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(var(--app-max-w), 94vw);
|
||||
|
||||
@@ -2,42 +2,44 @@
|
||||
position: relative;
|
||||
max-width: var(--app-max-w);
|
||||
margin: 0 auto;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Шапка вне прокручиваемой области по построению — sticky ей больше не нужен. */
|
||||
.top-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: calc(var(--safe-top) + 12px) 16px 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.top-bar .title { font-weight: 700; font-size: 17px; }
|
||||
|
||||
/* Единственная прокручиваемая область приложения. */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
min-height: 0; /* без этого flex-элемент не сжимается и прокрутка уходит в body */
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 14px;
|
||||
padding-bottom: calc(var(--bottombar-height) + var(--fab-height) + var(--safe-bottom) + 28px);
|
||||
/* Подвал теперь в потоке — компенсировать нужно только выступающий купол FAB. */
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Панель — обычный элемент флекс-колонки, не fixed: в мобильных браузерах со
|
||||
скрывающейся адресной строкой fixed-подвал дёргается при прокрутке.
|
||||
position: relative обязателен — на нём держится absolute-позиционирование .fab. */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: var(--app-max-w);
|
||||
height: calc(var(--bottombar-height) + var(--safe-bottom));
|
||||
padding-bottom: var(--safe-bottom);
|
||||
position: relative;
|
||||
flex: none;
|
||||
height: var(--bottombar-height);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
@@ -61,13 +63,14 @@
|
||||
.bottom-bar .slot .name { color: var(--text); font-weight: 600; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bottom-bar .spacer { width: var(--fab-width); flex: 0 0 var(--fab-width); }
|
||||
|
||||
/* Круг создания партии: полукруг-купол, по центру разделяет слоты, смещён вниз
|
||||
(база у нижней кромки панели) и поднят на передний план (z-index выше панели). */
|
||||
/* Круг создания партии: полукруг-купол, по центру разделяет слоты, базой стоит на
|
||||
нижнем крае экрана и поднят на передний план (z-index выше панели). Ширина вдвое
|
||||
больше высоты (140x70) — над кромкой панели купол выступает на 6px. */
|
||||
.fab {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(var(--safe-bottom) + 8px);
|
||||
bottom: 0;
|
||||
width: var(--fab-width);
|
||||
height: var(--fab-height);
|
||||
border: 1px solid var(--accent);
|
||||
@@ -131,6 +134,7 @@
|
||||
|
||||
/* Вкладки админки: горизонтальная карусель (не ломают вёрстку при переполнении) */
|
||||
.admin-tabs {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
@@ -218,7 +222,7 @@
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
width: min(300px, 84vw);
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
@@ -228,7 +232,6 @@
|
||||
z-index: 61;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--safe-top);
|
||||
}
|
||||
.side-menu.open { transform: translateX(0); }
|
||||
|
||||
|
||||
@@ -16,12 +16,10 @@
|
||||
/* Размеры */
|
||||
--app-max-w: 480px;
|
||||
--bottombar-height: 64px;
|
||||
--fab-width: 125px;
|
||||
--fab-height: 62.5px;
|
||||
--fab-width: 140px;
|
||||
--fab-height: 70px;
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
|
||||
/* Брейкпоинты (для справки) */
|
||||
--bp-lg: 768px;
|
||||
|
||||
@@ -82,10 +82,42 @@ switch ($appEnv) {
|
||||
Write-Host "node_modules not found. First: cd frontend; npm install" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
# Schema first: a branch may add a migration, and uvicorn would otherwise start
|
||||
# on the old schema and fail with 500 on the first hit of a new table.
|
||||
# Start-Process, not "& python ... 2> file": alembic logs to stderr, and in
|
||||
# Windows PowerShell 5.1 ANY stderr redirection of a native exe turns each line
|
||||
# into a NativeCommandError - which is terminating under $ErrorActionPreference
|
||||
# = "Stop" set at the top of this script, so the launcher died right here.
|
||||
Write-Host "Applying migrations (alembic upgrade head)..." -ForegroundColor Green
|
||||
$migOut = [System.IO.Path]::GetTempFileName()
|
||||
$migErr = [System.IO.Path]::GetTempFileName()
|
||||
$mig = Start-Process -FilePath $py `
|
||||
-ArgumentList "-m", "alembic", "upgrade", "head" `
|
||||
-WorkingDirectory $backend -NoNewWindow -Wait -PassThru `
|
||||
-RedirectStandardOutput $migOut -RedirectStandardError $migErr
|
||||
$migCode = $mig.ExitCode
|
||||
$migText = ((Get-Content $migErr -Raw), (Get-Content $migOut -Raw)) -join "`n"
|
||||
Remove-Item $migOut -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $migErr -Force -ErrorAction SilentlyContinue
|
||||
if ($migCode -ne 0) {
|
||||
Write-Host $migText
|
||||
if ($migText -match "locate revision") {
|
||||
# The DB carries a migration this branch does not have (switched back from
|
||||
# a feature branch). Extra tables do not bother the older code - go on.
|
||||
Write-Host "DB is newer than this branch - migrations skipped." -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Migrations failed - not starting. Fix the error above and retry." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Starting backend (uvicorn --reload) and frontend (vite) in separate windows..." -ForegroundColor Green
|
||||
# --timeout-graceful-shutdown: an open tab keeps the SSE stream /api/events forever,
|
||||
# and on reload the old process waits for ALL connections to close - with no limit
|
||||
# the reload never finishes and the site hangs on loading with nothing in the log.
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit", "-Command",
|
||||
"Set-Location '$backend'; & '$py' -m uvicorn app.main:app --reload"
|
||||
"Set-Location '$backend'; & '$py' -m uvicorn app.main:app --reload --timeout-graceful-shutdown 2"
|
||||
)
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit", "-Command",
|
||||
|
||||
@@ -50,8 +50,28 @@ case "$app_env" in
|
||||
[ -x "$py" ] || py="$root/backend/.venv/Scripts/python.exe" # Git Bash на Windows
|
||||
[ -x "$py" ] || { echo "Нет venv. Сначала: cd backend && python -m venv .venv && pip install -e '.[dev]'"; exit 1; }
|
||||
[ -d "$root/frontend/node_modules" ] || { echo "Нет node_modules. Сначала: cd frontend && npm install"; exit 1; }
|
||||
# Сначала схема: ветка могла принести миграцию, и без неё бэк поднимется на
|
||||
# старой схеме и упадёт 500 на первом же обращении к новой таблице.
|
||||
echo "Применяю миграции (alembic upgrade head)…"
|
||||
mig_log="$( cd "$root/backend" && "$py" -m alembic upgrade head 2>&1 )" && mig_code=0 || mig_code=$?
|
||||
if [ "$mig_code" -ne 0 ]; then
|
||||
echo "$mig_log"
|
||||
case "$mig_log" in
|
||||
*"locate revision"*)
|
||||
# В базе миграция, которой нет в этой ветке (вернулись с ветки задачи).
|
||||
# Лишние таблицы старому коду не мешают — продолжаем запуск.
|
||||
echo "База новее ветки — миграции пропущены." ;;
|
||||
*)
|
||||
echo "Миграции не применились — не запускаю. Исправьте ошибку выше и повторите."
|
||||
exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "Запуск бэка (uvicorn --reload) и фронта (vite)… Ctrl+C — остановить всё."
|
||||
( cd "$root/backend" && exec "$py" -m uvicorn app.main:app --reload ) &
|
||||
# --timeout-graceful-shutdown: открытая вкладка держит SSE-поток /api/events вечно, а при
|
||||
# reload старый процесс ждёт закрытия ВСЕХ соединений — без лимита перезапуск не
|
||||
# завершается, и сайт висит на загрузке без единой ошибки в логе.
|
||||
( cd "$root/backend" && exec "$py" -m uvicorn app.main:app --reload --timeout-graceful-shutdown 2 ) &
|
||||
back=$!
|
||||
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
||||
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
||||
|
||||
Reference in New Issue
Block a user