diff --git a/.gitignore b/.gitignore index 8a2ce9f..da9df0e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ backend/openapi.json # Бэкапы (создаёт scripts/backup.sh на Pi) backups/ +# Фронт-макеты для проработки UI (локальные прототипы, не для репозитория) +mockups/ + # AI-ассистенты (локальные, в репозиторий не идут) CLAUDE.md .claude/ diff --git a/README.md b/README.md index 10aa7ce..bbe83dd 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/backend/alembic/versions/0010_user_favorite_faction.py b/backend/alembic/versions/0010_user_favorite_faction.py new file mode 100644 index 0000000..a5740ea --- /dev/null +++ b/backend/alembic/versions/0010_user_favorite_faction.py @@ -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") diff --git a/backend/alembic/versions/0011_user_history_prefs.py b/backend/alembic/versions/0011_user_history_prefs.py new file mode 100644 index 0000000..539cb44 --- /dev/null +++ b/backend/alembic/versions/0011_user_history_prefs.py @@ -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") diff --git a/backend/alembic/versions/0012_match_finish_draft.py b/backend/alembic/versions/0012_match_finish_draft.py new file mode 100644 index 0000000..930c6ed --- /dev/null +++ b/backend/alembic/versions/0012_match_finish_draft.py @@ -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") diff --git a/backend/app/auth/login.py b/backend/app/auth/login.py index 5601f08..a69fe9e 100644 --- a/backend/app/auth/login.py +++ b/backend/app/auth/login.py @@ -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() diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 42dbf3d..14f9036 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/models.py b/backend/app/models.py index 478d619..993d68e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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; в БД только метаданные и относительный путь. diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 44b889f..5d58805 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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() diff --git a/backend/app/routers/dev_admin.py b/backend/app/routers/dev_admin.py index dd8130d..9e699f3 100644 --- a/backend/app/routers/dev_admin.py +++ b/backend/app/routers/dev_admin.py @@ -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() diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index 80ee642..1a9bc9d 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -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() diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index ef4cb4d..8778b7c 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -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() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 424a769..cb0e36a 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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), diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 2403c2a..4a3288e 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -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 единообразно подсвечивал diff --git a/backend/app/services/achievement_service.py b/backend/app/services/achievement_service.py index c01ec48..3bc1839 100644 --- a/backend/app/services/achievement_service.py +++ b/backend/app/services/achievement_service.py @@ -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 diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index 4616ea1..552f21d 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -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) # ─── Журнал аудита ─────────────────────────────────────────────────────────── diff --git a/backend/app/services/attachment_service.py b/backend/app/services/attachment_service.py index 5491e33..e3019cb 100644 --- a/backend/app/services/attachment_service.py +++ b/backend/app/services/attachment_service.py @@ -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() diff --git a/backend/app/services/faction_service.py b/backend/app/services/faction_service.py index 8d2529d..7ccae4c 100644 --- a/backend/app/services/faction_service.py +++ b/backend/app/services/faction_service.py @@ -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) diff --git a/backend/app/services/invitation_service.py b/backend/app/services/invitation_service.py index 5796e97..3b77c86 100644 --- a/backend/app/services/invitation_service.py +++ b/backend/app/services/invitation_service.py @@ -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, diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index 00ead8e..e016aca 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -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 diff --git a/backend/app/services/membership_service.py b/backend/app/services/membership_service.py index d4a83b6..18b8fba 100644 --- a/backend/app/services/membership_service.py +++ b/backend/app/services/membership_service.py @@ -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() diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index cd3c80d..c7e33b2 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -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) diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py index d2bd665..849c3de 100644 --- a/backend/app/services/notify.py +++ b/backend/app/services/notify.py @@ -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 [], + }, ) diff --git a/backend/app/services/scoring.py b/backend/app/services/scoring.py index ec7f93c..705bf86 100644 --- a/backend/app/services/scoring.py +++ b/backend/app/services/scoring.py @@ -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 ( diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 7424dc1..d77987e 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -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], diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 13d7916..987eeda 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index a513b62..3425430 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -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 diff --git a/backend/tests/test_achievements.py b/backend/tests/test_achievements.py index d940ded..f83f845 100644 --- a/backend/tests/test_achievements.py +++ b/backend/tests/test_achievements.py @@ -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() diff --git a/backend/tests/test_admin_extra.py b/backend/tests/test_admin_extra.py index 604e142..9d603f0 100644 --- a/backend/tests/test_admin_extra.py +++ b/backend/tests/test_admin_extra.py @@ -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] diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py index 5bce5d0..d0a48f5 100644 --- a/backend/tests/test_attachments.py +++ b/backend/tests/test_attachments.py @@ -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" diff --git a/backend/tests/test_concurrency.py b/backend/tests/test_concurrency.py index 516621f..fb7673d 100644 --- a/backend/tests/test_concurrency.py +++ b/backend/tests/test_concurrency.py @@ -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" diff --git a/backend/tests/test_events_payload.py b/backend/tests/test_events_payload.py new file mode 100644 index 0000000..63bd3db --- /dev/null +++ b/backend/tests/test_events_payload.py @@ -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]) diff --git a/backend/tests/test_finish_draft.py b/backend/tests/test_finish_draft.py new file mode 100644 index 0000000..e63e881 --- /dev/null +++ b/backend/tests/test_finish_draft.py @@ -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 diff --git a/backend/tests/test_match_edit.py b/backend/tests/test_match_edit.py new file mode 100644 index 0000000..43b11b8 --- /dev/null +++ b/backend/tests/test_match_edit.py @@ -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 diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index fb62917..03b02fb 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -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"]) diff --git a/backend/tests/test_stats_passes.py b/backend/tests/test_stats_passes.py new file mode 100644 index 0000000..6839358 --- /dev/null +++ b/backend/tests/test_stats_passes.py @@ -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 diff --git a/deploy/README.md b/deploy/README.md index fe335ca..4823166 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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`. diff --git a/frontend/index.html b/frontend/index.html index 874a03a..3748545 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ Forbidden Stars — учёт партий diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9baa563..f7a546b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(res: FetchResult): 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; + diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 951a533..cab916c 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -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; diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index ff8fb94..371d564 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -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; diff --git a/frontend/src/auth/guards.tsx b/frontend/src/auth/guards.tsx index a468f42..c5f24c9 100644 --- a/frontend/src/auth/guards.tsx +++ b/frontend/src/auth/guards.tsx @@ -5,25 +5,33 @@ import { Spinner } from "../components/Spinner"; import { useMe } from "../hooks/auth"; import { useAdminMe } from "../hooks/admin"; +/** Запрос упал, а не ответил «не авторизован»: связи нет — это не повод разлогинивать. */ +function OfflineNotice() { + return
Нет связи с сервером. Проверьте подключение и обновите страницу.
; +} + export function RequireAuth({ children }: PropsWithChildren) { - const { data: me, isLoading } = useMe(); + const { data: me, isLoading, isError } = useMe(); const location = useLocation(); if (isLoading) return ; + if (isError) return ; if (!me) return ; return <>{children}; } export function RequireGroup({ children }: PropsWithChildren) { - const { data: me, isLoading } = useMe(); + const { data: me, isLoading, isError } = useMe(); if (isLoading) return ; + if (isError) return ; if (!me) return ; if (me.active_group_id == null) return ; return <>{children}; } export function RequireAdmin({ children }: PropsWithChildren) { - const { data: admin, isLoading } = useAdminMe(); + const { data: admin, isLoading, isError } = useAdminMe(); if (isLoading) return ; + if (isError) return ; if (!admin) return ; return <>{children}; } diff --git a/frontend/src/components/InProgressMatches.tsx b/frontend/src/components/InProgressMatches.tsx index d4528c7..5b2daa5 100644 --- a/frontend/src/components/InProgressMatches.tsx +++ b/frontend/src/components/InProgressMatches.tsx @@ -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" }} >
-
- {m.group_name} - идёт -
+ {showGroupName && {m.group_name}}
{formatTime(m.started_at)} · {m.player_count}{" "} {plural(m.player_count, "игрок", "игрока", "игроков")} diff --git a/frontend/src/components/MatchHistory.tsx b/frontend/src/components/MatchHistory.tsx new file mode 100644 index 0000000..5d0b8f3 --- /dev/null +++ b/frontend/src/components/MatchHistory.tsx @@ -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 && ( + + ); + + if (items.length === 0) return
Партий пока нет.
; + if (detail === "full") + return ( + <> + + {more} + + ); + + return ( +
+ {items.map((m) => { + const mine = m.participants.find((p) => p.user_id === userId); + return ( + + ); + })} + {more} +
+ ); +} diff --git a/frontend/src/components/MatchList.tsx b/frontend/src/components/MatchList.tsx index 36d0555..014a31e 100644 --- a/frontend/src/components/MatchList.tsx +++ b/frontend/src/components/MatchList.tsx @@ -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
Партий пока нет.
; @@ -11,7 +14,6 @@ export function MatchListView({ items }: { items: MatchListItem[] }) { return (
{items.map((m) => { - const inProgress = m.status === "in_progress"; const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99)); return (
diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx index ccb0969..819c647 100644 --- a/frontend/src/context/ToastContext.tsx +++ b/frontend/src/context/ToastContext.tsx @@ -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 ( - + {children} {message &&
{message}
}
diff --git a/frontend/src/domain/format.ts b/frontend/src/domain/format.ts index 5885c61..f8cecd9 100644 --- a/frontend/src/domain/format.ts +++ b/frontend/src/domain/format.ts @@ -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())}`; } diff --git a/frontend/src/domain/types.ts b/frontend/src/domain/types.ts index baf132b..3230efb 100644 --- a/frontend/src/domain/types.ts +++ b/frontend/src/domain/types.ts @@ -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"]; diff --git a/frontend/src/hooks/admin.ts b/frontend/src/hooks/admin.ts index 8c4fa7a..8828c15 100644 --- a/frontend/src/hooks/admin.ts +++ b/frontend/src/hooks/admin.ts @@ -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 }), }); } diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index aef11de..9442c2d 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -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 => + 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 { - try { - const dataUrl = await new Promise((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((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((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 => 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 => { - 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 => + uploadFile("/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(); diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts index 90e2b94..8945c2e 100644 --- a/frontend/src/hooks/matches.ts +++ b/frontend/src/hooks/matches.ts @@ -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 => + 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 => { @@ -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 => + 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) }), }); } diff --git a/frontend/src/hooks/reference.ts b/frontend/src/hooks/reference.ts index 7f5133d..0b8abca 100644 --- a/frontend/src/hooks/reference.ts +++ b/frontend/src/hooks/reference.ts @@ -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"], diff --git a/frontend/src/hooks/useServerEvents.ts b/frontend/src/hooks/useServerEvents.ts index 8efddda..d18b957 100644 --- a/frontend/src/hooks/useServerEvents.ts +++ b/frontend/src/hooks/useServerEvents.ts @@ -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(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) }); diff --git a/frontend/src/hooks/users.ts b/frontend/src/hooks/users.ts index 191a328..b687ada 100644 --- a/frontend/src/hooks/users.ts +++ b/frontend/src/hooks/users.ts @@ -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(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()); diff --git a/frontend/src/lib/upload.ts b/frontend/src/lib/upload.ts new file mode 100644 index 0000000..1179d49 --- /dev/null +++ b/frontend/src/lib/upload.ts @@ -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( + url: string, + file: File, + opts: { + method?: "POST" | "PUT"; + fieldName?: string; + maxSide?: number | null; + errorMessage?: string; + } = {}, +): Promise { + 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; +} diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 14aac89..e4e8aa9 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -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() {
{me.nickname} + {stats &&
Партий: {stats.overall.games}
}
+
+ +
+ ( + <> + {f.name_ru} + + {expansionName(f.expansion_id)} + + + )} + renderValue={(f) => {f.name_ru}} + onPick={(f) => saveFavorite(f.id)} + /> + {favorite && ( + + )} +
+
+
@@ -153,6 +223,39 @@ export function AccountPage() { {stats && } +
+

История игр

+ {/* Настройки — витрина профиля: в этом же виде историю видят и гости. */} +
+
Только лучшая партия
+ saveHistory({ history_mode: v ? "best" : "all" })} + /> +
+
+
Подробные карточки
+ saveHistory({ history_detail: v ? "full" : "compact" })} + /> +
+
+ {history && ( + p.items)} + userId={me.id} + detail={history.pages[0].detail} + hasMore={hasNextPage} + loadingMore={isFetchingNextPage} + onMore={() => fetchNextPage()} + /> + )} +
+
+ diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx index e6aa1e9..d50afeb 100644 --- a/frontend/src/pages/CreateMatchPage.tsx +++ b/frontend/src/pages/CreateMatchPage.tsx @@ -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 ; if (!groupId) return
Нет активной группы.
; - 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) => - setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r))); + const update = (id: number, patch: Partial) => + 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() {
{rows.map((row, idx) => ( -
+
Игрок {idx + 1} {rows.length > 2 && ( - )} @@ -206,29 +223,29 @@ export function CreateMatchPage() { 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 })} />
- {spin[idx] != null ? ( -
{spin[idx]}
+ {spin[row.id] != null ? ( +
{spin[row.id]}
) : ( 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 })} /> )}