v1.35 — полировка мелких ошибок вёрстки, оптимизация сетевого кода, унификация и доработка элементов интерфейса
This commit is contained in:
@@ -38,6 +38,9 @@ backend/openapi.json
|
|||||||
# Бэкапы (создаёт scripts/backup.sh на Pi)
|
# Бэкапы (создаёт scripts/backup.sh на Pi)
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
|
# Фронт-макеты для проработки UI (локальные прототипы, не для репозитория)
|
||||||
|
mockups/
|
||||||
|
|
||||||
# AI-ассистенты (локальные, в репозиторий не идут)
|
# AI-ассистенты (локальные, в репозиторий не идут)
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ pip install -e ".[dev]" # .[dev] — один аргумент (пак
|
|||||||
Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория
|
Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория
|
||||||
alembic upgrade head # применит миграции и сидинг
|
alembic upgrade head # применит миграции и сидинг
|
||||||
python -m app.bootstrap # создаст/синхронизирует администратора из .env
|
python -m app.bootstrap # создаст/синхронизирует администратора из .env
|
||||||
uvicorn app.main:app --reload # http://localhost:8000 (Swagger: /api/docs)
|
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows cmd.exe** (здесь `&&` поддерживается):
|
**Windows cmd.exe** (здесь `&&` поддерживается):
|
||||||
@@ -61,7 +61,7 @@ pip install -e ".[dev]"
|
|||||||
copy ..\.env.example ..\.env
|
copy ..\.env.example ..\.env
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
python -m app.bootstrap
|
python -m app.bootstrap
|
||||||
uvicorn app.main:app --reload
|
uvicorn app.main:app --reload --timeout-graceful-shutdown 2
|
||||||
```
|
```
|
||||||
|
|
||||||
**Linux / macOS / Git Bash:**
|
**Linux / macOS / Git Bash:**
|
||||||
@@ -72,9 +72,13 @@ pip install -e ".[dev]"
|
|||||||
cp ../.env.example ../.env
|
cp ../.env.example ../.env
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
python -m app.bootstrap
|
python -m app.bootstrap
|
||||||
uvicorn app.main:app --reload
|
uvicorn app.main:app --reload --timeout-graceful-shutdown 2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **`--timeout-graceful-shutdown` обязателен.** Открытая вкладка держит SSE-поток
|
||||||
|
> `/api/events`, и без лимита `--reload` ждёт его закрытия вечно — сайт висит на загрузке,
|
||||||
|
> а в логе только `Reloading...`.
|
||||||
|
|
||||||
> **Единый `.env` — в корне репозитория** (`ForbidenStarsApp/.env`), рядом с `.env.example`.
|
> **Единый `.env` — в корне репозитория** (`ForbidenStarsApp/.env`), рядом с `.env.example`.
|
||||||
> Его читают и бэкенд (через абсолютный путь, независимо от рабочей папки), и `docker compose`.
|
> Его читают и бэкенд (через абсолютный путь, независимо от рабочей папки), и `docker compose`.
|
||||||
> Файл — локальный, на каждой машине свой (dev/prod различаются строкой `APP_ENV`).
|
> Файл — локальный, на каждой машине свой (dev/prod различаются строкой `APP_ENV`).
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Пользователь: любимая фракция (личный выбор в профиле).
|
||||||
|
|
||||||
|
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
||||||
|
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
|
||||||
|
|
||||||
|
Revision ID: 0010_user_favorite_faction
|
||||||
|
Revises: 0009_participant_eliminated
|
||||||
|
Create Date: 2026-09-07
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0010_user_favorite_faction"
|
||||||
|
down_revision: Union[str, None] = "0009_participant_eliminated"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
if "favorite_faction_id" not in cols:
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
b.add_column(sa.Column("favorite_faction_id", sa.Integer(), nullable=True))
|
||||||
|
b.create_foreign_key(
|
||||||
|
"fk_users_favorite_faction_id",
|
||||||
|
"factions",
|
||||||
|
["favorite_faction_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
if "favorite_faction_id" in cols:
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
b.drop_constraint("fk_users_favorite_faction_id", type_="foreignkey")
|
||||||
|
b.drop_column("favorite_faction_id")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Пользователь: настройки витрины истории партий в профиле.
|
||||||
|
|
||||||
|
Идемпотентна: на свежей БД столбцы создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
||||||
|
на существующей БД добавляет столбцы. render_as_batch включён в env.py (для SQLite).
|
||||||
|
|
||||||
|
Revision ID: 0011_user_history_prefs
|
||||||
|
Revises: 0010_user_favorite_faction
|
||||||
|
Create Date: 2026-09-07
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0011_user_history_prefs"
|
||||||
|
down_revision: Union[str, None] = "0010_user_favorite_faction"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
if "history_mode" not in cols:
|
||||||
|
b.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"history_mode", sa.String(8), nullable=False, server_default="all"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "history_detail" not in cols:
|
||||||
|
b.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"history_detail", sa.String(8), nullable=False, server_default="compact"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
if "history_detail" in cols:
|
||||||
|
b.drop_column("history_detail")
|
||||||
|
if "history_mode" in cols:
|
||||||
|
b.drop_column("history_mode")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Партия: общий черновик формы завершения (совместное заполнение результатов).
|
||||||
|
|
||||||
|
Идемпотентна: на свежей БД таблицу создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
||||||
|
на существующей БД создаёт таблицу.
|
||||||
|
|
||||||
|
Revision ID: 0012_match_finish_draft
|
||||||
|
Revises: 0011_user_history_prefs
|
||||||
|
Create Date: 2026-09-09
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0012_match_finish_draft"
|
||||||
|
down_revision: Union[str, None] = "0011_user_history_prefs"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
if "match_finish_drafts" in insp.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"match_finish_drafts",
|
||||||
|
sa.Column(
|
||||||
|
"match_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("matches.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column("data", sa.JSON(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"updated_by",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
if "match_finish_drafts" in insp.get_table_names():
|
||||||
|
op.drop_table("match_finish_drafts")
|
||||||
@@ -10,6 +10,7 @@ from sqlmodel import Session
|
|||||||
|
|
||||||
from app.auth.provider import ExternalIdentity
|
from app.auth.provider import ExternalIdentity
|
||||||
from app.core import security
|
from app.core import security
|
||||||
|
from app.core.security import client_ip
|
||||||
from app.core.errors import ForbiddenError
|
from app.core.errors import ForbiddenError
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.services import audit_service, user_service
|
from app.services import audit_service, user_service
|
||||||
@@ -29,7 +30,7 @@ def establish_session(
|
|||||||
entity_type="user",
|
entity_type="user",
|
||||||
entity_id=user.id,
|
entity_id=user.id,
|
||||||
payload={"provider": provider},
|
payload={"provider": provider},
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
@@ -118,3 +118,11 @@ def clear_user_session(response: Response) -> None:
|
|||||||
|
|
||||||
def clear_admin_session(response: Response) -> None:
|
def clear_admin_session(response: Response) -> None:
|
||||||
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
|
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str | None:
|
||||||
|
"""IP клиента для журнала аудита.
|
||||||
|
|
||||||
|
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из
|
||||||
|
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант."""
|
||||||
|
return request.client.host if request.client else None
|
||||||
|
|||||||
+78
-29
@@ -4,7 +4,7 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
JSON,
|
JSON,
|
||||||
@@ -23,9 +23,9 @@ from sqlalchemy import (
|
|||||||
)
|
)
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from app.core.timeutil import utcnow
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Справочники: дополнения и фракции ───────────────────────────────────────
|
# ─── Справочники: дополнения и фракции ───────────────────────────────────────
|
||||||
@@ -38,7 +38,7 @@ class Expansion(SQLModel, table=True):
|
|||||||
name_ru: str = Field(sa_column=Column(String(64), nullable=False))
|
name_ru: str = Field(sa_column=Column(String(64), nullable=False))
|
||||||
is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||||
sort_order: int = Field(default=0, nullable=False)
|
sort_order: int = Field(default=0, nullable=False)
|
||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Faction(SQLModel, table=True):
|
class Faction(SQLModel, table=True):
|
||||||
@@ -56,7 +56,7 @@ class Faction(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
sort_order: int = Field(default=0, nullable=False)
|
sort_order: int = Field(default=0, nullable=False)
|
||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
# ─── Пользователи и идентичности ─────────────────────────────────────────────
|
# ─── Пользователи и идентичности ─────────────────────────────────────────────
|
||||||
@@ -73,6 +73,10 @@ class User(SQLModel, table=True):
|
|||||||
"role <> 'admin' OR password_hash IS NOT NULL",
|
"role <> 'admin' OR password_hash IS NOT NULL",
|
||||||
name="ck_users_admin_has_password",
|
name="ck_users_admin_has_password",
|
||||||
),
|
),
|
||||||
|
CheckConstraint("history_mode IN ('all','best')", name="ck_users_history_mode"),
|
||||||
|
CheckConstraint(
|
||||||
|
"history_detail IN ('compact','full')", name="ck_users_history_detail"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
@@ -97,14 +101,34 @@ class User(SQLModel, table=True):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Витрина истории партий в профиле: что показывать (все / только лучшая по очкам)
|
||||||
|
# и насколько подробно. Действует и для гостей профиля, не только для владельца.
|
||||||
|
history_mode: str = Field(
|
||||||
|
default="all",
|
||||||
|
sa_column=Column(String(8), nullable=False, server_default="all"),
|
||||||
|
)
|
||||||
|
history_detail: str = Field(
|
||||||
|
default="compact",
|
||||||
|
sa_column=Column(String(8), nullable=False, server_default="compact"),
|
||||||
|
)
|
||||||
|
# Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление
|
||||||
|
# по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан.
|
||||||
|
favorite_faction_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("factions.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
|
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
|
||||||
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
|
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
|
||||||
# (выдача ачивок игрокам — следующий этап).
|
# (выдача ачивок игрокам — следующий этап).
|
||||||
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
|
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
|
||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=_utcnow,
|
default_factory=utcnow,
|
||||||
sa_column_kwargs={"onupdate": _utcnow},
|
sa_column_kwargs={"onupdate": utcnow},
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,7 +150,7 @@ class UserAchievement(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
|
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
|
||||||
earned_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
earned_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class AuthIdentity(SQLModel, table=True):
|
class AuthIdentity(SQLModel, table=True):
|
||||||
@@ -143,7 +167,7 @@ class AuthIdentity(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
provider: str = Field(sa_column=Column(String(16), nullable=False))
|
provider: str = Field(sa_column=Column(String(16), nullable=False))
|
||||||
external_id: str = Field(sa_column=Column(String(64), nullable=False))
|
external_id: str = Field(sa_column=Column(String(64), nullable=False))
|
||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
# ─── Группы и членство ───────────────────────────────────────────────────────
|
# ─── Группы и членство ───────────────────────────────────────────────────────
|
||||||
@@ -158,10 +182,10 @@ class Group(SQLModel, table=True):
|
|||||||
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
|
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=_utcnow,
|
default_factory=utcnow,
|
||||||
sa_column_kwargs={"onupdate": _utcnow},
|
sa_column_kwargs={"onupdate": utcnow},
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -185,7 +209,7 @@ class GroupMember(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member"))
|
role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member"))
|
||||||
joined_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
joined_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class GroupInvitation(SQLModel, table=True):
|
class GroupInvitation(SQLModel, table=True):
|
||||||
@@ -213,7 +237,7 @@ class GroupInvitation(SQLModel, table=True):
|
|||||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=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):
|
class GroupExpansion(SQLModel, table=True):
|
||||||
@@ -233,7 +257,7 @@ class GroupExpansion(SQLModel, table=True):
|
|||||||
Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False
|
Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
# ─── Партии и участники ──────────────────────────────────────────────────────
|
# ─── Партии и участники ──────────────────────────────────────────────────────
|
||||||
@@ -271,10 +295,10 @@ class Match(SQLModel, table=True):
|
|||||||
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
|
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=_utcnow,
|
default_factory=utcnow,
|
||||||
sa_column_kwargs={"onupdate": _utcnow},
|
sa_column_kwargs={"onupdate": utcnow},
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -309,7 +333,7 @@ class MatchParticipant(SQLModel, table=True):
|
|||||||
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||||
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||||
comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class MatchAttachment(SQLModel, table=True):
|
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))
|
storage_path: str = Field(sa_column=Column(String(255), nullable=False))
|
||||||
mime_type: str = Field(sa_column=Column(String(64), nullable=False))
|
mime_type: str = Field(sa_column=Column(String(64), nullable=False))
|
||||||
size_bytes: int = Field(sa_column=Column(Integer, 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):
|
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))
|
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))
|
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))
|
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))
|
payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True))
|
||||||
ip: str | None = Field(sa_column=Column(String(45), nullable=True))
|
ip: str | None = Field(sa_column=Column(String(45), nullable=True))
|
||||||
user_agent: str | None = Field(sa_column=Column(String(256), nullable=True))
|
user_agent: str | None = Field(sa_column=Column(String(256), nullable=True))
|
||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
# Заготовка под будущие вложения (НЕ в v1-миграции, добавится отдельно):
|
|
||||||
# class Attachment(SQLModel, table=True):
|
|
||||||
# id, match_id (FK CASCADE), participant_id (FK NULL SET NULL),
|
|
||||||
# uploaded_by (FK), kind ('photo'|'video'), storage_path, mime_type,
|
|
||||||
# size_bytes, created_at
|
|
||||||
# Файлы — на томе /data/uploads; в БД только метаданные и относительный путь.
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ from sqlmodel import Session
|
|||||||
|
|
||||||
from app.auth.deps import get_current_admin
|
from app.auth.deps import get_current_admin
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.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.core.timeutil import iso_utc
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.routers.matches import attachment_read, build_match_read
|
from app.routers.matches import attachment_read, build_match_read
|
||||||
from app.schemas import api as s
|
from app.schemas import api as s
|
||||||
|
from app.services.match_service import ParticipantInput
|
||||||
from app.services import (
|
from app.services import (
|
||||||
achievement_service,
|
achievement_service,
|
||||||
admin_service,
|
admin_service,
|
||||||
@@ -25,8 +27,6 @@ from app.services import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
|
_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"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ def admin_login(
|
|||||||
action="login",
|
action="login",
|
||||||
entity_type="admin",
|
entity_type="admin",
|
||||||
entity_id=admin.id,
|
entity_id=admin.id,
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -104,7 +104,7 @@ def update_user(
|
|||||||
entity_type="user",
|
entity_type="user",
|
||||||
entity_id=user_id,
|
entity_id=user_id,
|
||||||
payload=body.model_dump(exclude_none=True),
|
payload=body.model_dump(exclude_none=True),
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return s.AdminUserRead(
|
return s.AdminUserRead(
|
||||||
@@ -114,7 +114,7 @@ def update_user(
|
|||||||
is_active=u.is_active,
|
is_active=u.is_active,
|
||||||
auth_provider=u.auth_provider,
|
auth_provider=u.auth_provider,
|
||||||
telegram_id=u.telegram_id,
|
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)
|
admin_service.delete_group(session, group_id)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id,
|
session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id,
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
@@ -239,7 +239,7 @@ def update_match(
|
|||||||
action="update",
|
action="update",
|
||||||
entity_type="match",
|
entity_type="match",
|
||||||
entity_id=match.id,
|
entity_id=match.id,
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_changed(session, match)
|
notify.match_changed(session, match)
|
||||||
@@ -277,7 +277,7 @@ def rename_faction(
|
|||||||
entity_type="faction",
|
entity_type="faction",
|
||||||
entity_id=faction_id,
|
entity_id=faction_id,
|
||||||
payload={"name_ru": f.name_ru},
|
payload={"name_ru": f.name_ru},
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return s.FactionRead(
|
return s.FactionRead(
|
||||||
@@ -293,13 +293,15 @@ def delete_match(
|
|||||||
admin: User = Depends(get_current_admin),
|
admin: User = Depends(get_current_admin),
|
||||||
) -> s.OkResponse:
|
) -> s.OkResponse:
|
||||||
group_id = match_service.get_match(session, match_id).group_id # для уведомления
|
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)
|
admin_service.delete_match(session, match_id)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_removed(session, match_id, group_id)
|
notify.match_removed(session, match_id, group_id, participant_ids)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|
||||||
@@ -326,12 +328,9 @@ def admin_add_attachment(
|
|||||||
admin: User = Depends(get_current_admin),
|
admin: User = Depends(get_current_admin),
|
||||||
) -> s.AttachmentRead:
|
) -> s.AttachmentRead:
|
||||||
match = match_service.get_match(session, match_id)
|
match = match_service.get_match(session, match_id)
|
||||||
content = file.file.read(_ATTACHMENT_MAX_BYTES + 1)
|
content, ext = user_service.read_capped_image(
|
||||||
if len(content) > _ATTACHMENT_MAX_BYTES:
|
file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)."
|
||||||
raise ValidationError("Файл слишком большой (макс. 10 МБ).")
|
)
|
||||||
ext = user_service.sniff_image_ext(content)
|
|
||||||
if ext is None:
|
|
||||||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
|
||||||
att = attachment_service.add_photo(
|
att = attachment_service.add_photo(
|
||||||
session, match, admin, content, ext, user_service.avatar_media_type(ext)
|
session, match, admin, content, ext, user_service.avatar_media_type(ext)
|
||||||
)
|
)
|
||||||
@@ -389,7 +388,7 @@ def create_achievement(
|
|||||||
audit_service.record(
|
audit_service.record(
|
||||||
session, actor_id=admin.id, action="create", entity_type="achievement",
|
session, actor_id=admin.id, action="create", entity_type="achievement",
|
||||||
payload={"slug": ach["slug"], "name": ach["name"]},
|
payload={"slug": ach["slug"], "name": ach["name"]},
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return ach
|
return ach
|
||||||
@@ -408,7 +407,7 @@ def update_achievement(
|
|||||||
)
|
)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session, actor_id=admin.id, action="update", entity_type="achievement",
|
session, actor_id=admin.id, action="update", entity_type="achievement",
|
||||||
payload={"slug": slug}, ip=request.client.host if request.client else None,
|
payload={"slug": slug}, ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return ach
|
return ach
|
||||||
@@ -420,10 +419,9 @@ def upload_achievement_icon(
|
|||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1)
|
content, ext = user_service.read_capped_image(
|
||||||
if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES:
|
file, _ACHIEVEMENT_ICON_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
|
||||||
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
|
)
|
||||||
ext = achievement_service.validate_icon(content)
|
|
||||||
return achievement_service.set_icon(slug, content, ext)
|
return achievement_service.set_icon(slug, content, ext)
|
||||||
|
|
||||||
|
|
||||||
@@ -437,7 +435,7 @@ def delete_achievement(
|
|||||||
achievement_service.delete(slug)
|
achievement_service.delete(slug)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session, actor_id=admin.id, action="delete", entity_type="achievement",
|
session, actor_id=admin.id, action="delete", entity_type="achievement",
|
||||||
payload={"slug": slug}, ip=request.client.host if request.client else None,
|
payload={"slug": slug}, ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, Request
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.auth.deps import get_current_admin
|
from app.auth.deps import get_current_admin
|
||||||
|
from app.core.security import client_ip
|
||||||
from app.core.errors import NotFoundError, ValidationError
|
from app.core.errors import NotFoundError, ValidationError
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import Group, Match, MatchParticipant, User
|
from app.models import Group, Match, MatchParticipant, User
|
||||||
@@ -63,7 +64,7 @@ def delete_user_hard(
|
|||||||
entity_type="user",
|
entity_type="user",
|
||||||
entity_id=user_id,
|
entity_id=user_id,
|
||||||
payload={"hard": True, "nickname": nickname},
|
payload={"hard": True, "nickname": nickname},
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user
|
from app.auth.deps import get_current_user
|
||||||
|
from app.core.security import client_ip
|
||||||
from app.core.timeutil import iso_utc
|
from app.core.timeutil import iso_utc
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
@@ -61,7 +62,7 @@ def create_group(
|
|||||||
entity_type="group",
|
entity_type="group",
|
||||||
entity_id=group.id,
|
entity_id=group.id,
|
||||||
payload={"name": group.name},
|
payload={"name": group.name},
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -157,7 +158,7 @@ def invite_member(
|
|||||||
entity_type="group_invitation",
|
entity_type="group_invitation",
|
||||||
entity_id=group_id,
|
entity_id=group_id,
|
||||||
payload={"invited_user_id": invited.id, "nickname": invited.nickname},
|
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"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from fastapi.responses import FileResponse
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user
|
from app.auth.deps import get_current_user
|
||||||
from app.core.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.core.timeutil import iso_utc
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import Match, MatchAttachment, User
|
from app.models import Match, MatchAttachment, User
|
||||||
@@ -24,7 +25,6 @@ from app.services.match_service import FinishInput, ParticipantInput, RosterInpu
|
|||||||
|
|
||||||
router = APIRouter(prefix="/matches", tags=["matches"])
|
router = APIRouter(prefix="/matches", tags=["matches"])
|
||||||
|
|
||||||
_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ
|
|
||||||
|
|
||||||
|
|
||||||
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
|
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]
|
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
|
||||||
]
|
]
|
||||||
|
draft_row = match_service.get_finish_draft(session, match.id) # type: ignore[arg-type]
|
||||||
|
draft = None
|
||||||
|
if draft_row is not None:
|
||||||
|
author = session.get(User, draft_row.updated_by) if draft_row.updated_by else None
|
||||||
|
draft = s.MatchFinishDraftRead(
|
||||||
|
data=s.MatchFinishDraftData(**draft_row.data),
|
||||||
|
updated_by=draft_row.updated_by,
|
||||||
|
updated_by_nickname=author.nickname if author else None,
|
||||||
|
updated_at=iso_utc(draft_row.updated_at),
|
||||||
|
)
|
||||||
return s.MatchRead(
|
return s.MatchRead(
|
||||||
id=match.id, # type: ignore[arg-type]
|
id=match.id, # type: ignore[arg-type]
|
||||||
group_id=match.group_id,
|
group_id=match.group_id,
|
||||||
@@ -79,6 +89,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
|||||||
attachment_read(a, f"/api/matches/{match.id}")
|
attachment_read(a, f"/api/matches/{match.id}")
|
||||||
for a in attachment_service.list_for_match(session, match.id) # type: ignore[arg-type]
|
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_type="match",
|
||||||
entity_id=match.id,
|
entity_id=match.id,
|
||||||
payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"},
|
payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"},
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -165,7 +176,7 @@ def finish_match(
|
|||||||
entity_type="match",
|
entity_type="match",
|
||||||
entity_id=match.id,
|
entity_id=match.id,
|
||||||
payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes},
|
payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes},
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -225,7 +236,7 @@ def update_match(
|
|||||||
action="update",
|
action="update",
|
||||||
entity_type="match",
|
entity_type="match",
|
||||||
entity_id=match.id,
|
entity_id=match.id,
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -233,6 +244,26 @@ def update_match(
|
|||||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{match_id}/finish-draft", response_model=s.MatchRead)
|
||||||
|
def save_finish_draft(
|
||||||
|
match_id: int,
|
||||||
|
body: s.MatchFinishDraftData,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> s.MatchRead:
|
||||||
|
"""Общий черновик формы завершения: то, что видят все, кто заполняет партию.
|
||||||
|
|
||||||
|
Права те же, что у самой формы. Версию партии запись черновика не двигает —
|
||||||
|
иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку."""
|
||||||
|
match = match_service.get_match(session, match_id)
|
||||||
|
match_service.assert_can_modify(session, match, user)
|
||||||
|
match_service.save_finish_draft(session, match, user, body.model_dump())
|
||||||
|
notify.match_draft_changed(session, match, actor_id=user.id) # type: ignore[arg-type]
|
||||||
|
return build_match_read(
|
||||||
|
session, match, can_modify=match_service.can_modify(session, match, user)
|
||||||
|
)
|
||||||
|
|
||||||
# ─── Медиа партии (фото) ──────────────────────────────────────────────────────
|
# ─── Медиа партии (фото) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _assert_can_attach(session: Session, match: Match, user: User) -> None:
|
def _assert_can_attach(session: Session, match: Match, user: User) -> None:
|
||||||
@@ -250,12 +281,9 @@ def add_attachment(
|
|||||||
) -> s.AttachmentRead:
|
) -> s.AttachmentRead:
|
||||||
match = match_service.get_match(session, match_id)
|
match = match_service.get_match(session, match_id)
|
||||||
_assert_can_attach(session, match, user)
|
_assert_can_attach(session, match, user)
|
||||||
content = file.file.read(_ATTACHMENT_MAX_BYTES + 1)
|
content, ext = user_service.read_capped_image(
|
||||||
if len(content) > _ATTACHMENT_MAX_BYTES:
|
file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)."
|
||||||
raise ValidationError("Файл слишком большой (макс. 10 МБ).")
|
)
|
||||||
ext = user_service.sniff_image_ext(content)
|
|
||||||
if ext is None:
|
|
||||||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
|
||||||
att = attachment_service.add_photo(
|
att = attachment_service.add_photo(
|
||||||
session, match, user, content, ext, user_service.avatar_media_type(ext)
|
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_service.assert_can_modify(session, match, user)
|
||||||
match_id_val = match.id
|
match_id_val = match.id
|
||||||
group_id_val = match.group_id
|
group_id_val = match.group_id
|
||||||
|
# Участников читаем до удаления: каскад унесёт их строки вместе с партией,
|
||||||
|
# а событию они нужны, чтобы клиент знал, чьи витрины протухли.
|
||||||
|
participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type]
|
||||||
match_service.delete_match(session, match, expected_version=expected_version)
|
match_service.delete_match(session, match, expected_version=expected_version)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
@@ -315,9 +346,9 @@ def delete_match(
|
|||||||
entity_type="match",
|
entity_type="match",
|
||||||
entity_id=match_id_val,
|
entity_id=match_id_val,
|
||||||
payload={"group_id": group_id_val},
|
payload={"group_id": group_id_val},
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_removed(session, match_id_val, group_id_val) # type: ignore[arg-type]
|
notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type]
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from fastapi.responses import FileResponse
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user
|
from app.auth.deps import get_current_user
|
||||||
from app.core.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.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.schemas import api as s
|
from app.schemas import api as s
|
||||||
@@ -31,6 +32,9 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
|||||||
active_group_id=user.active_group_id,
|
active_group_id=user.active_group_id,
|
||||||
bio=user.bio,
|
bio=user.bio,
|
||||||
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||||
|
favorite_faction_id=user.favorite_faction_id,
|
||||||
|
history_mode=user.history_mode,
|
||||||
|
history_detail=user.history_detail,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,7 +62,7 @@ def update_me(
|
|||||||
entity_type="user",
|
entity_type="user",
|
||||||
entity_id=user.id,
|
entity_id=user.id,
|
||||||
payload={"nickname": user.nickname},
|
payload={"nickname": user.nickname},
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -71,6 +75,9 @@ def update_me(
|
|||||||
active_group_id=user.active_group_id,
|
active_group_id=user.active_group_id,
|
||||||
bio=user.bio,
|
bio=user.bio,
|
||||||
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||||
|
favorite_faction_id=user.favorite_faction_id,
|
||||||
|
history_mode=user.history_mode,
|
||||||
|
history_detail=user.history_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -81,15 +88,28 @@ def update_my_profile(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> s.MeRead:
|
) -> s.MeRead:
|
||||||
user_service.update_bio(session, user, body.bio)
|
# Обновляем только реально переданные поля: «О себе» и любимая фракция правятся
|
||||||
|
# по отдельности, и частичный запрос не должен обнулять соседнее поле.
|
||||||
|
changed = body.model_dump(exclude_unset=True)
|
||||||
|
if "bio" in changed:
|
||||||
|
user_service.update_bio(session, user, changed["bio"])
|
||||||
|
if "favorite_faction_id" in changed:
|
||||||
|
user_service.update_favorite_faction(session, user, changed["favorite_faction_id"])
|
||||||
|
if "history_mode" in changed or "history_detail" in changed:
|
||||||
|
user_service.update_history_prefs(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
mode=changed.get("history_mode"),
|
||||||
|
detail=changed.get("history_detail"),
|
||||||
|
)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=user.id,
|
actor_id=user.id,
|
||||||
action="update",
|
action="update",
|
||||||
entity_type="user",
|
entity_type="user",
|
||||||
entity_id=user.id,
|
entity_id=user.id,
|
||||||
payload={"bio": True},
|
payload={key: True for key in changed},
|
||||||
ip=request.client.host if request.client else None,
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return build_me(session, user)
|
return build_me(session, user)
|
||||||
@@ -101,12 +121,9 @@ def upload_my_avatar(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> s.MeRead:
|
) -> s.MeRead:
|
||||||
content = file.file.read(_AVATAR_MAX_BYTES + 1)
|
content, ext = user_service.read_capped_image(
|
||||||
if len(content) > _AVATAR_MAX_BYTES:
|
file, _AVATAR_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
|
||||||
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
|
)
|
||||||
ext = user_service.sniff_image_ext(content)
|
|
||||||
if ext is None:
|
|
||||||
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
|
||||||
user_service.set_avatar(session, user, content, ext)
|
user_service.set_avatar(session, user, content, ext)
|
||||||
return build_me(session, user)
|
return build_me(session, user)
|
||||||
|
|
||||||
@@ -147,6 +164,26 @@ def get_user_profile(
|
|||||||
return user_service.public_profile(session, user_id)
|
return user_service.public_profile(session, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/matches", response_model=s.MatchHistory)
|
||||||
|
def user_matches(
|
||||||
|
user_id: int,
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""История партий игрока. Режим — витрина владельца профиля: её видят и гости."""
|
||||||
|
owner = user_service.get_user(session, user_id)
|
||||||
|
data = stats_service.user_match_list(
|
||||||
|
session,
|
||||||
|
user_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
best_only=owner.history_mode == "best",
|
||||||
|
)
|
||||||
|
return {**data, "mode": owner.history_mode, "detail": owner.history_detail}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/stats", response_model=s.ProfileStats)
|
@router.get("/me/stats", response_model=s.ProfileStats)
|
||||||
def my_stats(
|
def my_stats(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ class UserRead(BaseModel):
|
|||||||
active_group_id: int | None = None
|
active_group_id: int | None = None
|
||||||
bio: str | None = None
|
bio: str | None = None
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
|
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
|
||||||
|
favorite_faction_id: int | None = None
|
||||||
|
# Витрина истории партий в профиле.
|
||||||
|
history_mode: str = "all"
|
||||||
|
history_detail: str = "compact"
|
||||||
|
|
||||||
|
|
||||||
class MeRead(UserRead):
|
class MeRead(UserRead):
|
||||||
@@ -101,7 +106,12 @@ class NicknameUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProfileUpdate(BaseModel):
|
class ProfileUpdate(BaseModel):
|
||||||
|
# Оба поля необязательны и обновляются, только если реально переданы
|
||||||
|
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
|
||||||
bio: str | None = None
|
bio: str | None = None
|
||||||
|
favorite_faction_id: int | None = None
|
||||||
|
history_mode: str | None = None
|
||||||
|
history_detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ActiveGroupUpdate(BaseModel):
|
class ActiveGroupUpdate(BaseModel):
|
||||||
@@ -266,6 +276,25 @@ class AttachmentRead(BaseModel):
|
|||||||
created_at: str
|
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):
|
class MatchRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
group_id: int
|
group_id: int
|
||||||
@@ -282,6 +311,8 @@ class MatchRead(BaseModel):
|
|||||||
version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно
|
version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно
|
||||||
participants: list[MatchParticipantRead] = []
|
participants: list[MatchParticipantRead] = []
|
||||||
attachments: list[AttachmentRead] = []
|
attachments: list[AttachmentRead] = []
|
||||||
|
# Общий черновик формы завершения (только у незавершённой партии).
|
||||||
|
finish_draft: MatchFinishDraftRead | None = None
|
||||||
|
|
||||||
|
|
||||||
# ─── Статистика ──────────────────────────────────────────────────────────────
|
# ─── Статистика ──────────────────────────────────────────────────────────────
|
||||||
@@ -301,6 +332,20 @@ class LeaderboardEntry(OverallStats):
|
|||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MatchHistory(BaseModel):
|
||||||
|
"""История партий игрока плюс настройки витрины его профиля.
|
||||||
|
|
||||||
|
Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так,
|
||||||
|
как выбрал её владелец, не делая второго запроса за профилем."""
|
||||||
|
|
||||||
|
items: list[MatchListItem] = []
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
mode: str
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
class Leaderboard(BaseModel):
|
class Leaderboard(BaseModel):
|
||||||
entries: list[LeaderboardEntry] = []
|
entries: list[LeaderboardEntry] = []
|
||||||
provisional: list[LeaderboardEntry] = []
|
provisional: list[LeaderboardEntry] = []
|
||||||
@@ -311,6 +356,8 @@ class FactionStat(BaseModel):
|
|||||||
faction_id: int
|
faction_id: int
|
||||||
code: str
|
code: str
|
||||||
name_ru: str
|
name_ru: str
|
||||||
|
# Название в предложном падеже — для строки «Чаще всего играет на …».
|
||||||
|
name_ru_prepositional: str
|
||||||
expansion_code: str
|
expansion_code: str
|
||||||
games: int
|
games: int
|
||||||
wins: int
|
wins: int
|
||||||
@@ -331,8 +378,9 @@ class ProfileStats(BaseModel):
|
|||||||
factions: list[FactionStat] = []
|
factions: list[FactionStat] = []
|
||||||
best_faction: FactionStat | None = None
|
best_faction: FactionStat | None = None
|
||||||
worst_faction: FactionStat | None = None
|
worst_faction: FactionStat | None = None
|
||||||
# Любимая — по партиям с ручным выбором фракции; основная — по всем (включая рандом).
|
# Любимая — личный выбор игрока в профиле (не статистика).
|
||||||
most_played_faction: FactionStat | None = None
|
favorite_faction: FactionRead | None = None
|
||||||
|
# «Чаще всего играет на» — самая игранная по всем партиям, включая рандомные раздачи.
|
||||||
main_faction: FactionStat | None = None
|
main_faction: FactionStat | None = None
|
||||||
recent_form: list[RecentFormItem] = []
|
recent_form: list[RecentFormItem] = []
|
||||||
# Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал
|
# Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ def _root() -> Path:
|
|||||||
return Path(settings.achievements_dir)
|
return Path(settings.achievements_dir)
|
||||||
|
|
||||||
|
|
||||||
|
# Формат slug — то, что выдаёт _slugify: только латиница, цифры и дефис.
|
||||||
|
_SLUG_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,63}")
|
||||||
|
|
||||||
|
|
||||||
def _slugify(name: str) -> str:
|
def _slugify(name: str) -> str:
|
||||||
text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower())
|
text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower())
|
||||||
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
||||||
@@ -49,6 +53,11 @@ def _slugify(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _dir(slug: str) -> Path:
|
def _dir(slug: str) -> Path:
|
||||||
|
"""Папка ачивки. Slug приходит из URL, поэтому формат проверяем здесь: без этого
|
||||||
|
`..` или `a/b` увели бы файловые операции (вплоть до rmtree в delete) за пределы
|
||||||
|
каталога ачивок."""
|
||||||
|
if not _SLUG_RE.fullmatch(slug or ""):
|
||||||
|
raise NotFoundError("Ачивка не найдена.")
|
||||||
return _root() / slug
|
return _root() / slug
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.errors import (
|
from app.core.errors import (
|
||||||
|
ConflictError,
|
||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
NicknameTakenError,
|
NicknameTakenError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
@@ -75,6 +76,10 @@ def delete_group(session: Session, group_id: int) -> None:
|
|||||||
group = session.get(Group, group_id)
|
group = session.get(Group, group_id)
|
||||||
if group is None:
|
if group is None:
|
||||||
raise NotFoundError("Группа не найдена.")
|
raise NotFoundError("Группа не найдена.")
|
||||||
|
# matches.group_id — ON DELETE RESTRICT, поэтому группу с партиями БД не отдаст.
|
||||||
|
# Проверяем сами, иначе IntegrityError уходит наружу голым 500 без AppError-конверта.
|
||||||
|
if session.exec(select(Match.id).where(Match.group_id == group_id)).first() is not None:
|
||||||
|
raise ConflictError("Нельзя удалить группу, в которой есть партии. Сначала удалите их.")
|
||||||
session.delete(group)
|
session.delete(group)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -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:
|
def delete_match(session: Session, match_id: int) -> None:
|
||||||
|
from app.services import attachment_service # избегаем цикла импорта
|
||||||
|
|
||||||
match = session.get(Match, match_id)
|
match = session.get(Match, match_id)
|
||||||
if match is None:
|
if match is None:
|
||||||
raise NotFoundError("Партия не найдена.")
|
raise NotFoundError("Партия не найдена.")
|
||||||
session.delete(match)
|
session.delete(match)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
# Как и в игроцком пути (match_service.delete_match): строки вложений уходят
|
||||||
|
# каскадом, а файлы с тома нужно убрать руками, иначе они остаются навсегда.
|
||||||
|
attachment_service.delete_match_files(match_id)
|
||||||
|
|
||||||
|
|
||||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.core.errors import ConflictError, NotFoundError
|
|||||||
from app.models import Match, MatchAttachment, User
|
from app.models import Match, MatchAttachment, User
|
||||||
|
|
||||||
MAX_ATTACHMENTS = 10
|
MAX_ATTACHMENTS = 10
|
||||||
|
MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 # 10 МБ
|
||||||
_SUBDIR = "matches"
|
_SUBDIR = "matches"
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +43,8 @@ def file_path(att: MatchAttachment) -> Path:
|
|||||||
def add_photo(
|
def add_photo(
|
||||||
session: Session, match: Match, user: User, content: bytes, ext: str, mime: str
|
session: Session, match: Match, user: User, content: bytes, ext: str, mime: str
|
||||||
) -> MatchAttachment:
|
) -> MatchAttachment:
|
||||||
|
from app.services import match_service # избегаем цикла импорта
|
||||||
|
|
||||||
if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type]
|
if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type]
|
||||||
raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.")
|
raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.")
|
||||||
att = MatchAttachment(
|
att = MatchAttachment(
|
||||||
@@ -60,6 +63,7 @@ def add_photo(
|
|||||||
abs_path.write_bytes(content)
|
abs_path.write_bytes(content)
|
||||||
att.storage_path = rel
|
att.storage_path = rel
|
||||||
session.add(att)
|
session.add(att)
|
||||||
|
match_service.touch(session, match)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(att)
|
session.refresh(att)
|
||||||
return 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:
|
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]
|
att = get_for_match(session, match.id, att_id) # type: ignore[arg-type]
|
||||||
path = file_path(att)
|
path = file_path(att)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
@@ -81,6 +87,7 @@ def delete(session: Session, match: Match, att_id: int) -> None:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
session.delete(att)
|
session.delete(att)
|
||||||
|
match_service.touch(session, match)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,3 +18,27 @@ def list_factions(session: Session) -> list[Faction]:
|
|||||||
select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
|
select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
|
||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Предложный падеж названий фракций для фразы «Чаще всего играет на …».
|
||||||
|
# Словарь в коде, а не колонка в БД: набор фракций задан игрой и не меняется.
|
||||||
|
# Фракция, добавленная админом мимо словаря, выводится в именительном падеже.
|
||||||
|
_PREPOSITIONAL: dict[str, str] = {
|
||||||
|
"orks": "Орках",
|
||||||
|
"ultramarines": "Ультрамаринах",
|
||||||
|
"eldar": "Эльдарах",
|
||||||
|
"chaos": "Хаоситах",
|
||||||
|
"astra_militarum": "Имперской гвардии",
|
||||||
|
"tau": "Тау",
|
||||||
|
"necrons": "Некронах",
|
||||||
|
"tyranids": "Тиранидах",
|
||||||
|
"inquisition": "Инквизиции",
|
||||||
|
"sisters_of_battle": "Сёстрах битвы",
|
||||||
|
"drukhari": "Друкхари",
|
||||||
|
"adeptus_mechanicus": "Адептус Механикус",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prepositional(code: str, name_ru: str) -> str:
|
||||||
|
"""Название фракции в предложном падеже; вне словаря — именительный как есть."""
|
||||||
|
return _PREPOSITIONAL.get(code, name_ru)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ GroupMember). Лимит участников — общий с membership_servi
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
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]:
|
def list_for_user(session: Session, user_id: int) -> list[dict]:
|
||||||
|
inviter = aliased(User)
|
||||||
rows = session.exec(
|
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)
|
.where(GroupInvitation.user_id == user_id)
|
||||||
.order_by(GroupInvitation.created_at.desc())
|
.order_by(GroupInvitation.created_at.desc())
|
||||||
).all()
|
).all()
|
||||||
out = []
|
out = []
|
||||||
for inv, gname in rows:
|
for inv, gname, inviter_nick 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
|
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"id": inv.id,
|
"id": inv.id,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import random
|
import random
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ from app.core.errors import (
|
|||||||
NotFoundError,
|
NotFoundError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
from app.core.timeutil import app_today, iso_utc
|
from app.core.timeutil import app_today, iso_utc, utcnow
|
||||||
from app.models import Faction, GroupMember, Match, MatchParticipant, User
|
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
|
||||||
from app.services import group_service
|
from app.services import group_service
|
||||||
|
|
||||||
MAX_MATCH_PLAYERS = 6
|
MAX_MATCH_PLAYERS = 6
|
||||||
@@ -56,10 +56,6 @@ class ParticipantInput:
|
|||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def round_to_30(minutes: float) -> int:
|
def round_to_30(minutes: float) -> int:
|
||||||
"""Округление длительности до получаса, минимум 30 минут."""
|
"""Округление длительности до получаса, минимум 30 минут."""
|
||||||
return max(30, int(round(minutes / 30.0)) * 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)
|
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:
|
def assert_version(match: Match, expected: str | None) -> None:
|
||||||
"""Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию)."""
|
"""Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию)."""
|
||||||
if expected is not None and expected != match_version(match):
|
if expected is not None and expected != match_version(match):
|
||||||
@@ -161,16 +168,28 @@ def _validate_roster_basics(
|
|||||||
group_id: int,
|
group_id: int,
|
||||||
user_ids: list[int],
|
user_ids: list[int],
|
||||||
faction_ids: list[int],
|
faction_ids: list[int],
|
||||||
|
*,
|
||||||
|
keep_user_ids: set[int] | None = None,
|
||||||
|
keep_faction_ids: set[int] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Состав партии: размер, отсутствие дублей, принадлежность группе.
|
||||||
|
|
||||||
|
keep_* — то, что уже записано в правимой партии: такие игроки и фракции проходят
|
||||||
|
независимо от текущего состава группы. Иначе отключённое дополнение или ушедший из
|
||||||
|
группы игрок делали бы старую партию неисправимой навсегда."""
|
||||||
if len(user_ids) < 2:
|
if len(user_ids) < 2:
|
||||||
raise ValidationError("В партии должно быть не менее 2 участников.")
|
raise ValidationError("В партии должно быть не менее 2 участников.")
|
||||||
if len(user_ids) > MAX_MATCH_PLAYERS:
|
if len(user_ids) > MAX_MATCH_PLAYERS:
|
||||||
raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.")
|
raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.")
|
||||||
if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids):
|
if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids):
|
||||||
raise DuplicateParticipantError()
|
raise DuplicateParticipantError()
|
||||||
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("Все участники должны состоять в группе.")
|
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()
|
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]
|
session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster]
|
||||||
)
|
)
|
||||||
|
|
||||||
now = _utcnow()
|
now = utcnow()
|
||||||
match = Match(
|
match = Match(
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
status="in_progress",
|
status="in_progress",
|
||||||
@@ -264,7 +283,7 @@ def finish_match(
|
|||||||
p.faction_id = f.faction_id
|
p.faction_id = f.faction_id
|
||||||
session.add(p)
|
session.add(p)
|
||||||
|
|
||||||
now = _utcnow()
|
now = utcnow()
|
||||||
match.finished_at = now
|
match.finished_at = now
|
||||||
started = match.started_at
|
started = match.started_at
|
||||||
if started is not None:
|
if started is not None:
|
||||||
@@ -279,11 +298,93 @@ def finish_match(
|
|||||||
match.overall_comment = overall_comment or None
|
match.overall_comment = overall_comment or None
|
||||||
|
|
||||||
session.add(match)
|
session.add(match)
|
||||||
|
# Содержимое черновика уже в результатах — второй источник правды не нужен.
|
||||||
|
clear_finish_draft(session, match.id) # type: ignore[arg-type]
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(match)
|
session.refresh(match)
|
||||||
return match
|
return match
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Черновик завершения (совместное заполнение формы) ────────────────────────
|
||||||
|
|
||||||
|
def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None:
|
||||||
|
return session.get(MatchFinishDraft, match_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _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:
|
def can_modify(session: Session, match: Match, user: User) -> bool:
|
||||||
@@ -311,7 +412,18 @@ def update_match(
|
|||||||
participants: list[ParticipantInput] | None = None,
|
participants: list[ParticipantInput] | None = None,
|
||||||
expected_version: str | None = None,
|
expected_version: str | None = None,
|
||||||
) -> Match:
|
) -> 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)
|
assert_version(match, expected_version)
|
||||||
if played_at is not None:
|
if played_at is not None:
|
||||||
match.played_at = played_at
|
match.played_at = played_at
|
||||||
@@ -323,18 +435,23 @@ def update_match(
|
|||||||
match.win_reason = win_reason
|
match.win_reason = win_reason
|
||||||
|
|
||||||
if participants is not None:
|
if participants is not None:
|
||||||
|
saved = session.exec(
|
||||||
|
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||||
|
).all()
|
||||||
|
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
||||||
|
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
||||||
_validate_roster_basics(
|
_validate_roster_basics(
|
||||||
session,
|
session,
|
||||||
match.group_id,
|
match.group_id,
|
||||||
[p.user_id for p in participants],
|
[p.user_id for p in participants],
|
||||||
[p.faction_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(
|
places = _resolve_finish_places(
|
||||||
[(p.user_id, p.place, p.eliminated) for p in participants]
|
[(p.user_id, p.place, p.eliminated) for p in participants]
|
||||||
)
|
)
|
||||||
for old in session.exec(
|
for old in saved:
|
||||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
|
||||||
).all():
|
|
||||||
session.delete(old)
|
session.delete(old)
|
||||||
session.flush()
|
session.flush()
|
||||||
for p in participants:
|
for p in participants:
|
||||||
@@ -351,7 +468,7 @@ def update_match(
|
|||||||
)
|
)
|
||||||
match.player_count = len(participants)
|
match.player_count = len(participants)
|
||||||
|
|
||||||
session.add(match)
|
touch(session, match)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(match)
|
session.refresh(match)
|
||||||
return match
|
return match
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlmodel import Session, select
|
|||||||
|
|
||||||
from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||||
from app.models import Group, GroupMember, User
|
from app.models import Group, GroupMember, User
|
||||||
|
from app.services import group_service
|
||||||
|
|
||||||
MAX_GROUP_SIZE = 10
|
MAX_GROUP_SIZE = 10
|
||||||
|
|
||||||
@@ -33,11 +34,7 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
|
|||||||
if member_count >= MAX_GROUP_SIZE:
|
if member_count >= MAX_GROUP_SIZE:
|
||||||
raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).")
|
raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).")
|
||||||
|
|
||||||
existing = session.exec(
|
existing = group_service.get_membership(session, group.id, user.id)
|
||||||
select(GroupMember).where(
|
|
||||||
GroupMember.group_id == group.id, GroupMember.user_id == user.id
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
raise ConflictError("Игрок уже в группе.")
|
raise ConflictError("Игрок уже в группе.")
|
||||||
|
|
||||||
@@ -48,22 +45,26 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
|
|||||||
return member, user
|
return member, user
|
||||||
|
|
||||||
|
|
||||||
def remove_member(session: Session, group: Group, user_id: int) -> None:
|
def _assert_not_last_owner(session: Session, group: Group, member: GroupMember, message: str) -> None:
|
||||||
member = session.exec(
|
"""Группа без владельца неисправима: назначить нового становится некому."""
|
||||||
select(GroupMember).where(
|
if member.role != "owner":
|
||||||
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
return
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if member is None:
|
|
||||||
raise NotFoundError("Игрок не состоит в группе.")
|
|
||||||
if member.role == "owner":
|
|
||||||
owners = session.exec(
|
owners = session.exec(
|
||||||
select(GroupMember).where(
|
select(GroupMember.id).where(
|
||||||
GroupMember.group_id == group.id, GroupMember.role == "owner"
|
GroupMember.group_id == group.id, GroupMember.role == "owner"
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
if len(owners) <= 1:
|
if len(owners) <= 1:
|
||||||
raise ForbiddenError("Нельзя удалить последнего владельца группы.")
|
raise ForbiddenError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_member(session: Session, group: Group, user_id: int) -> None:
|
||||||
|
member = group_service.get_membership(session, group.id, user_id)
|
||||||
|
if member is None:
|
||||||
|
raise NotFoundError("Игрок не состоит в группе.")
|
||||||
|
_assert_not_last_owner(
|
||||||
|
session, group, member, "Нельзя удалить последнего владельца группы."
|
||||||
|
)
|
||||||
|
|
||||||
# Сбросить активную группу у тех, для кого она была активной.
|
# Сбросить активную группу у тех, для кого она была активной.
|
||||||
user = session.get(User, user_id)
|
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:
|
def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember:
|
||||||
if role not in ("owner", "member"):
|
if role not in ("owner", "member"):
|
||||||
raise ValidationError("Недопустимая роль.")
|
raise ValidationError("Недопустимая роль.")
|
||||||
member = session.exec(
|
member = group_service.get_membership(session, group.id, user_id)
|
||||||
select(GroupMember).where(
|
|
||||||
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if member is None:
|
if member is None:
|
||||||
raise NotFoundError("Игрок не состоит в группе.")
|
raise NotFoundError("Игрок не состоит в группе.")
|
||||||
|
if role != "owner":
|
||||||
|
_assert_not_last_owner(
|
||||||
|
session, group, member, "Нельзя снять роль с последнего владельца группы."
|
||||||
|
)
|
||||||
member.role = role
|
member.role = role
|
||||||
session.add(member)
|
session.add(member)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ def mark_read(session: Session, user_id: int, ids: list[int] | None = None) -> i
|
|||||||
session.add(row)
|
session.add(row)
|
||||||
if rows:
|
if rows:
|
||||||
session.commit()
|
session.commit()
|
||||||
|
# Счётчик непрочитанных изменился — толкаем тот же сигнал, что и create_for,
|
||||||
|
# иначе вкладка на другом устройстве держит устаревший бейдж до перезагрузки.
|
||||||
|
notify.notifications_changed(user_id)
|
||||||
return len(rows)
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.events import hub
|
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]:
|
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:
|
def match_participant_ids(session: Session, match_id: int) -> list[int]:
|
||||||
"""Партия изменилась — уведомить всех участников её группы."""
|
"""Кто играл в партии. Нужен в событии, чтобы клиент понимал, чьи витрины
|
||||||
hub.publish(
|
(история игр, публичный профиль, личная статистика) реально протухли."""
|
||||||
_group_member_ids(session, match.group_id),
|
return list(
|
||||||
{"type": "match", "match_id": match.id, "group_id": match.group_id},
|
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(
|
hub.publish(
|
||||||
_group_member_ids(session, group_id),
|
_group_member_ids(session, group_id),
|
||||||
{"type": "match", "match_id": match_id, "group_id": group_id},
|
{
|
||||||
|
"type": "match",
|
||||||
|
"match_id": match_id,
|
||||||
|
"group_id": group_id,
|
||||||
|
"participant_ids": participant_ids or [],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ MATCH_POINTS_SQL = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def smoothed_score(points_sum: float, games: int) -> float | None:
|
||||||
|
"""Тот же сглаженный рейтинг, что и SMOOTHED_SCORE_SQL, но в Python.
|
||||||
|
|
||||||
|
Нужен там, где строки уже вытащены и агрегировать в SQL нечего (профиль игрока).
|
||||||
|
Держим рядом с SQL-версией и на одних константах: разъехавшиеся реализации одной
|
||||||
|
формулы — источник расхождений, который потом ловится только глазами."""
|
||||||
|
if games <= 0:
|
||||||
|
return None # рейтинга без игр нет — как SUM(...) = NULL в SQL
|
||||||
|
return (PRIOR_GAMES * PRIOR_MEAN + points_sum) / (PRIOR_GAMES + games) * 100
|
||||||
|
|
||||||
|
|
||||||
def leaderboard_sort_key(row: dict) -> tuple:
|
def leaderboard_sort_key(row: dict) -> tuple:
|
||||||
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
|
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,18 +3,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import func, text
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.timeutil import iso_utc
|
from app.core.timeutil import iso_utc
|
||||||
from app.models import Group, GroupMember, Match
|
from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User
|
||||||
from app.services import group_service, membership_service, user_service
|
from app.services import faction_service, group_service, membership_service, user_service
|
||||||
from app.services.scoring import (
|
from app.services.scoring import (
|
||||||
FACTION_MIN_GAMES,
|
FACTION_MIN_GAMES,
|
||||||
MATCH_POINTS_SQL,
|
MATCH_POINTS_SQL,
|
||||||
MIN_GAMES,
|
MIN_GAMES,
|
||||||
SMOOTHED_SCORE_SQL,
|
SMOOTHED_SCORE_SQL,
|
||||||
leaderboard_sort_key,
|
leaderboard_sort_key,
|
||||||
|
smoothed_score,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Базовый блок: одна строка на участие с tie-aware очками.
|
# Базовый блок: одна строка на участие с 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:
|
def _user_scored_rows(session: Session, user_id: int) -> list[dict]:
|
||||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
"""Строки участия игрока со всеми полями, нужными витринам профиля.
|
||||||
|
|
||||||
|
Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма
|
||||||
|
последних партий считаются из одного и того же набора строк. CTE джойнит участия
|
||||||
|
со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с
|
||||||
|
общим числом партий, а не с числом партий игрока."""
|
||||||
sql = f"""
|
sql = f"""
|
||||||
{SCORED_CTE}
|
{SCORED_CTE}
|
||||||
SELECT COUNT(*) AS games,
|
SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at,
|
||||||
SUM(s.is_win) AS wins,
|
s.place AS place, s.player_count AS player_count,
|
||||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
s.points AS points, s.is_win AS is_win,
|
||||||
AVG(s.place) AS avg_place,
|
f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||||
{SMOOTHED_SCORE_SQL} AS score
|
e.code AS expansion_code
|
||||||
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
|
|
||||||
FROM scored s
|
FROM scored s
|
||||||
JOIN factions f ON f.id = s.faction_id
|
JOIN factions f ON f.id = s.faction_id
|
||||||
JOIN expansions e ON e.id = f.expansion_id
|
JOIN expansions e ON e.id = f.expansion_id
|
||||||
{cond}
|
WHERE s.user_id = :uid
|
||||||
GROUP BY f.id, f.code, f.name_ru, e.code
|
|
||||||
ORDER BY games DESC, score DESC
|
|
||||||
"""
|
"""
|
||||||
params: dict[str, Any] = {"uid": user_id}
|
rows = session.execute(text(sql), {"uid": user_id}).mappings().all()
|
||||||
if group_id is not None:
|
return [dict(r) for r in rows]
|
||||||
params["gid"] = group_id
|
|
||||||
result = session.execute(text(sql), params).mappings().all()
|
|
||||||
|
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 = []
|
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(
|
out.append(
|
||||||
{
|
{
|
||||||
"faction_id": r["faction_id"],
|
"faction_id": fid,
|
||||||
"code": r["code"],
|
"code": meta["code"],
|
||||||
"name_ru": r["name_ru"],
|
"name_ru": meta["name_ru"],
|
||||||
"expansion_code": r["expansion_code"],
|
"expansion_code": meta["expansion_code"],
|
||||||
"games": int(r["games"] or 0),
|
"games": games,
|
||||||
"wins": int(r["wins"] or 0),
|
"wins": wins,
|
||||||
"win_rate": _round(r["win_rate"] or 0.0, 4),
|
"win_rate": _round(wins / games, 4),
|
||||||
"avg_place": _round(r["avg_place"], 2),
|
"avg_place": _round(sum(r["place"] for r in group) / games, 2),
|
||||||
"score": _round(r["score"], 1),
|
# Фракции: чистое среднее (служебная метрика «лучшая/худшая»,
|
||||||
# Служебное (в API-схему не входит): партии, где фракция выбрана
|
# сглаживание задавило бы её к 50).
|
||||||
# вручную, — для «любимой фракции» рандомные раздачи не в счёт.
|
"score": _round(sum(float(r["points"]) for r in group) / games * 100, 1),
|
||||||
"chosen_games": int(r["chosen_games"] or 0),
|
"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
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]:
|
def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]:
|
||||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True)
|
||||||
sql = f"""
|
|
||||||
{SCORED_CTE}
|
|
||||||
SELECT s.place AS place, s.player_count AS player_count, s.played_at AS played_at
|
|
||||||
FROM scored s
|
|
||||||
{cond}
|
|
||||||
ORDER BY s.played_at DESC, s.match_id DESC
|
|
||||||
LIMIT :lim
|
|
||||||
"""
|
|
||||||
params: dict[str, Any] = {"uid": user_id, "lim": limit}
|
|
||||||
if group_id is not None:
|
|
||||||
params["gid"] = group_id
|
|
||||||
result = session.execute(text(sql), params).mappings().all()
|
|
||||||
return [
|
return [
|
||||||
{"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])}
|
{
|
||||||
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:
|
def _favorite_faction(session: Session, user_id: int) -> dict | None:
|
||||||
overall = _overall_for_user(session, user_id, group_id)
|
"""Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики."""
|
||||||
factions = _faction_breakdown(session, user_id, group_id)
|
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]
|
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
|
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
|
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
|
main = max(factions, key=lambda f: f["games"]) if factions else None
|
||||||
return {
|
return {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -214,21 +231,22 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
|
|||||||
"factions": factions,
|
"factions": factions,
|
||||||
"best_faction": best,
|
"best_faction": best,
|
||||||
"worst_faction": worst,
|
"worst_faction": worst,
|
||||||
"most_played_faction": most_played,
|
"favorite_faction": _favorite_faction(session, user_id),
|
||||||
"main_faction": main,
|
"main_faction": main,
|
||||||
"recent_form": _recent_form(session, user_id, group_id),
|
"recent_form": _recent_form_from_rows(scoped),
|
||||||
"min_games": MIN_GAMES,
|
"min_games": MIN_GAMES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def group_stats(session: Session, group_id: int) -> dict:
|
def group_stats(session: Session, group_id: int) -> dict:
|
||||||
board = leaderboard(session, group_id=group_id)
|
board = leaderboard(session, group_id=group_id)
|
||||||
total_matches = session.exec(
|
# Нужны только счётчик и дата последней партии — тянуть строки целиком незачем.
|
||||||
select(Match).where(Match.group_id == group_id, Match.status == "finished")
|
games_count, last_played = session.exec(
|
||||||
).all()
|
select(func.count(), func.max(Match.played_at)).where(
|
||||||
last_at = None
|
Match.group_id == group_id, Match.status == "finished"
|
||||||
if total_matches:
|
)
|
||||||
last_at = str(max(m.played_at for m in total_matches))
|
).one()
|
||||||
|
last_at = str(last_played) if last_played else None
|
||||||
|
|
||||||
available_ids = group_service.available_faction_ids(session, group_id)
|
available_ids = group_service.available_faction_ids(session, group_id)
|
||||||
faction_meta = []
|
faction_meta = []
|
||||||
@@ -278,7 +296,7 @@ def group_stats(session: Session, group_id: int) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"group_id": group_id,
|
"group_id": group_id,
|
||||||
"total_matches": len(total_matches),
|
"total_matches": games_count,
|
||||||
"last_match_at": last_at,
|
"last_match_at": last_at,
|
||||||
"leaderboard": board["entries"],
|
"leaderboard": board["entries"],
|
||||||
"provisional": board["provisional"],
|
"provisional": board["provisional"],
|
||||||
@@ -288,24 +306,8 @@ 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:
|
def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
|
||||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
return {
|
||||||
|
|
||||||
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
|
|
||||||
matches = session.exec(
|
|
||||||
select(Match)
|
|
||||||
.where(Match.group_id == group_id)
|
|
||||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for m in matches:
|
|
||||||
parts = []
|
|
||||||
for p, u, f in participants_detail(session, m.id): # type: ignore[arg-type]
|
|
||||||
parts.append(
|
|
||||||
{
|
|
||||||
"user_id": u.id,
|
"user_id": u.id,
|
||||||
"nickname": u.nickname,
|
"nickname": u.nickname,
|
||||||
"faction_id": f.id,
|
"faction_id": f.id,
|
||||||
@@ -315,7 +317,33 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
|
|||||||
"was_random": p.was_random,
|
"was_random": p.was_random,
|
||||||
"comment": p.comment,
|
"comment": p.comment,
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
|
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 = by_match.get(m.id, [])
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
"id": m.id,
|
"id": m.id,
|
||||||
@@ -331,13 +359,82 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
|
|||||||
"participants": parts,
|
"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]:
|
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
||||||
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
|
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
|
||||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
|
||||||
|
|
||||||
group_ids = list(
|
group_ids = list(
|
||||||
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
|
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
|
||||||
)
|
)
|
||||||
@@ -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))
|
.where(Match.status == "in_progress", Match.group_id.in_(group_ids))
|
||||||
.order_by(Match.started_at.desc(), Match.id.desc())
|
.order_by(Match.started_at.desc(), Match.id.desc())
|
||||||
).all()
|
).all()
|
||||||
|
by_match = _participants_by_match(session, [m.id for m, _ in rows])
|
||||||
out = []
|
out = []
|
||||||
for m, gname in rows:
|
for m, gname in rows:
|
||||||
parts = [
|
parts = by_match.get(m.id, [])
|
||||||
{
|
|
||||||
"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]
|
|
||||||
]
|
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"id": m.id,
|
"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:
|
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
|
||||||
board = leaderboard(session, group_id=None)
|
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
|
active_group_brief = None
|
||||||
if active_group_id is not None:
|
if active_group_id is not None:
|
||||||
group = session.get(Group, active_group_id)
|
group = session.get(Group, active_group_id)
|
||||||
@@ -387,7 +475,7 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
|
|||||||
active_group_brief = {
|
active_group_brief = {
|
||||||
"id": group.id,
|
"id": group.id,
|
||||||
"name": group.name,
|
"name": group.name,
|
||||||
**_overall_for_user(session, user_id, active_group_id),
|
**_overall_from_rows(_for_group(rows, active_group_id)),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"leaderboard": board["entries"][:leaderboard_limit],
|
"leaderboard": board["entries"][:leaderboard_limit],
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Пользователи: создание из внешней личности, ник, активная группа, профиль."""
|
"""Пользователи: создание из внешней личности, ник, активная группа, профиль."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -11,7 +10,8 @@ from sqlmodel import Session, select
|
|||||||
from app.auth.provider import ExternalIdentity
|
from app.auth.provider import ExternalIdentity
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
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)
|
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
|
||||||
_BIO_MAX = 500
|
_BIO_MAX = 500
|
||||||
@@ -134,7 +134,7 @@ def register_from_identity(
|
|||||||
|
|
||||||
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
||||||
new_nickname = (new_nickname or "").strip()
|
new_nickname = (new_nickname or "").strip()
|
||||||
if not _NICK_RE.match(new_nickname):
|
if not nickname_format_ok(new_nickname):
|
||||||
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
||||||
if not nickname_available(session, new_nickname, exclude_user_id=user.id):
|
if not nickname_available(session, new_nickname, exclude_user_id=user.id):
|
||||||
raise NicknameTakenError()
|
raise NicknameTakenError()
|
||||||
@@ -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:
|
def set_active_group(session: Session, user: User, group_id: int | None) -> User:
|
||||||
if group_id is not None:
|
if group_id is not None:
|
||||||
member = session.exec(
|
from app.services import group_service # избегаем цикла импорта
|
||||||
select(GroupMember).where(
|
|
||||||
GroupMember.group_id == group_id, GroupMember.user_id == user.id
|
if group_service.get_membership(session, group_id, user.id) is None:
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if member is None:
|
|
||||||
raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.")
|
raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.")
|
||||||
user.active_group_id = group_id
|
user.active_group_id = group_id
|
||||||
session.add(user)
|
session.add(user)
|
||||||
@@ -170,7 +167,13 @@ def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime |
|
|||||||
подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
|
подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
|
||||||
if not avatar_path:
|
if not avatar_path:
|
||||||
return None
|
return None
|
||||||
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}"
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
_HISTORY_MODES = {"all", "best"}
|
||||||
|
_HISTORY_DETAILS = {"compact", "full"}
|
||||||
|
|
||||||
|
|
||||||
|
def update_history_prefs(
|
||||||
|
session: Session, user: User, *, mode: str | None = None, detail: str | None = None
|
||||||
|
) -> User:
|
||||||
|
"""Витрина истории партий: что показывать и насколько подробно. None — не менять."""
|
||||||
|
if mode is not None:
|
||||||
|
if mode not in _HISTORY_MODES:
|
||||||
|
raise ValidationError("Неизвестный режим истории партий.")
|
||||||
|
user.history_mode = mode
|
||||||
|
if detail is not None:
|
||||||
|
if detail not in _HISTORY_DETAILS:
|
||||||
|
raise ValidationError("Неизвестная подробность истории партий.")
|
||||||
|
user.history_detail = detail
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User:
|
||||||
|
"""Любимая фракция — личный выбор игрока; None очищает выбор."""
|
||||||
|
if faction_id is not None and session.get(Faction, faction_id) is None:
|
||||||
|
raise ValidationError("Такой фракции не существует.")
|
||||||
|
user.favorite_faction_id = faction_id
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def read_capped_image(file, max_bytes: int, limit_message: str) -> tuple[bytes, str]:
|
||||||
|
"""Прочитать загруженный файл с ограничением размера и убедиться, что это картинка.
|
||||||
|
|
||||||
|
Читаем на байт больше лимита: так превышение видно, не загружая файл целиком.
|
||||||
|
Один хелпер на все загрузки (аватар, фото партии, иконка ачивки) — иначе
|
||||||
|
правка лимита или списка форматов расходится по четырём роутерам."""
|
||||||
|
content = file.file.read(max_bytes + 1)
|
||||||
|
if len(content) > max_bytes:
|
||||||
|
raise ValidationError(limit_message)
|
||||||
|
ext = sniff_image_ext(content)
|
||||||
|
if ext is None:
|
||||||
|
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
|
||||||
|
return content, ext
|
||||||
|
|
||||||
|
|
||||||
def sniff_image_ext(content: bytes) -> str | None:
|
def sniff_image_ext(content: bytes) -> str | None:
|
||||||
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
|
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
|
||||||
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
@@ -220,6 +271,10 @@ def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
|
|||||||
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
|
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
|
||||||
(Path(settings.upload_dir) / rel).write_bytes(content)
|
(Path(settings.upload_dir) / rel).write_bytes(content)
|
||||||
user.avatar_path = rel
|
user.avatar_path = rel
|
||||||
|
# Файл перезаписывается по тому же пути, поэтому при том же расширении avatar_path
|
||||||
|
# не меняется, UPDATE не эмитится и onupdate не срабатывает. Без явного бампа
|
||||||
|
# кэш-бастер остаётся прежним, и браузер час показывает старую картинку.
|
||||||
|
user.updated_at = utcnow()
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
@@ -234,6 +289,7 @@ def clear_avatar(session: Session, user: User) -> User:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
user.avatar_path = None
|
user.avatar_path = None
|
||||||
|
user.updated_at = utcnow()
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
|
|||||||
@@ -10,5 +10,7 @@ python -m app.bootstrap
|
|||||||
echo "[entrypoint] Запуск сервера…"
|
echo "[entrypoint] Запуск сервера…"
|
||||||
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS):
|
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS):
|
||||||
# чтобы приложение знало, что снаружи запрос пришёл по HTTPS.
|
# чтобы приложение знало, что снаружи запрос пришёл по 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 \
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \
|
||||||
--proxy-headers --forwarded-allow-ips="*"
|
--proxy-headers --forwarded-allow-ips="*" --timeout-graceful-shutdown 10
|
||||||
|
|||||||
@@ -111,3 +111,24 @@ def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path
|
|||||||
f"/api/admin/achievements/{slug}", headers=csrf_headers(client)
|
f"/api/admin/achievements/{slug}", headers=csrf_headers(client)
|
||||||
).status_code == 200
|
).status_code == 200
|
||||||
assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json())
|
assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_rejects_traversal_slug(client: TestClient, make_admin, monkeypatch, tmp_path):
|
||||||
|
"""Slug из URL не должен уводить файловые операции за каталог ачивок.
|
||||||
|
|
||||||
|
Регрессия: `DELETE /api/admin/achievements/%2E%2E` снимал rmtree'ом родительскую
|
||||||
|
папку каталога (в проде это /data — БД, uploads и ачивки разом)."""
|
||||||
|
root = _use_tmp_achievements(monkeypatch, tmp_path / "achievements")
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
sibling = tmp_path / "db.sqlite3"
|
||||||
|
sibling.write_bytes(b"data")
|
||||||
|
_admin(client, make_admin)
|
||||||
|
|
||||||
|
# Именно percent-кодированная форма: обычные точки httpx нормализует ещё до
|
||||||
|
# отправки, запрос уходит на /api/admin/ и до обработчика вовсе не доходит.
|
||||||
|
r = client.request(
|
||||||
|
"DELETE", "/api/admin/achievements/%2E%2E", headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
assert r.json()["error"]["code"] == "NOT_FOUND" # ответ обработчика, а не промах роутинга
|
||||||
|
assert sibling.exists() and root.is_dir()
|
||||||
|
|||||||
@@ -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()
|
detail = client.get(f"/api/admin/matches/{mid}").json()
|
||||||
ap = next(p for p in detail["participants"] if p["user_id"] == me["id"])
|
ap = next(p for p in detail["participants"] if p["user_id"] == me["id"])
|
||||||
assert ap["faction_name"] == "Орки WAAAGH"
|
assert ap["faction_name"] == "Орки WAAAGH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
|
||||||
|
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
|
||||||
|
|
||||||
|
Регрессия: matches.group_id — ON DELETE RESTRICT, и голый session.delete ронял
|
||||||
|
IntegrityError наружу пятисоткой без конверта ошибки."""
|
||||||
|
me = login(client, "Owner")
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
p2 = add_group_member(engine, gid, "Игрок2")
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
create_finished_match(
|
||||||
|
client, gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": p2, "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
_admin_login(client, make_admin)
|
||||||
|
r = client.delete(f"/api/admin/groups/{gid}", headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 409, r.text
|
||||||
|
# Важен не только код ответа: группа и её партии должны пережить отказ.
|
||||||
|
assert any(g["id"] == gid for g in client.get("/api/admin/groups").json())
|
||||||
|
assert any(m["group_id"] == gid for m in client.get("/api/admin/matches").json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_owner_cannot_demote_self(client: TestClient, engine):
|
||||||
|
"""Единственный владелец не может разжаловать сам себя.
|
||||||
|
|
||||||
|
Регрессия: change_role не проверял последнего владельца (в отличие от удаления),
|
||||||
|
и группа оставалась без владельца навсегда — назначить нового было некому."""
|
||||||
|
me = login(client, "Owner")
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
add_group_member(engine, gid, "Игрок2")
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/groups/{gid}/members/{me['id']}",
|
||||||
|
json={"role": "member"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
members = client.get(f"/api/groups/{gid}/members").json()
|
||||||
|
assert any(m["user_id"] == me["id"] and m["role"] == "owner" for m in members)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ownership_transfer_still_works(client: TestClient, engine):
|
||||||
|
"""Обратная сторона защиты последнего владельца: передать роль по-прежнему можно."""
|
||||||
|
me = login(client, "Owner")
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
p2 = add_group_member(engine, gid, "Игрок2")
|
||||||
|
|
||||||
|
promote = client.patch(
|
||||||
|
f"/api/groups/{gid}/members/{p2}", json={"role": "owner"}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert promote.status_code == 200, promote.text
|
||||||
|
|
||||||
|
# Владельцев теперь двое — прежний может сложить полномочия.
|
||||||
|
demote = client.patch(
|
||||||
|
f"/api/groups/{gid}/members/{me['id']}",
|
||||||
|
json={"role": "member"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert demote.status_code == 200, demote.text
|
||||||
|
members = client.get(f"/api/groups/{gid}/members").json()
|
||||||
|
assert [m["user_id"] for m in members if m["role"] == "owner"] == [p2]
|
||||||
|
|||||||
@@ -138,3 +138,23 @@ def test_non_member_cannot_view(client: TestClient, engine, monkeypatch, tmp_pat
|
|||||||
login(client, "Чужак") # не состоит в группе
|
login(client, "Чужак") # не состоит в группе
|
||||||
g = client.get(f"/api/matches/{mid}/attachments/{aid}")
|
g = client.get(f"/api/matches/{mid}/attachments/{aid}")
|
||||||
assert g.status_code in (401, 403)
|
assert g.status_code in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_upload_moves_version(client: TestClient, engine, monkeypatch, tmp_path):
|
||||||
|
"""Вложения видны в MatchRead, но строку matches не трогают.
|
||||||
|
|
||||||
|
Регрессия: из-за этого версия партии не двигалась, и правка со старой версией
|
||||||
|
проходила мимо оптимистичной блокировки."""
|
||||||
|
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||||
|
me, gid, p2, mid = _start(client, engine)
|
||||||
|
v1 = client.get(f"/api/matches/{mid}").json()["version"]
|
||||||
|
|
||||||
|
assert _upload(client, mid).status_code == 200
|
||||||
|
v2 = client.get(f"/api/matches/{mid}").json()["version"]
|
||||||
|
assert v2 != v1
|
||||||
|
|
||||||
|
stale = client.delete(
|
||||||
|
f"/api/matches/{mid}", params={"expected_version": v1}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert stale.status_code == 409, stale.text
|
||||||
|
assert stale.json()["error"]["code"] == "STALE_WRITE"
|
||||||
|
|||||||
@@ -75,3 +75,39 @@ def test_stale_finish_rejected(client: TestClient, engine):
|
|||||||
headers=csrf_headers(client),
|
headers=csrf_headers(client),
|
||||||
)
|
)
|
||||||
assert r.status_code == 409 and r.json()["error"]["code"] == "STALE_WRITE", r.text
|
assert r.status_code == 409 and r.json()["error"]["code"] == "STALE_WRITE", r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_participant_edit_moves_version(client: TestClient, engine):
|
||||||
|
"""Правка одних участников тоже двигает версию партии.
|
||||||
|
|
||||||
|
Регрессия: updated_at менялся только при UPDATE строки matches, поэтому после
|
||||||
|
правки участников версия оставалась прежней и вторая правка со старой версией
|
||||||
|
проходила вместо 409 — ровно то, от чего защищает блокировка."""
|
||||||
|
me, p2, mid = _start(client, engine)
|
||||||
|
fin = finish_match(
|
||||||
|
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}],
|
||||||
|
win_reason="objectives",
|
||||||
|
)
|
||||||
|
assert fin.status_code == 200, fin.text
|
||||||
|
v1 = client.get(f"/api/matches/{mid}").json()["version"]
|
||||||
|
|
||||||
|
parts = {p["user_id"]: p["faction_id"] for p in client.get(f"/api/matches/{mid}").json()["participants"]}
|
||||||
|
swap = [
|
||||||
|
{"user_id": me["id"], "faction_id": parts[me["id"]], "place": 2},
|
||||||
|
{"user_id": p2, "faction_id": parts[p2], "place": 1},
|
||||||
|
]
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/matches/{mid}",
|
||||||
|
json={"participants": swap, "expected_version": v1},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert client.get(f"/api/matches/{mid}").json()["version"] != v1
|
||||||
|
|
||||||
|
stale = client.patch(
|
||||||
|
f"/api/matches/{mid}",
|
||||||
|
json={"participants": swap, "expected_version": v1},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert stale.status_code == 409, stale.text
|
||||||
|
assert stale.json()["error"]["code"] == "STALE_WRITE"
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Событие партии несёт список участников: по нему клиент решает, чьи витрины протухли."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]:
|
||||||
|
from app.core import events
|
||||||
|
|
||||||
|
published: list[tuple[list[int], dict]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
events.hub, "publish", lambda ids, event: published.append((list(ids), event))
|
||||||
|
)
|
||||||
|
return published
|
||||||
|
|
||||||
|
|
||||||
|
def _match_events(published: list[tuple[list[int], dict]]) -> list[dict]:
|
||||||
|
return [e for _ids, e in published if e.get("type") == "match"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_event_carries_participants(client: TestClient, engine, monkeypatch):
|
||||||
|
me = login(client, "Хост")
|
||||||
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
p2 = add_group_member(engine, gid, "Игрок2")
|
||||||
|
# Третий в группе, но НЕ в партии: его история от этой партии не меняется.
|
||||||
|
p3 = add_group_member(engine, gid, "Зритель")
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
|
||||||
|
published = _capture_events(monkeypatch)
|
||||||
|
started = start_match(
|
||||||
|
client, gid,
|
||||||
|
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||||
|
)
|
||||||
|
assert started.status_code == 200, started.text
|
||||||
|
mid = started.json()["id"]
|
||||||
|
|
||||||
|
ev = _match_events(published)[-1]
|
||||||
|
assert sorted(ev["participant_ids"]) == sorted([me["id"], p2])
|
||||||
|
assert p3 not in ev["participant_ids"]
|
||||||
|
|
||||||
|
published.clear()
|
||||||
|
fin = finish_match(
|
||||||
|
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
||||||
|
)
|
||||||
|
assert fin.status_code == 200, fin.text
|
||||||
|
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_event_carries_participants(client: TestClient, engine, monkeypatch):
|
||||||
|
"""Удаление — главный случай: строки участников уже уничтожены каскадом.
|
||||||
|
|
||||||
|
Если собирать их после удаления, список всегда окажется пустым, и клиент не
|
||||||
|
обновит историю тем, кто в этой партии играл."""
|
||||||
|
me = login(client, "Хост")
|
||||||
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
p2 = add_group_member(engine, gid, "Игрок2")
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
mid = start_match(
|
||||||
|
client, gid,
|
||||||
|
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
published = _capture_events(monkeypatch)
|
||||||
|
version = client.get(f"/api/matches/{mid}").json()["version"]
|
||||||
|
r = client.delete(
|
||||||
|
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Черновик формы завершения: совместное заполнение результатов партии.
|
||||||
|
|
||||||
|
Плюс запрет правки незавершённой партии (места без завершения — «результат есть,
|
||||||
|
а игры как бы не было»)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||||
|
|
||||||
|
|
||||||
|
def _start(client: TestClient, engine) -> tuple[dict, int, int, int]:
|
||||||
|
me = login(client, "Хост")
|
||||||
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
p2 = add_group_member(engine, gid, "Игрок2")
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
started = start_match(
|
||||||
|
client, gid,
|
||||||
|
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||||
|
)
|
||||||
|
assert started.status_code == 200, started.text
|
||||||
|
return me, gid, p2, started.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _draft(client: TestClient, mid: int, body: dict):
|
||||||
|
return client.put(f"/api/matches/{mid}/finish-draft", json=body, headers=csrf_headers(client))
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_is_shared_between_participants(client: TestClient, engine):
|
||||||
|
"""Второй участник видит раскладку первого, не перезагружая страницу."""
|
||||||
|
me, gid, p2, mid = _start(client, engine)
|
||||||
|
|
||||||
|
r = _draft(client, mid, {
|
||||||
|
"blocks": [[me["id"]], [p2]],
|
||||||
|
"eliminated": [],
|
||||||
|
"comments": {str(p2): "почти успел"},
|
||||||
|
"win_reason": "worlds",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["finish_draft"]["data"]["blocks"] == [[me["id"]], [p2]]
|
||||||
|
|
||||||
|
login(client, "Игрок2")
|
||||||
|
seen = client.get(f"/api/matches/{mid}").json()["finish_draft"]
|
||||||
|
assert seen["data"]["blocks"] == [[me["id"]], [p2]]
|
||||||
|
assert seen["data"]["comments"][str(p2)] == "почти успел"
|
||||||
|
assert seen["data"]["win_reason"] == "worlds"
|
||||||
|
assert seen["updated_by_nickname"] == "Хост"
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_does_not_move_match_version(client: TestClient, engine):
|
||||||
|
"""Черновик не трогает версию партии.
|
||||||
|
|
||||||
|
Иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую
|
||||||
|
правку — ровно то, ради чего черновик и делался."""
|
||||||
|
me, gid, p2, mid = _start(client, engine)
|
||||||
|
v1 = client.get(f"/api/matches/{mid}").json()["version"]
|
||||||
|
|
||||||
|
assert _draft(client, mid, {"blocks": [[p2], [me["id"]]]}).status_code == 200
|
||||||
|
assert client.get(f"/api/matches/{mid}").json()["version"] == v1
|
||||||
|
|
||||||
|
# И завершение со «старой» (на деле актуальной) версией проходит.
|
||||||
|
fin = finish_match(
|
||||||
|
client, mid, [{"user_id": p2, "place": 1}, {"user_id": me["id"], "place": 2}],
|
||||||
|
)
|
||||||
|
assert fin.status_code == 200, fin.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_cleared_after_finish(client: TestClient, engine):
|
||||||
|
me, gid, p2, mid = _start(client, engine)
|
||||||
|
assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 200
|
||||||
|
|
||||||
|
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
|
||||||
|
assert client.get(f"/api/matches/{mid}").json()["finish_draft"] is None
|
||||||
|
|
||||||
|
# В завершённую партию черновик не пишется.
|
||||||
|
assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_rejects_outsider_and_foreign_players(client: TestClient, engine):
|
||||||
|
me, gid, p2, mid = _start(client, engine)
|
||||||
|
stranger = login(client, "Чужак") # в группе не состоит
|
||||||
|
assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 403
|
||||||
|
|
||||||
|
login(client, "Хост")
|
||||||
|
bad = _draft(client, mid, {"blocks": [[me["id"]], [stranger["id"]]]})
|
||||||
|
assert bad.status_code == 422, bad.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_progress_match_cannot_be_patched(client: TestClient, engine):
|
||||||
|
"""Места и причина победы у идущей партии — только через завершение.
|
||||||
|
|
||||||
|
Иначе партия остаётся in_progress с проставленными местами: висит в
|
||||||
|
«Незавершённых», но в статистику не попадает и очков не приносит."""
|
||||||
|
me, gid, p2, mid = _start(client, engine)
|
||||||
|
parts = client.get(f"/api/matches/{mid}").json()["participants"]
|
||||||
|
body = {
|
||||||
|
"participants": [
|
||||||
|
{"user_id": p["user_id"], "faction_id": p["faction_id"], "place": i + 1}
|
||||||
|
for i, p in enumerate(parts)
|
||||||
|
],
|
||||||
|
"win_reason": "objectives",
|
||||||
|
}
|
||||||
|
r = client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 409, r.text
|
||||||
|
assert client.get(f"/api/matches/{mid}").json()["status"] == "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_patch_in_progress_match(client: TestClient, engine, make_admin):
|
||||||
|
me, gid, p2, mid = _start(client, engine)
|
||||||
|
parts = client.get(f"/api/matches/{mid}").json()["participants"]
|
||||||
|
|
||||||
|
make_admin("admin", "secret123")
|
||||||
|
assert client.post(
|
||||||
|
"/api/admin/auth/login",
|
||||||
|
json={"username": "admin", "password": "secret123"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
).status_code == 200
|
||||||
|
body = {
|
||||||
|
"participants": [
|
||||||
|
{"user_id": p["user_id"], "faction_id": p["faction_id"], "place": i + 1}
|
||||||
|
for i, p in enumerate(parts)
|
||||||
|
],
|
||||||
|
"win_reason": "objectives",
|
||||||
|
}
|
||||||
|
r = client.patch(f"/api/admin/matches/{mid}", json=body, headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 409, r.text
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Правка завершённой партии игроком: история чинится после изменений в группе."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||||
|
|
||||||
|
|
||||||
|
def _exp_id(client: TestClient, code: str) -> int:
|
||||||
|
return next(e["id"] for e in client.get("/api/expansions").json() if e["code"] == code)
|
||||||
|
|
||||||
|
|
||||||
|
def _played(client: TestClient, engine) -> tuple[dict, int, int, int, dict]:
|
||||||
|
"""Партия «Аня против Бори» в группе с обоими дополнениями."""
|
||||||
|
me = login(client, "Аня")
|
||||||
|
fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids")
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": [fw, fv]},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
).json()["id"]
|
||||||
|
b = add_group_member(engine, gid, "Боря")
|
||||||
|
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||||
|
mid = create_finished_match(
|
||||||
|
client, gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
|
||||||
|
{"user_id": b, "faction_id": factions["tau"], "place": 2},
|
||||||
|
],
|
||||||
|
)["id"]
|
||||||
|
return me, gid, b, mid, factions
|
||||||
|
|
||||||
|
|
||||||
|
def _swap_places(client: TestClient, mid: int, me_id: int, b: int, factions: dict) -> dict:
|
||||||
|
detail = client.get(f"/api/matches/{mid}").json()
|
||||||
|
fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]}
|
||||||
|
return {
|
||||||
|
"participants": [
|
||||||
|
{"user_id": me_id, "faction_id": fid[me_id], "place": 2},
|
||||||
|
{"user_id": b, "faction_id": fid[b], "place": 1},
|
||||||
|
],
|
||||||
|
"expected_version": detail["version"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_after_expansion_disabled(client: TestClient, engine):
|
||||||
|
"""Дополнение выключили — партия на Тау всё равно правится.
|
||||||
|
|
||||||
|
Регрессия: правка проверяла фракции по ТЕКУЩЕМУ набору группы, и партия,
|
||||||
|
сыгранная на фракции из отключённого дополнения, становилась неисправимой."""
|
||||||
|
me, gid, b, mid, factions = _played(client, engine)
|
||||||
|
|
||||||
|
off = client.put(
|
||||||
|
f"/api/groups/{gid}/expansions",
|
||||||
|
json={"expansion_ids": [_exp_id(client, "forsaken_voids")]},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert off.status_code == 200, off.text
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions),
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
places = {p["user_id"]: p["place"] for p in client.get(f"/api/matches/{mid}").json()["participants"]}
|
||||||
|
assert places[b] == 1 and places[me["id"]] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_after_player_left_group(client: TestClient, engine):
|
||||||
|
"""Игрока убрали из группы — партия с ним всё равно правится."""
|
||||||
|
me, gid, b, mid, factions = _played(client, engine)
|
||||||
|
|
||||||
|
out = client.delete(f"/api/groups/{gid}/members/{b}", headers=csrf_headers(client))
|
||||||
|
assert out.status_code == 200, out.text
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions),
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_rejects_new_outsider_and_unavailable_faction(client: TestClient, engine):
|
||||||
|
"""Послабление — только для того, что уже в партии.
|
||||||
|
|
||||||
|
Вписать нового игрока не из группы или фракцию, которой в партии не было и у
|
||||||
|
группы нет, по-прежнему нельзя: иначе в историю можно занести что угодно."""
|
||||||
|
me, gid, b, mid, factions = _played(client, engine)
|
||||||
|
stranger = client.post(
|
||||||
|
"/api/auth/dev/users", json={"nickname": "Чужак"}, headers=csrf_headers(client)
|
||||||
|
).json()
|
||||||
|
detail = client.get(f"/api/matches/{mid}").json()
|
||||||
|
fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]}
|
||||||
|
|
||||||
|
bad_user = client.patch(
|
||||||
|
f"/api/matches/{mid}",
|
||||||
|
json={
|
||||||
|
"participants": [
|
||||||
|
{"user_id": me["id"], "faction_id": fid[me["id"]], "place": 1},
|
||||||
|
{"user_id": stranger["id"], "faction_id": fid[b], "place": 2},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert bad_user.status_code == 422, bad_user.text
|
||||||
|
|
||||||
|
# Выключаем дополнение и пробуем поставить ЕГО фракцию, которой в партии не было.
|
||||||
|
assert client.put(
|
||||||
|
f"/api/groups/{gid}/expansions",
|
||||||
|
json={"expansion_ids": [_exp_id(client, "forsaken_voids")]},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
).status_code == 200
|
||||||
|
bad_faction = client.patch(
|
||||||
|
f"/api/matches/{mid}",
|
||||||
|
json={
|
||||||
|
"participants": [
|
||||||
|
{"user_id": me["id"], "faction_id": factions["necrons"], "place": 1},
|
||||||
|
{"user_id": b, "faction_id": fid[b], "place": 2},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert bad_faction.status_code == 422, bad_faction.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_match_still_validated(client: TestClient, engine):
|
||||||
|
"""Создание партии не ослабло: посторонний игрок по-прежнему отклоняется."""
|
||||||
|
me, gid, b, mid, factions = _played(client, engine)
|
||||||
|
stranger = client.post(
|
||||||
|
"/api/auth/dev/users", json={"nickname": "Чужак2"}, headers=csrf_headers(client)
|
||||||
|
).json()
|
||||||
|
r = client.post(
|
||||||
|
"/api/matches",
|
||||||
|
json={
|
||||||
|
"group_id": gid,
|
||||||
|
"participants": [
|
||||||
|
{"user_id": me["id"], "faction_id": factions["orks"]},
|
||||||
|
{"user_id": stranger["id"], "faction_id": factions["eldar"]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
+277
-15
@@ -1,9 +1,18 @@
|
|||||||
"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль."""
|
"""Профиль: «о себе» (bio), любимая фракция, история партий, аватар
|
||||||
|
(загрузка/отдача/удаление), публичный профиль."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
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»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
|
||||||
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
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"] # после загрузки — ссылка
|
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, "Фракционер")
|
me = login(client, "Фракционер")
|
||||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
gid = client.post(
|
gid = client.post(
|
||||||
@@ -156,17 +165,15 @@ def test_most_played_faction_ignores_random(client: TestClient, engine):
|
|||||||
)
|
)
|
||||||
|
|
||||||
stats = client.get("/api/users/me/stats").json()
|
stats = client.get("/api/users/me/stats").json()
|
||||||
assert stats["most_played_faction"]["faction_id"] == fids[2] # B, хоть партий с A больше
|
# Условия выбора не важны: A сыграна чаще, пусть и по рандому.
|
||||||
assert stats["main_faction"]["faction_id"] == fids[0] # основная — по всем партиям
|
assert stats["main_faction"]["faction_id"] == fids[0]
|
||||||
|
assert stats["main_faction"]["name_ru_prepositional"] # падежная форма проставлена
|
||||||
# У соперника все фракции выбраны вручную — любимая считается как раньше.
|
# Любимая больше не выводится из истории — она задаётся игроком вручную.
|
||||||
login(client, "Партнёр")
|
assert stats["favorite_faction"] is None
|
||||||
stats2 = client.get("/api/users/me/stats").json()
|
|
||||||
assert stats2["most_played_faction"]["faction_id"] == fids[1]
|
|
||||||
|
|
||||||
|
|
||||||
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, "Рандомщик")
|
me = login(client, "Рандомщик")
|
||||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
gid = client.post(
|
gid = client.post(
|
||||||
@@ -183,5 +190,260 @@ def test_most_played_faction_absent_when_all_random(client: TestClient, engine):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
stats = client.get("/api/users/me/stats").json()
|
stats = client.get("/api/users/me/stats").json()
|
||||||
assert stats["most_played_faction"] is None
|
assert stats["main_faction"]["faction_id"] == fids[0]
|
||||||
assert stats["main_faction"]["faction_id"] == fids[0] # основная есть и у рандомщика
|
|
||||||
|
|
||||||
|
def test_favorite_faction_set_and_clear(client: TestClient):
|
||||||
|
"""Любимая фракция — личный выбор игрока: сохраняется и снимается."""
|
||||||
|
login(client, "Выбирающий")
|
||||||
|
fid = client.get("/api/factions").json()[0]["id"]
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["favorite_faction_id"] == fid
|
||||||
|
assert client.get("/api/users/me").json()["favorite_faction_id"] == fid
|
||||||
|
# Партий нет вовсе, а любимая фракция всё равно показывается — она не из статистики.
|
||||||
|
assert client.get("/api/users/me/stats").json()["favorite_faction"]["id"] == fid
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
"/api/users/me/profile", json={"favorite_faction_id": None}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["favorite_faction_id"] is None
|
||||||
|
assert client.get("/api/users/me/stats").json()["favorite_faction"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_favorite_faction_rejects_unknown(client: TestClient):
|
||||||
|
login(client, "Фантазёр")
|
||||||
|
r = client.patch(
|
||||||
|
"/api/users/me/profile", json={"favorite_faction_id": 99999}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_bio_update_keeps_favorite_faction(client: TestClient):
|
||||||
|
"""Частичный PATCH: правка «о себе» не должна обнулять выбранную фракцию."""
|
||||||
|
login(client, "Аккуратный")
|
||||||
|
fid = client.get("/api/factions").json()[0]["id"]
|
||||||
|
client.patch(
|
||||||
|
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.patch("/api/users/me/profile", json={"bio": "Только био"}, headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["bio"] == "Только био"
|
||||||
|
assert r.json()["favorite_faction_id"] == fid
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepositional_dictionary_and_fallback():
|
||||||
|
"""Падежная форма берётся из словаря; незнакомый код — именительный как есть."""
|
||||||
|
from app.services import faction_service
|
||||||
|
|
||||||
|
assert faction_service.prepositional("orks", "Орки") == "Орках"
|
||||||
|
assert (
|
||||||
|
faction_service.prepositional("astra_militarum", "Имперская гвардия")
|
||||||
|
== "Имперской гвардии"
|
||||||
|
)
|
||||||
|
# Фракция, заведённая админом мимо словаря, не роняет вывод.
|
||||||
|
assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── История партий в профиле (#1) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _history(client: TestClient, user_id: int) -> dict:
|
||||||
|
r = client.get(f"/api/users/{user_id}/matches")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _group_with(client: TestClient, engine, *nicknames: str) -> tuple[int, list[int], list[int]]:
|
||||||
|
"""Группа со всеми дополнениями + перечисленные соседи. → (group_id, их user_id, faction_id)."""
|
||||||
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
mates = [add_group_member(engine, gid, nick) for nick in nicknames]
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
return gid, mates, fids
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_lists_only_own_finished_matches(client: TestClient, engine):
|
||||||
|
"""В историю идут только завершённые партии этого игрока."""
|
||||||
|
me = login(client, "Историк")
|
||||||
|
gid, (mate,), fids = _group_with(client, engine, "Сосед")
|
||||||
|
|
||||||
|
create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": mate, "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Незавершённая партия мест не имеет и в историю попадать не должна.
|
||||||
|
assert start_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[2]},
|
||||||
|
{"user_id": mate, "faction_id": fids[3]},
|
||||||
|
],
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
data = _history(client, me["id"])
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert [m["status"] for m in data["items"]] == ["finished"]
|
||||||
|
# Значения по умолчанию едут вместе со списком — гостю хватает одного запроса.
|
||||||
|
assert data["mode"] == "all"
|
||||||
|
assert data["detail"] == "compact"
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_excludes_matches_without_the_player(client: TestClient, engine):
|
||||||
|
"""Чужая партия в историю игрока не попадает, даже внутри его группы."""
|
||||||
|
me = login(client, "Наблюдатель")
|
||||||
|
gid, (mate, third), fids = _group_with(client, engine, "Игрок2", "Игрок3")
|
||||||
|
|
||||||
|
# Партию заводит сосед, сам игрок в ней не участвует.
|
||||||
|
login(client, "Игрок2")
|
||||||
|
create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": mate, "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": third, "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _history(client, me["id"])["total"] == 0
|
||||||
|
assert _history(client, mate)["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_best_mode_picks_highest_points(client: TestClient, engine):
|
||||||
|
"""Режим best берёт партию с максимальными League Points, а не самую свежую.
|
||||||
|
|
||||||
|
Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0."""
|
||||||
|
me = login(client, "Лучший")
|
||||||
|
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
|
||||||
|
|
||||||
|
create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": a, "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
|
||||||
|
{"user_id": b, "faction_id": fids[2], "place": 3},
|
||||||
|
{"user_id": c, "faction_id": fids[3], "place": 4},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Свежее, но по очкам хуже — последнее место на двоих.
|
||||||
|
create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": a, "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
client.patch(
|
||||||
|
"/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
data = _history(client, me["id"])
|
||||||
|
assert data["mode"] == "best"
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["player_count"] == 4 # старшая партия, но с лучшими очками
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_prefs_saved_and_validated(client: TestClient):
|
||||||
|
"""Настройки витрины сохраняются; мусор отклоняется, не сбивая сохранённое."""
|
||||||
|
login(client, "Настройщик")
|
||||||
|
r = client.patch(
|
||||||
|
"/api/users/me/profile",
|
||||||
|
json={"history_mode": "best", "history_detail": "full"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["history_mode"] == "best"
|
||||||
|
assert r.json()["history_detail"] == "full"
|
||||||
|
assert client.get("/api/users/me").json()["history_detail"] == "full"
|
||||||
|
|
||||||
|
bad = client.patch(
|
||||||
|
"/api/users/me/profile", json={"history_mode": "неведомое"}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert bad.status_code == 422
|
||||||
|
assert client.get("/api/users/me").json()["history_mode"] == "best"
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_uses_owner_mode_for_guests(client: TestClient, engine):
|
||||||
|
"""Гость видит историю в том режиме, который выбрал владелец профиля."""
|
||||||
|
me = login(client, "Витрина")
|
||||||
|
gid, (mate,), fids = _group_with(client, engine, "Партнёр")
|
||||||
|
for _ in range(2):
|
||||||
|
create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": mate, "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
client.patch(
|
||||||
|
"/api/users/me/profile",
|
||||||
|
json={"history_mode": "best", "history_detail": "full"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
login(client, "Прохожий")
|
||||||
|
data = _history(client, me["id"])
|
||||||
|
assert data["mode"] == "best"
|
||||||
|
assert data["detail"] == "full"
|
||||||
|
assert data["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_avatar_version_is_stable_across_surfaces(client: TestClient, engine, monkeypatch, tmp_path):
|
||||||
|
"""Кэш-бастер аватара одинаков в профиле и в лидерборде, и меняется при перезаливке.
|
||||||
|
|
||||||
|
Регрессия: версию профиля считал Python из наивного времени как из локального,
|
||||||
|
а лидерборд — SQL как из UTC, и браузер тянул одну картинку дважды. Плюс при
|
||||||
|
том же расширении файла updated_at не двигался и ссылка оставалась прежней."""
|
||||||
|
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||||
|
me = login(client, "Версия")
|
||||||
|
_finished_match_for(client, engine, me)
|
||||||
|
|
||||||
|
def version_in(url: str) -> str:
|
||||||
|
return url.split("?v=")[1]
|
||||||
|
|
||||||
|
def leaderboard_url() -> str:
|
||||||
|
board = client.get("/api/stats/leaderboard").json()
|
||||||
|
entry = next(
|
||||||
|
e for e in board["entries"] + board["provisional"] if e["user_id"] == me["id"]
|
||||||
|
)
|
||||||
|
return entry["avatar_url"]
|
||||||
|
|
||||||
|
first = client.put(
|
||||||
|
"/api/users/me/avatar",
|
||||||
|
files={"file": ("a.png", PNG, "image/png")},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
v_profile = version_in(first.json()["avatar_url"])
|
||||||
|
assert version_in(leaderboard_url()) == v_profile
|
||||||
|
|
||||||
|
# Повторная загрузка с тем же расширением: avatar_path не меняется, поэтому UPDATE
|
||||||
|
# строки сам собой не эмитится — updated_at должен двигаться явно, иначе кэш-бастер
|
||||||
|
# замирает и браузер час показывает прежнюю картинку. Версия в ссылке считается с
|
||||||
|
# точностью до секунды, поэтому сдвиг проверяем по времени в БД.
|
||||||
|
with Session(engine) as s:
|
||||||
|
before = s.get(User, me["id"]).updated_at
|
||||||
|
second = client.put(
|
||||||
|
"/api/users/me/avatar",
|
||||||
|
files={"file": ("a.png", PNG + b"\x00", "image/png")},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert second.status_code == 200, second.text
|
||||||
|
with Session(engine) as s:
|
||||||
|
assert s.get(User, me["id"]).updated_at > before
|
||||||
|
assert version_in(leaderboard_url()) == version_in(second.json()["avatar_url"])
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Статистика профиля: цифры сходятся с лидербордом, а главная не гоняет CTE лишний раз."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||||
|
|
||||||
|
|
||||||
|
def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dict, int, int]:
|
||||||
|
me = login(client, "Статист")
|
||||||
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
p2 = add_group_member(engine, gid, "Соперник")
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
for i in range(games):
|
||||||
|
# Чередуем победителя и берём разные фракции: средние и разбивка по фракциям
|
||||||
|
# должны получиться нетривиальными, а фракции в партии не повторяться.
|
||||||
|
winner_first = i % 2 == 0
|
||||||
|
mine, theirs = fids[(2 * i) % len(fids)], fids[(2 * i + 1) % len(fids)]
|
||||||
|
create_finished_match(
|
||||||
|
client, gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": mine, "place": 1 if winner_first else 2},
|
||||||
|
{"user_id": p2, "faction_id": theirs, "place": 2 if winner_first else 1},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return me, gid, p2
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_numbers_match_leaderboard(client: TestClient, engine):
|
||||||
|
"""Профиль считает в Python, лидерборд — в SQL: цифры обязаны совпадать.
|
||||||
|
|
||||||
|
Формула сглаженного рейтинга живёт в двух видах (SMOOTHED_SCORE_SQL и
|
||||||
|
scoring.smoothed_score); этот тест ловит их расхождение."""
|
||||||
|
me, gid, p2 = _group_with_matches(client, engine, games=4)
|
||||||
|
|
||||||
|
profile = client.get("/api/users/me/stats").json()["overall"]
|
||||||
|
board = client.get("/api/stats/leaderboard").json()
|
||||||
|
entry = next(
|
||||||
|
e for e in board["entries"] + board["provisional"] if e["user_id"] == me["id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in ("games", "wins", "win_rate", "avg_place", "score"):
|
||||||
|
assert profile[field] == entry[field], field
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_does_not_repeat_scored_cte(client: TestClient, engine):
|
||||||
|
"""Главная делает не больше двух проходов по SCORED_CTE.
|
||||||
|
|
||||||
|
Было пять: лидерборд, три запроса профиля и итог по активной группе. Без этой
|
||||||
|
проверки оптимизация тихо отъедет назад при следующей правке витрин."""
|
||||||
|
me, gid, p2 = _group_with_matches(client, engine, games=2)
|
||||||
|
client.put(
|
||||||
|
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def before_execute(conn, cursor, statement, params, context, executemany):
|
||||||
|
if "WITH tie AS" in statement:
|
||||||
|
seen.append(statement)
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", before_execute)
|
||||||
|
try:
|
||||||
|
r = client.get("/api/home")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
finally:
|
||||||
|
event.remove(engine, "before_cursor_execute", before_execute)
|
||||||
|
|
||||||
|
# Сейчас ровно два: лидерборд и один проход по строкам игрока. Нижняя граница не
|
||||||
|
# для красоты — без неё тест пройдёт и когда счётчик молча перестанет что-либо
|
||||||
|
# ловить (сменился путь, переименован CTE).
|
||||||
|
assert 1 <= len(seen) <= 2, f"ожидали 1–2 прохода, получили {len(seen)}"
|
||||||
|
# Главная всё ещё показывает и профиль, и блок активной группы.
|
||||||
|
body = r.json()
|
||||||
|
assert body["profile"]["overall"]["games"] == 2
|
||||||
|
assert body["active_group"]["games"] == 2
|
||||||
@@ -57,3 +57,8 @@ HTTPS твоими сертификатами и проксирует трафи
|
|||||||
Шина событий — **in-memory**, рассчитана на один процесс (uvicorn `--workers 1`, как в
|
Шина событий — **in-memory**, рассчитана на один процесс (uvicorn `--workers 1`, как в
|
||||||
контейнере). Если когда-нибудь поднимешь несколько воркеров/реплик — шину нужно вынести во
|
контейнере). Если когда-нибудь поднимешь несколько воркеров/реплик — шину нужно вынести во
|
||||||
внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию.
|
внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию.
|
||||||
|
|
||||||
|
SSE-поток сам не закрывается, поэтому uvicorn запускается с `--timeout-graceful-shutdown`
|
||||||
|
(`entrypoint.sh`, `run.*`). Без него остановка ждёт закрытия всех соединений: в dev
|
||||||
|
`--reload` при открытой вкладке висит вечно, в контейнере остановку обрывает только SIGKILL
|
||||||
|
по `stop_grace_period`.
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no"
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#0f1115" />
|
<meta name="theme-color" content="#0f1115" />
|
||||||
<title>Forbidden Stars — учёт партий</title>
|
<title>Forbidden Stars — учёт партий</title>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import createClient from "openapi-fetch";
|
import createClient from "openapi-fetch";
|
||||||
import type { paths } from "./schema";
|
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 + "=([^;]*)"));
|
const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
||||||
return m ? decodeURIComponent(m[1]) : null;
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
}
|
}
|
||||||
@@ -53,3 +53,13 @@ export function unwrap<T>(res: FetchResult<T>): T {
|
|||||||
}
|
}
|
||||||
return res.data as T;
|
return res.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Повторы для проб сессии (`/users/me`, `/admin/me`). «Не авторизован» — это
|
||||||
|
* только ответ сервера (401/403, обрабатывается в самих хуках); обрыв связи
|
||||||
|
* ответом не является, и без повторов гварды приняли бы его за разлогин и
|
||||||
|
* увели на страницу входа.
|
||||||
|
*/
|
||||||
|
export const authProbeRetry = (count: number, err: unknown): boolean =>
|
||||||
|
!(err instanceof ApiError) && count < 2;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const qk = {
|
|||||||
factions: ["factions"] as const,
|
factions: ["factions"] as const,
|
||||||
myStats: ["myStats"] as const,
|
myStats: ["myStats"] as const,
|
||||||
publicProfile: (id: number) => ["publicProfile", id] 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,
|
userSearch: (q: string, limit: number) => ["userSearch", q, limit] as const,
|
||||||
groups: ["groups"] as const,
|
groups: ["groups"] as const,
|
||||||
group: (id: number) => ["group", id] as const,
|
group: (id: number) => ["group", id] as const,
|
||||||
@@ -25,3 +26,17 @@ export const qk = {
|
|||||||
adminLogs: ["adminLogs"] as const,
|
adminLogs: ["adminLogs"] as const,
|
||||||
adminAchievements: ["adminAchievements"] as const,
|
adminAchievements: ["adminAchievements"] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем
|
||||||
|
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один
|
||||||
|
* список на SSE-обработчик и на завершение партии — иначе переименование ключа
|
||||||
|
* в этом файле тихо разойдётся с местами, где он написан строкой.
|
||||||
|
*/
|
||||||
|
export const matchAffectedKeys = [
|
||||||
|
qk.home,
|
||||||
|
qk.leaderboard,
|
||||||
|
qk.myStats,
|
||||||
|
["userMatches"],
|
||||||
|
["publicProfile"],
|
||||||
|
] as const;
|
||||||
|
|||||||
Vendored
+209
-1
@@ -174,6 +174,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/users/me/stats": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -528,6 +548,29 @@ export interface paths {
|
|||||||
patch: operations["update_match_api_matches__match_id__patch"];
|
patch: operations["update_match_api_matches__match_id__patch"];
|
||||||
trace?: never;
|
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": {
|
"/api/matches/{match_id}/attachments": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1357,6 +1400,8 @@ export interface components {
|
|||||||
code: string;
|
code: string;
|
||||||
/** Name Ru */
|
/** Name Ru */
|
||||||
name_ru: string;
|
name_ru: string;
|
||||||
|
/** Name Ru Prepositional */
|
||||||
|
name_ru_prepositional: string;
|
||||||
/** Expansion Code */
|
/** Expansion Code */
|
||||||
expansion_code: string;
|
expansion_code: string;
|
||||||
/** Games */
|
/** Games */
|
||||||
@@ -1581,6 +1626,45 @@ export interface components {
|
|||||||
/** Expected Version */
|
/** Expected Version */
|
||||||
expected_version?: string | null;
|
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 */
|
||||||
MatchFinishParticipant: {
|
MatchFinishParticipant: {
|
||||||
/** User Id */
|
/** User Id */
|
||||||
@@ -1597,6 +1681,30 @@ export interface components {
|
|||||||
/** Faction Id */
|
/** Faction Id */
|
||||||
faction_id?: number | null;
|
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 */
|
||||||
MatchList: {
|
MatchList: {
|
||||||
/**
|
/**
|
||||||
@@ -1729,6 +1837,7 @@ export interface components {
|
|||||||
* @default []
|
* @default []
|
||||||
*/
|
*/
|
||||||
attachments: components["schemas"]["AttachmentRead"][];
|
attachments: components["schemas"]["AttachmentRead"][];
|
||||||
|
finish_draft?: components["schemas"]["MatchFinishDraftRead"] | null;
|
||||||
};
|
};
|
||||||
/** MatchUpdate */
|
/** MatchUpdate */
|
||||||
MatchUpdate: {
|
MatchUpdate: {
|
||||||
@@ -1761,6 +1870,18 @@ export interface components {
|
|||||||
bio?: string | null;
|
bio?: string | null;
|
||||||
/** Avatar Url */
|
/** Avatar Url */
|
||||||
avatar_url?: string | null;
|
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
|
* Groups
|
||||||
* @default []
|
* @default []
|
||||||
@@ -1881,7 +2002,7 @@ export interface components {
|
|||||||
factions: components["schemas"]["FactionStat"][];
|
factions: components["schemas"]["FactionStat"][];
|
||||||
best_faction?: components["schemas"]["FactionStat"] | null;
|
best_faction?: components["schemas"]["FactionStat"] | null;
|
||||||
worst_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;
|
main_faction?: components["schemas"]["FactionStat"] | null;
|
||||||
/**
|
/**
|
||||||
* Recent Form
|
* Recent Form
|
||||||
@@ -1898,6 +2019,12 @@ export interface components {
|
|||||||
ProfileUpdate: {
|
ProfileUpdate: {
|
||||||
/** Bio */
|
/** Bio */
|
||||||
bio?: string | null;
|
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 */
|
||||||
PublicProfile: {
|
PublicProfile: {
|
||||||
@@ -2004,6 +2131,18 @@ export interface components {
|
|||||||
bio?: string | null;
|
bio?: string | null;
|
||||||
/** Avatar Url */
|
/** Avatar Url */
|
||||||
avatar_url?: string | null;
|
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
|
* 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: {
|
my_stats_api_users_me_stats_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
add_attachment_api_matches__match_id__attachments_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -5,25 +5,33 @@ import { Spinner } from "../components/Spinner";
|
|||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
import { useAdminMe } from "../hooks/admin";
|
import { useAdminMe } from "../hooks/admin";
|
||||||
|
|
||||||
|
/** Запрос упал, а не ответил «не авторизован»: связи нет — это не повод разлогинивать. */
|
||||||
|
function OfflineNotice() {
|
||||||
|
return <div className="muted">Нет связи с сервером. Проверьте подключение и обновите страницу.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
export function RequireAuth({ children }: PropsWithChildren) {
|
export function RequireAuth({ children }: PropsWithChildren) {
|
||||||
const { data: me, isLoading } = useMe();
|
const { data: me, isLoading, isError } = useMe();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError) return <OfflineNotice />;
|
||||||
if (!me) return <Navigate to="/login" replace state={{ from: location }} />;
|
if (!me) return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RequireGroup({ children }: PropsWithChildren) {
|
export function RequireGroup({ children }: PropsWithChildren) {
|
||||||
const { data: me, isLoading } = useMe();
|
const { data: me, isLoading, isError } = useMe();
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError) return <OfflineNotice />;
|
||||||
if (!me) return <Navigate to="/login" replace />;
|
if (!me) return <Navigate to="/login" replace />;
|
||||||
if (me.active_group_id == null) return <Navigate to="/" replace />;
|
if (me.active_group_id == null) return <Navigate to="/" replace />;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RequireAdmin({ children }: PropsWithChildren) {
|
export function RequireAdmin({ children }: PropsWithChildren) {
|
||||||
const { data: admin, isLoading } = useAdminMe();
|
const { data: admin, isLoading, isError } = useAdminMe();
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError) return <OfflineNotice />;
|
||||||
if (!admin) return <Navigate to="/admin/login" replace />;
|
if (!admin) return <Navigate to="/admin/login" replace />;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { formatTime, plural } from "../domain/format";
|
import { formatTime, plural } from "../domain/format";
|
||||||
import type { HomeInProgressMatch } from "../domain/types";
|
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();
|
const navigate = useNavigate();
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
@@ -20,10 +29,7 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
|||||||
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div className="row" style={{ gap: 8 }}>
|
{showGroupName && <b>{m.group_name}</b>}
|
||||||
<b>{m.group_name}</b>
|
|
||||||
<span className="badge badge-live">идёт</span>
|
|
||||||
</div>
|
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
{formatTime(m.started_at)} · {m.player_count}{" "}
|
{formatTime(m.started_at)} · {m.player_count}{" "}
|
||||||
{plural(m.player_count, "игрок", "игрока", "игроков")}
|
{plural(m.player_count, "игрок", "игрока", "игроков")}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { formatDate, formatDuration } from "../domain/format";
|
||||||
|
import type { MatchListItem } from "../domain/types";
|
||||||
|
import { MatchListView } from "./MatchList";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
|
||||||
|
* (MatchListView со всеми участниками); компактный — строка с результатом самого
|
||||||
|
* владельца профиля, поэтому компонент и принимает его userId.
|
||||||
|
*/
|
||||||
|
export function MatchHistory({
|
||||||
|
items,
|
||||||
|
userId,
|
||||||
|
detail,
|
||||||
|
hasMore = false,
|
||||||
|
loadingMore = false,
|
||||||
|
onMore,
|
||||||
|
}: {
|
||||||
|
items: MatchListItem[];
|
||||||
|
userId: number;
|
||||||
|
detail: string;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
|
onMore?: () => void;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const more = hasMore && onMore && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-block"
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
onClick={onMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
>
|
||||||
|
{loadingMore ? "Загружаю…" : "Показать ещё"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
||||||
|
if (detail === "full")
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MatchListView items={items} />
|
||||||
|
{more}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
{items.map((m) => {
|
||||||
|
const mine = m.participants.find((p) => p.user_id === userId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className="card row-between"
|
||||||
|
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
||||||
|
onClick={() => navigate(`/match/${m.id}`)}
|
||||||
|
>
|
||||||
|
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"place-badge" +
|
||||||
|
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
|
||||||
|
(mine?.eliminated ? " eliminated" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{mine?.faction_name ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="muted small" style={{ flexShrink: 0 }}>
|
||||||
|
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{more}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import { formatDate, formatDuration } from "../domain/format";
|
|||||||
import { winReasonLabel } from "../domain/winReasons";
|
import { winReasonLabel } from "../domain/winReasons";
|
||||||
import type { MatchListItem } from "../domain/types";
|
import type { MatchListItem } from "../domain/types";
|
||||||
|
|
||||||
|
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
|
||||||
|
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
|
||||||
|
* отфильтрованной по status="finished". */
|
||||||
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
||||||
@@ -11,7 +14,6 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
|||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{items.map((m) => {
|
{items.map((m) => {
|
||||||
const inProgress = m.status === "in_progress";
|
|
||||||
const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
|
const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -22,13 +24,9 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
|||||||
>
|
>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<span className="muted small">{formatDate(m.played_at)}</span>
|
<span className="muted small">{formatDate(m.played_at)}</span>
|
||||||
{inProgress ? (
|
|
||||||
<span className="badge badge-live">идёт</span>
|
|
||||||
) : (
|
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
{formatDuration(m.duration_minutes)} · {winReasonLabel(m.win_reason)}
|
{formatDuration(m.duration_minutes)} · {winReasonLabel(m.win_reason)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
|
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
|
||||||
{/* Ники здесь — просто текст: карточка целиком ведёт в партию,
|
{/* Ники здесь — просто текст: карточка целиком ведёт в партию,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function PlaceEditor({
|
|||||||
const elimRef = useRef<HTMLDivElement>(null);
|
const elimRef = useRef<HTMLDivElement>(null);
|
||||||
const indRef = useRef<HTMLDivElement>(null);
|
const indRef = useRef<HTMLDivElement>(null);
|
||||||
const dragRef = useRef<DragState | null>(null);
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
const hintRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const byId = new Map(players.map((p) => [p.user_id, p]));
|
const byId = new Map(players.map((p) => [p.user_id, p]));
|
||||||
const placeOf = (i: number) =>
|
const placeOf = (i: number) =>
|
||||||
@@ -80,6 +81,7 @@ export function PlaceEditor({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearHints = () => {
|
const clearHints = () => {
|
||||||
|
hintRef.current = null;
|
||||||
if (indRef.current) indRef.current.style.display = "none";
|
if (indRef.current) indRef.current.style.display = "none";
|
||||||
listRef.current
|
listRef.current
|
||||||
?.querySelectorAll(".merge-target")
|
?.querySelectorAll(".merge-target")
|
||||||
@@ -88,7 +90,12 @@ export function PlaceEditor({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showHint = (t: Target, d: DragState) => {
|
const showHint = (t: Target, d: DragState) => {
|
||||||
|
// pointermove срабатывает десятки раз в секунду, а цель меняется намного реже:
|
||||||
|
// без этой проверки на каждое движение шёл querySelectorAll и перестановка классов.
|
||||||
|
const key = `${t.type}:${t.type === "elim" ? "" : t.idx}`;
|
||||||
|
if (key === hintRef.current) return;
|
||||||
clearHints();
|
clearHints();
|
||||||
|
hintRef.current = key;
|
||||||
if (t.type === "elim") {
|
if (t.type === "elim") {
|
||||||
elimRef.current?.classList.add("highlight");
|
elimRef.current?.classList.add("highlight");
|
||||||
return;
|
return;
|
||||||
@@ -182,6 +189,10 @@ export function PlaceEditor({
|
|||||||
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => startDrag(e, opts),
|
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => startDrag(e, opts),
|
||||||
onPointerMove: moveDrag,
|
onPointerMove: moveDrag,
|
||||||
onPointerUp: endDrag,
|
onPointerUp: endDrag,
|
||||||
|
// Браузер отменяет указатель (перехват прокрутки, входящий звонок) — без этого
|
||||||
|
// блок остаётся с классом dragging и сдвигом: они выставлены в обход React,
|
||||||
|
// и перерендер их не снимет.
|
||||||
|
onPointerCancel: endDrag,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё.
|
// ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё.
|
||||||
|
|||||||
@@ -38,16 +38,17 @@ export function ProfileStatsCard({
|
|||||||
<div>Очки (рейтинг)</div>
|
<div>Очки (рейтинг)</div>
|
||||||
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
|
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
|
||||||
</div>
|
</div>
|
||||||
{stats.most_played_faction && (
|
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
|
||||||
|
{stats.favorite_faction && (
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<div>Любимая фракция</div>
|
<div>Любимая фракция</div>
|
||||||
<b>{stats.most_played_faction.name_ru}</b>
|
<b>{stats.favorite_faction.name_ru}</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{stats.main_faction && (
|
{stats.main_faction && (
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<div>Основная фракция</div>
|
<div>Чаще всего играет на</div>
|
||||||
<b>{stats.main_faction.name_ru}</b>
|
<b>{stats.main_faction.name_ru_prepositional}</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
interface ToastCtx {
|
interface ToastCtx {
|
||||||
@@ -17,8 +17,13 @@ export function ToastProvider({ children }: PropsWithChildren) {
|
|||||||
timer.current = setTimeout(() => setMessage(null), 2800);
|
timer.current = setTimeout(() => setMessage(null), 2800);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост:
|
||||||
|
// без useMemo новое значение контекста заставляло бы перерисоваться и всех
|
||||||
|
// потребителей, к самому тосту отношения не имеющих.
|
||||||
|
const value = useMemo(() => ({ show }), [show]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Ctx.Provider value={{ show }}>
|
<Ctx.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
{message && <div className="toast">{message}</div>}
|
{message && <div className="toast">{message}</div>}
|
||||||
</Ctx.Provider>
|
</Ctx.Provider>
|
||||||
|
|||||||
@@ -29,12 +29,9 @@ export function formatDuration(minutes: number | null | undefined): string {
|
|||||||
|
|
||||||
export function formatTime(iso: string | null | undefined): string {
|
export function formatTime(iso: string | null | undefined): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
try {
|
|
||||||
const d = shifted(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())}`;
|
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части,
|
// «Дата игры» приходит 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 {
|
export function formatDateTime(iso: string | null | undefined): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
try {
|
|
||||||
const d = shifted(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())}`;
|
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ export type ParticipantInput = S["ParticipantInput"];
|
|||||||
export type MatchUpdate = S["MatchUpdate"];
|
export type MatchUpdate = S["MatchUpdate"];
|
||||||
export type MatchCreate = S["MatchCreate"];
|
export type MatchCreate = S["MatchCreate"];
|
||||||
export type MatchFinish = S["MatchFinish"];
|
export type MatchFinish = S["MatchFinish"];
|
||||||
|
export type MatchFinishDraftData = S["MatchFinishDraftData"];
|
||||||
|
|||||||
+10
-51
@@ -1,14 +1,9 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { 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 { qk } from "../api/queryKeys";
|
||||||
import type { AdminMe, MatchUpdate } from "../domain/types";
|
import type { AdminMe, MatchUpdate } from "../domain/types";
|
||||||
import { resizeImage } from "../lib/image";
|
import { uploadFile } from "../lib/upload";
|
||||||
|
|
||||||
function readCsrfToken(): string | null {
|
|
||||||
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
|
|
||||||
return m ? decodeURIComponent(m[1]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAdminMe() {
|
export function useAdminMe() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -18,6 +13,7 @@ export function useAdminMe() {
|
|||||||
if (r.response.status === 401 || r.response.status === 403) return null;
|
if (r.response.status === 401 || r.response.status === 403) return null;
|
||||||
return unwrap(r);
|
return unwrap(r);
|
||||||
},
|
},
|
||||||
|
retry: authProbeRetry,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,29 +102,8 @@ export function useAdminMatchAttachments(matchId: number | null) {
|
|||||||
export function useAdminUploadAttachment(matchId: number) {
|
export function useAdminUploadAttachment(matchId: number) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) =>
|
||||||
const blob = await resizeImage(file);
|
uploadFile(`/api/admin/matches/${matchId}/attachments`, 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();
|
|
||||||
},
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -283,28 +258,12 @@ export function useDeleteAchievement() {
|
|||||||
export function useUploadAchievementIcon() {
|
export function useUploadAchievementIcon() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ slug, file }: { slug: string; file: File }) => {
|
// Иконку шлём оригиналом: ресайз в JPEG убил бы прозрачность герба.
|
||||||
const form = new FormData();
|
mutationFn: async ({ slug, file }: { slug: string; file: File }) =>
|
||||||
form.append("file", file, file.name);
|
uploadFile(`/api/admin/achievements/${slug}/icon`, file, {
|
||||||
const csrf = readCsrfToken();
|
|
||||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
|
||||||
const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, {
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: form,
|
maxSide: null,
|
||||||
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();
|
|
||||||
},
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-60
@@ -1,7 +1,8 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { qk } from "../api/queryKeys";
|
||||||
|
import { uploadFile } from "../lib/upload";
|
||||||
import type { AuthConfig, Me } from "../domain/types";
|
import type { AuthConfig, Me } from "../domain/types";
|
||||||
|
|
||||||
export function useMe() {
|
export function useMe() {
|
||||||
@@ -12,6 +13,7 @@ export function useMe() {
|
|||||||
if (r.response.status === 401) return null;
|
if (r.response.status === 401) return null;
|
||||||
return unwrap(r);
|
return unwrap(r);
|
||||||
},
|
},
|
||||||
|
retry: authProbeRetry,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,74 +119,53 @@ export function useUpdateBio() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным
|
// Любимая фракция — личный выбор игрока (в статистике не вычисляется). null снимает выбор.
|
||||||
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
|
// Шлём только это поле: роутер обновляет лишь пришедшие, «о себе» не затирается.
|
||||||
function readCsrfToken(): string | null {
|
export function useUpdateFavoriteFaction() {
|
||||||
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
|
const qc = useQueryClient();
|
||||||
return m ? decodeURIComponent(m[1]) : null;
|
return useMutation({
|
||||||
|
mutationFn: async (factionId: number | null): Promise<Me> =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH("/api/users/me/profile", {
|
||||||
|
body: { favorite_faction_id: factionId },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: (me) => {
|
||||||
|
qc.setQueryData(qk.me, me);
|
||||||
|
// Карточка статистики показывает любимую фракцию — её тоже перезапрашиваем.
|
||||||
|
qc.invalidateQueries({ queryKey: qk.myStats });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.home });
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resizeImage(file: File, max = 512): Promise<Blob> {
|
// Витрина истории партий: режим (all/best) и подробность (compact/full).
|
||||||
try {
|
// Шлём только изменившееся поле — роутер обновляет лишь пришедшие.
|
||||||
const dataUrl = await new Promise<string>((res, rej) => {
|
export function useUpdateHistoryPrefs() {
|
||||||
const fr = new FileReader();
|
const qc = useQueryClient();
|
||||||
fr.onload = () => res(fr.result as string);
|
return useMutation({
|
||||||
fr.onerror = () => rej(fr.error);
|
mutationFn: async (prefs: {
|
||||||
fr.readAsDataURL(file);
|
history_mode?: string;
|
||||||
|
history_detail?: string;
|
||||||
|
}): Promise<Me> => unwrap(await api.PATCH("/api/users/me/profile", { body: prefs })),
|
||||||
|
onSuccess: (me) => {
|
||||||
|
qc.setQueryData(qk.me, me);
|
||||||
|
// Список истории приходит вместе с режимом — перезапрашиваем его целиком.
|
||||||
|
qc.invalidateQueries({ queryKey: qk.userMatches(me.id) });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const img = await new Promise<HTMLImageElement>((res, rej) => {
|
|
||||||
const i = new Image();
|
|
||||||
i.onload = () => res(i);
|
|
||||||
i.onerror = () => rej(new Error("image load failed"));
|
|
||||||
i.src = dataUrl;
|
|
||||||
});
|
|
||||||
let { width, height } = img;
|
|
||||||
if (width > max || height > max) {
|
|
||||||
const scale = Math.min(max / width, max / height);
|
|
||||||
width = Math.round(width * scale);
|
|
||||||
height = Math.round(height * scale);
|
|
||||||
}
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return file;
|
|
||||||
ctx.drawImage(img, 0, 0, width, height);
|
|
||||||
const blob = await new Promise<Blob | null>((res) =>
|
|
||||||
canvas.toBlob(res, "image/jpeg", 0.85),
|
|
||||||
);
|
|
||||||
return blob ?? file;
|
|
||||||
} catch {
|
|
||||||
return file; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Аватар: картинку уменьшаем на клиенте (≤512px) и грузим общим загрузчиком.
|
||||||
export function useUploadAvatar() {
|
export function useUploadAvatar() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (file: File): Promise<Me> => {
|
mutationFn: async (file: File): Promise<Me> =>
|
||||||
const blob = await resizeImage(file);
|
uploadFile<Me>("/api/users/me/avatar", 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`, {
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: form,
|
fieldName: "avatar.jpg",
|
||||||
credentials: "include",
|
maxSide: 512,
|
||||||
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;
|
|
||||||
},
|
|
||||||
onSuccess: (me) => {
|
onSuccess: (me) => {
|
||||||
qc.setQueryData(qk.me, me);
|
qc.setQueryData(qk.me, me);
|
||||||
qc.invalidateQueries();
|
qc.invalidateQueries();
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { ApiError, api, unwrap } from "../api/client";
|
import { api, unwrap } from "../api/client";
|
||||||
import { qk } from "../api/queryKeys";
|
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
||||||
import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types";
|
import type {
|
||||||
import { resizeImage } from "../lib/image";
|
FactionRead,
|
||||||
|
MatchCreate,
|
||||||
function readCsrfToken(): string | null {
|
MatchFinish,
|
||||||
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
|
MatchFinishDraftData,
|
||||||
return m ? decodeURIComponent(m[1]) : null;
|
MatchRead,
|
||||||
}
|
MatchUpdate,
|
||||||
|
} from "../domain/types";
|
||||||
|
import { uploadFile } from "../lib/upload";
|
||||||
|
|
||||||
export function useMatch(matchId: number | null) {
|
export function useMatch(matchId: number | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: matchId ? qk.match(matchId) : ["match", "none"],
|
// Ключ и enabled должны сходиться: иначе запрос без валидного id кэшировал бы
|
||||||
|
// свою ошибку под общим ключом-заглушкой.
|
||||||
|
queryKey: matchId != null ? qk.match(matchId) : ["match", "none"],
|
||||||
enabled: matchId != null,
|
enabled: matchId != null,
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
unwrap(
|
unwrap(
|
||||||
@@ -23,6 +27,26 @@ export function useMatch(matchId: number | null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общий черновик формы завершения: то, что видят все, кто заполняет партию.
|
||||||
|
* Ответ — свежая партия целиком, поэтому кладём его прямо в кэш, без инвалидации:
|
||||||
|
* перезапрос на каждое движение тайла был бы расточительным.
|
||||||
|
*/
|
||||||
|
export function useSaveFinishDraft(matchId: number | null) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: MatchFinishDraftData): Promise<MatchRead> =>
|
||||||
|
unwrap(
|
||||||
|
await api.PUT("/api/matches/{match_id}/finish-draft", {
|
||||||
|
params: { path: { match_id: matchId as number } },
|
||||||
|
body: data,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: (m) => qc.setQueryData(qk.match(m.id), m),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useRandomizeFaction() {
|
export function useRandomizeFaction() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (args: { group_id: number; exclude_faction_ids: number[] }): Promise<FactionRead> => {
|
mutationFn: async (args: { group_id: number; exclude_faction_ids: number[] }): Promise<FactionRead> => {
|
||||||
@@ -60,13 +84,33 @@ export function useFinishMatch() {
|
|||||||
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||||
qc.invalidateQueries({ queryKey: qk.home });
|
|
||||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Правка завершённой партии участником группы (у админки свой эндпойнт). */
|
||||||
|
export function useUpdateMatch() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (args: { matchId: number; body: MatchUpdate }): Promise<MatchRead> =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH("/api/matches/{match_id}", {
|
||||||
|
params: { path: { match_id: args.matchId } },
|
||||||
|
body: args.body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: (m) => {
|
||||||
|
qc.setQueryData(qk.match(m.id), m);
|
||||||
|
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
||||||
|
// Места изменились — значит изменились лидерборд, история и профили.
|
||||||
|
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useDeleteMatch() {
|
export function useDeleteMatch() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -88,29 +132,8 @@ export function useDeleteMatch() {
|
|||||||
export function useUploadMatchAttachment(matchId: number) {
|
export function useUploadMatchAttachment(matchId: number) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) =>
|
||||||
const blob = await resizeImage(file);
|
uploadFile(`/api/matches/${matchId}/attachments`, 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();
|
|
||||||
},
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ export function useExpansions() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Весь справочник фракций (для выбора любимой в профиле — он не зависит от группы).
|
||||||
|
export function useFactions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.factions,
|
||||||
|
queryFn: async () => unwrap(await api.GET("/api/factions")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useGroupFactions(groupId: number | null) {
|
export function useGroupFactions(groupId: number | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"],
|
queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"],
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
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 {
|
interface ServerEvent {
|
||||||
type: "match" | "group" | "invitations" | "notifications";
|
type: "match" | "match_draft" | "group" | "invitations" | "notifications";
|
||||||
match_id?: number;
|
match_id?: number;
|
||||||
group_id?: number;
|
group_id?: number;
|
||||||
|
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
|
||||||
|
participant_ids?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +19,11 @@ interface ServerEvent {
|
|||||||
*/
|
*/
|
||||||
export function useServerEvents(enabled: boolean) {
|
export function useServerEvents(enabled: boolean) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { data: me } = useMe();
|
||||||
|
// Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали
|
||||||
|
// SSE-соединение каждый раз, когда профиль перезапрашивается.
|
||||||
|
const myId = useRef<number | null>(null);
|
||||||
|
myId.current = me?.id ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
@@ -32,14 +40,34 @@ export function useServerEvents(enabled: boolean) {
|
|||||||
qc.invalidateQueries({ queryKey: qk.invitations });
|
qc.invalidateQueries({ queryKey: qk.invitations });
|
||||||
} else if (ev.type === "notifications") {
|
} else if (ev.type === "notifications") {
|
||||||
qc.invalidateQueries({ queryKey: qk.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") {
|
} else if (ev.type === "match") {
|
||||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||||
if (ev.group_id != null) {
|
if (ev.group_id != null) {
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
|
||||||
}
|
}
|
||||||
|
// Общее меняется от любой партии: рейтинг глобальный, и чужая игра двигает топ.
|
||||||
qc.invalidateQueries({ queryKey: qk.home });
|
qc.invalidateQueries({ queryKey: qk.home });
|
||||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
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") {
|
} else if (ev.type === "group") {
|
||||||
if (ev.group_id != null) {
|
if (ev.group_id != null) {
|
||||||
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { api, unwrap } from "../api/client";
|
import { api, unwrap } from "../api/client";
|
||||||
@@ -15,6 +15,34 @@ function useDebounced<T>(value: T, ms = 100): T {
|
|||||||
return debounced;
|
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). */
|
/** Подсказки по нику среди всех активных игроков (GET /api/users/search). */
|
||||||
export function useUserSearch(q: string, limit = 3) {
|
export function useUserSearch(q: string, limit = 3) {
|
||||||
const dq = useDebounced(q.trim());
|
const dq = useDebounced(q.trim());
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { ApiError, readCookie } from "../api/client";
|
||||||
|
|
||||||
|
import { resizeImage } from "./image";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка файла multipart'ом. openapi-fetch неудобен для файлов, поэтому идём
|
||||||
|
* обычным fetch — а значит CSRF-заголовок, базовый URL и разбор конверта ошибки
|
||||||
|
* приходится ставить руками. Один хелпер на все четыре загрузки (аватар, фото
|
||||||
|
* партии, фото партии из админки, иконка ачивки): иначе правка вроде таймаута
|
||||||
|
* или прогресса расходится по копиям.
|
||||||
|
*
|
||||||
|
* maxSide задаёт ресайз перед отправкой; `null` отправляет файл как есть
|
||||||
|
* (иконки ачивок грузятся оригиналом, у них свой лимит на сервере).
|
||||||
|
*/
|
||||||
|
export async function uploadFile<T>(
|
||||||
|
url: string,
|
||||||
|
file: File,
|
||||||
|
opts: {
|
||||||
|
method?: "POST" | "PUT";
|
||||||
|
fieldName?: string;
|
||||||
|
maxSide?: number | null;
|
||||||
|
errorMessage?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
method = "POST",
|
||||||
|
maxSide = 1600,
|
||||||
|
fieldName = maxSide == null ? file.name : "photo.jpg",
|
||||||
|
errorMessage = "Не удалось загрузить",
|
||||||
|
} = opts;
|
||||||
|
const body = maxSide == null ? file : await resizeImage(file, maxSide);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", body, fieldName);
|
||||||
|
const csrf = readCookie("csrf_token");
|
||||||
|
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
|
const r = await fetch(`${base}${url}`, {
|
||||||
|
method,
|
||||||
|
body: form,
|
||||||
|
credentials: "include",
|
||||||
|
headers: csrf ? { "X-CSRF-Token": csrf } : {},
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
let env: { code?: string; message?: string; details?: unknown } | undefined;
|
||||||
|
try {
|
||||||
|
env = ((await r.json()) as { error?: typeof env }).error;
|
||||||
|
} catch {
|
||||||
|
/* тело без JSON */
|
||||||
|
}
|
||||||
|
throw new ApiError(env?.message || errorMessage, env?.code || "ERROR", r.status, env?.details);
|
||||||
|
}
|
||||||
|
return (await r.json()) as T;
|
||||||
|
}
|
||||||
@@ -3,24 +3,41 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
|
import { MatchHistory } from "../components/MatchHistory";
|
||||||
|
import { PickerSelect } from "../components/PickerSelect";
|
||||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
|
import { Switch } from "../components/Switch";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import {
|
import {
|
||||||
useDeleteAvatar,
|
useDeleteAvatar,
|
||||||
useLogout,
|
useLogout,
|
||||||
useMe,
|
useMe,
|
||||||
useUpdateBio,
|
useUpdateBio,
|
||||||
|
useUpdateFavoriteFaction,
|
||||||
|
useUpdateHistoryPrefs,
|
||||||
useUpdateNickname,
|
useUpdateNickname,
|
||||||
useUploadAvatar,
|
useUploadAvatar,
|
||||||
} from "../hooks/auth";
|
} from "../hooks/auth";
|
||||||
|
import { useExpansions, useFactions } from "../hooks/reference";
|
||||||
import { useMyStats } from "../hooks/stats";
|
import { useMyStats } from "../hooks/stats";
|
||||||
|
import { useUserMatches } from "../hooks/users";
|
||||||
|
|
||||||
export function AccountPage() {
|
export function AccountPage() {
|
||||||
const { data: me, isLoading } = useMe();
|
const { data: me, isLoading } = useMe();
|
||||||
const { data: stats } = useMyStats();
|
const { data: stats } = useMyStats();
|
||||||
const updateNick = useUpdateNickname();
|
const updateNick = useUpdateNickname();
|
||||||
const updateBio = useUpdateBio();
|
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 uploadAvatar = useUploadAvatar();
|
||||||
const deleteAvatar = useDeleteAvatar();
|
const deleteAvatar = useDeleteAvatar();
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
@@ -35,6 +52,28 @@ export function AccountPage() {
|
|||||||
// bio редактируется локально; до первого ввода берём значение из профиля.
|
// bio редактируется локально; до первого ввода берём значение из профиля.
|
||||||
const bioValue = bio ?? me.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 saveNick = async () => {
|
||||||
const nick = nickname.trim();
|
const nick = nickname.trim();
|
||||||
if (!nick) return;
|
if (!nick) return;
|
||||||
@@ -90,6 +129,7 @@ export function AccountPage() {
|
|||||||
<Avatar url={me.avatar_url} nickname={me.nickname} size={64} />
|
<Avatar url={me.avatar_url} nickname={me.nickname} size={64} />
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<b style={{ fontSize: 18 }}>{me.nickname}</b>
|
<b style={{ fontSize: 18 }}>{me.nickname}</b>
|
||||||
|
{stats && <div className="muted small">Партий: {stats.overall.games}</div>}
|
||||||
<div className="row" style={{ gap: 8, marginTop: 8 }}>
|
<div className="row" style={{ gap: 8, marginTop: 8 }}>
|
||||||
<button
|
<button
|
||||||
className="btn small"
|
className="btn small"
|
||||||
@@ -140,6 +180,36 @@ export function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="field" style={{ marginTop: 10 }}>
|
||||||
|
<label className="label">Любимая фракция</label>
|
||||||
|
<div className="row">
|
||||||
|
<PickerSelect
|
||||||
|
selected={favorite}
|
||||||
|
options={factions ?? []}
|
||||||
|
placeholder="Не выбрана"
|
||||||
|
renderOption={(f) => (
|
||||||
|
<>
|
||||||
|
<span className="nick">{f.name_ru}</span>
|
||||||
|
<span className="muted small" style={{ marginLeft: "auto" }}>
|
||||||
|
{expansionName(f.expansion_id)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
renderValue={(f) => <span className="nick">{f.name_ru}</span>}
|
||||||
|
onPick={(f) => saveFavorite(f.id)}
|
||||||
|
/>
|
||||||
|
{favorite && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost small"
|
||||||
|
onClick={() => saveFavorite(null)}
|
||||||
|
disabled={updateFavorite.isPending}
|
||||||
|
>
|
||||||
|
Убрать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="field" style={{ marginTop: 10 }}>
|
<div className="field" style={{ marginTop: 10 }}>
|
||||||
<label className="label">Сменить никнейм</label>
|
<label className="label">Сменить никнейм</label>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
@@ -153,6 +223,39 @@ export function AccountPage() {
|
|||||||
|
|
||||||
{stats && <ProfileStatsCard stats={stats} />}
|
{stats && <ProfileStatsCard stats={stats} />}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>История игр</h3>
|
||||||
|
{/* Настройки — витрина профиля: в этом же виде историю видят и гости. */}
|
||||||
|
<div className="row-between">
|
||||||
|
<div>Только лучшая партия</div>
|
||||||
|
<Switch
|
||||||
|
checked={me.history_mode === "best"}
|
||||||
|
disabled={updateHistory.isPending}
|
||||||
|
onChange={(v) => saveHistory({ history_mode: v ? "best" : "all" })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="row-between" style={{ marginTop: 10 }}>
|
||||||
|
<div>Подробные карточки</div>
|
||||||
|
<Switch
|
||||||
|
checked={me.history_detail === "full"}
|
||||||
|
disabled={updateHistory.isPending}
|
||||||
|
onChange={(v) => saveHistory({ history_detail: v ? "full" : "compact" })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
{history && (
|
||||||
|
<MatchHistory
|
||||||
|
items={history.pages.flatMap((p) => p.items)}
|
||||||
|
userId={me.id}
|
||||||
|
detail={history.pages[0].detail}
|
||||||
|
hasMore={hasNextPage}
|
||||||
|
loadingMore={isFetchingNextPage}
|
||||||
|
onMore={() => fetchNextPage()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button className="btn btn-danger btn-block" onClick={doLogout}>
|
<button className="btn btn-danger btn-block" onClick={doLogout}>
|
||||||
Выйти
|
Выйти
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,13 +15,21 @@ import { useGroupMembers } from "../hooks/groups";
|
|||||||
import { useRandomizeFaction, useStartMatch } from "../hooks/matches";
|
import { useRandomizeFaction, useStartMatch } from "../hooks/matches";
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
|
// Стабильный ключ строки: индексы сдвигаются при удалении, а барабан рандома
|
||||||
|
// отложенно дописывает результат в свою строку и промахнулся бы по соседа.
|
||||||
|
id: number;
|
||||||
user_id: number | "";
|
user_id: number | "";
|
||||||
faction_id: number | "";
|
faction_id: number | "";
|
||||||
was_random: boolean;
|
was_random: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let draftSeq = 0;
|
||||||
|
|
||||||
function emptyDraft(): Draft {
|
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() {
|
export function CreateMatchPage() {
|
||||||
@@ -51,30 +59,39 @@ export function CreateMatchPage() {
|
|||||||
if (lm || lf) return <Spinner />;
|
if (lm || lf) return <Spinner />;
|
||||||
if (!groupId) return <div className="muted">Нет активной группы.</div>;
|
if (!groupId) return <div className="muted">Нет активной группы.</div>;
|
||||||
|
|
||||||
const takenFactionIds = (exceptIdx: number) =>
|
const takenFactionIds = (exceptId: number) =>
|
||||||
rows
|
rows
|
||||||
.filter((_, i) => i !== exceptIdx)
|
.filter((r) => r.id !== exceptId)
|
||||||
.map((r) => r.faction_id)
|
.map((r) => r.faction_id)
|
||||||
.filter((x): x is number => typeof x === "number");
|
.filter((x): x is number => typeof x === "number");
|
||||||
|
|
||||||
// Игроки, уже выбранные в других строках, — в списке не предлагаются.
|
// Игроки, уже выбранные в других строках, — в списке не предлагаются.
|
||||||
const takenUserIds = (exceptIdx: number) =>
|
const takenUserIds = (exceptId: number) =>
|
||||||
rows
|
rows
|
||||||
.filter((_, i) => i !== exceptIdx)
|
.filter((r) => r.id !== exceptId)
|
||||||
.map((r) => r.user_id)
|
.map((r) => r.user_id)
|
||||||
.filter((x): x is number => typeof x === "number");
|
.filter((x): x is number => typeof x === "number");
|
||||||
|
|
||||||
const update = (idx: number, patch: Partial<Draft>) =>
|
const update = (id: number, patch: Partial<Draft>) =>
|
||||||
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
|
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с — и только
|
// затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только
|
||||||
// после этого фракция «выбирается» (onReveal: показываем select с результатом).
|
// после этого фракция «выбирается» (onReveal: показываем select с результатом).
|
||||||
const animateReel = (
|
const animateReel = (
|
||||||
idx: number,
|
id: number,
|
||||||
names: string[],
|
names: string[],
|
||||||
finalName: string,
|
finalName: string,
|
||||||
onReveal: () => void,
|
onReveal: () => void,
|
||||||
@@ -82,16 +99,16 @@ export function CreateMatchPage() {
|
|||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
let delay = 50;
|
let delay = 50;
|
||||||
const tick = () => {
|
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;
|
delay *= 1.18;
|
||||||
if (delay < 300) {
|
if (delay < 300) {
|
||||||
reelTimers.current[idx] = setTimeout(tick, delay);
|
reelTimers.current[id] = setTimeout(tick, delay);
|
||||||
} else {
|
} else {
|
||||||
reelTimers.current[idx] = setTimeout(() => {
|
reelTimers.current[id] = setTimeout(() => {
|
||||||
setSpin((s) => ({ ...s, [idx]: finalName })); // остановка на выбранной
|
setSpin((s) => ({ ...s, [id]: finalName })); // остановка на выбранной
|
||||||
reelTimers.current[idx] = setTimeout(() => {
|
reelTimers.current[id] = setTimeout(() => {
|
||||||
onReveal();
|
onReveal();
|
||||||
setSpin((s) => ({ ...s, [idx]: null }));
|
setSpin((s) => ({ ...s, [id]: null }));
|
||||||
resolve();
|
resolve();
|
||||||
}, 500); // пауза перед «выбором»
|
}, 500); // пауза перед «выбором»
|
||||||
}, delay);
|
}, delay);
|
||||||
@@ -100,20 +117,20 @@ export function CreateMatchPage() {
|
|||||||
tick();
|
tick();
|
||||||
});
|
});
|
||||||
|
|
||||||
const randomizeRow = async (idx: number) => {
|
const randomizeRow = async (id: number) => {
|
||||||
const pool = factions ?? [];
|
const pool = factions ?? [];
|
||||||
if (pool.length === 0 || spin[idx] != null) return;
|
if (pool.length === 0 || spin[id] != null) return;
|
||||||
try {
|
try {
|
||||||
const faction = await randomize.mutateAsync({
|
const faction = await randomize.mutateAsync({
|
||||||
group_id: groupId,
|
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, () =>
|
await animateReel(id, pool.map((f) => f.name_ru), faction.name_ru, () =>
|
||||||
update(idx, { faction_id: faction.id, was_random: true }),
|
update(id, { faction_id: faction.id, was_random: true }),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]);
|
if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
|
||||||
setSpin((s) => ({ ...s, [idx]: null }));
|
setSpin((s) => ({ ...s, [id]: null }));
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -130,10 +147,10 @@ export function CreateMatchPage() {
|
|||||||
const names = all.map((f) => f.name_ru);
|
const names = all.map((f) => f.name_ru);
|
||||||
// Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
|
// Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
rows.map((_, idx) => {
|
rows.map((row, idx) => {
|
||||||
const f = pool[idx % pool.length];
|
const f = pool[idx % pool.length];
|
||||||
return animateReel(idx, names, f.name_ru, () =>
|
return animateReel(row.id, names, f.name_ru, () =>
|
||||||
update(idx, { faction_id: f.id, was_random: true }),
|
update(row.id, { faction_id: f.id, was_random: true }),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -194,11 +211,11 @@ export function CreateMatchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rows.map((row, idx) => (
|
{rows.map((row, idx) => (
|
||||||
<div className="participant-row" key={idx}>
|
<div className="participant-row" key={row.id}>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<b>Игрок {idx + 1}</b>
|
<b>Игрок {idx + 1}</b>
|
||||||
{rows.length > 2 && (
|
{rows.length > 2 && (
|
||||||
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(idx)}>
|
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(row.id)}>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -206,29 +223,29 @@ export function CreateMatchPage() {
|
|||||||
|
|
||||||
<PickerSelect
|
<PickerSelect
|
||||||
selected={playerOptions.find((m) => m.id === row.user_id) ?? null}
|
selected={playerOptions.find((m) => m.id === row.user_id) ?? null}
|
||||||
options={playerOptions.filter((m) => !takenUserIds(idx).includes(m.id))}
|
options={playerOptions.filter((m) => !takenUserIds(row.id).includes(m.id))}
|
||||||
placeholder="— игрок —"
|
placeholder="— игрок —"
|
||||||
renderOption={(m) => playerLabel(m, 28)}
|
renderOption={(m) => playerLabel(m, 28)}
|
||||||
renderValue={(m) => playerLabel(m, 24)}
|
renderValue={(m) => playerLabel(m, 24)}
|
||||||
onPick={(m) => update(idx, { user_id: m.id })}
|
onPick={(m) => update(row.id, { user_id: m.id })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{spin[idx] != null ? (
|
{spin[row.id] != null ? (
|
||||||
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div>
|
<div className="faction-spin" style={{ flex: 1 }}>{spin[row.id]}</div>
|
||||||
) : (
|
) : (
|
||||||
<PickerSelect
|
<PickerSelect
|
||||||
selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null}
|
selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null}
|
||||||
options={(factions ?? []).filter((f) => !takenFactionIds(idx).includes(f.id))}
|
options={(factions ?? []).filter((f) => !takenFactionIds(row.id).includes(f.id))}
|
||||||
placeholder="— фракция —"
|
placeholder="— фракция —"
|
||||||
renderOption={(f) => f.name_ru}
|
renderOption={(f) => f.name_ru}
|
||||||
onPick={(f) => update(idx, { faction_id: f.id, was_random: false })}
|
onPick={(f) => update(row.id, { faction_id: f.id, was_random: false })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
onClick={() => randomizeRow(idx)}
|
onClick={() => randomizeRow(row.id)}
|
||||||
disabled={spin[idx] != null}
|
disabled={spin[row.id] != null}
|
||||||
title="Случайная фракция"
|
title="Случайная фракция"
|
||||||
>
|
>
|
||||||
<Dices size={18} />
|
<Dices size={18} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ApiError } from "../api/client";
|
|||||||
import { CreateGroupForm } from "../components/CreateGroupForm";
|
import { CreateGroupForm } from "../components/CreateGroupForm";
|
||||||
import { GroupInvitations } from "../components/GroupInvitations";
|
import { GroupInvitations } from "../components/GroupInvitations";
|
||||||
import { GroupSwitcher } from "../components/GroupSwitcher";
|
import { GroupSwitcher } from "../components/GroupSwitcher";
|
||||||
|
import { InProgressMatches } from "../components/InProgressMatches";
|
||||||
import { Leaderboard } from "../components/Leaderboard";
|
import { Leaderboard } from "../components/Leaderboard";
|
||||||
import { MatchListView } from "../components/MatchList";
|
import { MatchListView } from "../components/MatchList";
|
||||||
import { PlayerSuggestions } from "../components/PlayerSuggestions";
|
import { PlayerSuggestions } from "../components/PlayerSuggestions";
|
||||||
@@ -83,6 +84,14 @@ export function GroupPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Идущие партии выносим тем же блоком, что и на главной; из списка «Партии»
|
||||||
|
// их исключаем, иначе одна партия показалась бы на странице дважды.
|
||||||
|
const allMatches = matches?.items ?? [];
|
||||||
|
const inProgress = allMatches
|
||||||
|
.filter((m) => m.status === "in_progress")
|
||||||
|
.map((m) => ({ ...m, group_id: groupId, group_name: group?.name ?? "" }));
|
||||||
|
const finishedMatches = allMatches.filter((m) => m.status !== "in_progress");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -152,8 +161,11 @@ export function GroupPage() {
|
|||||||
|
|
||||||
<GroupSwitcher />
|
<GroupSwitcher />
|
||||||
|
|
||||||
|
{/* Название группы не показываем: оно уже есть в заголовке страницы. */}
|
||||||
|
<InProgressMatches items={inProgress} showGroupName={false} />
|
||||||
|
|
||||||
<h3 style={{ marginTop: 8 }}>Партии</h3>
|
<h3 style={{ marginTop: 8 }}>Партии</h3>
|
||||||
{matches && <MatchListView items={matches.items} />}
|
{matches && <MatchListView items={finishedMatches} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
@@ -9,23 +9,34 @@ import { PlaceEditor } from "../components/PlaceEditor";
|
|||||||
import { PlayerLink } from "../components/PlayerLink";
|
import { PlayerLink } from "../components/PlayerLink";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import { formatDate, formatDuration, formatTime } from "../domain/format";
|
import { formatDate, formatDuration, formatTime } from "../domain/format";
|
||||||
|
import type { MatchFinishDraftData } from "../domain/types";
|
||||||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { useMe } from "../hooks/auth";
|
||||||
import {
|
import {
|
||||||
useDeleteMatch,
|
useDeleteMatch,
|
||||||
useDeleteMatchAttachment,
|
useDeleteMatchAttachment,
|
||||||
useFinishMatch,
|
useFinishMatch,
|
||||||
useMatch,
|
useMatch,
|
||||||
|
useSaveFinishDraft,
|
||||||
|
useUpdateMatch,
|
||||||
useUploadMatchAttachment,
|
useUploadMatchAttachment,
|
||||||
} from "../hooks/matches";
|
} from "../hooks/matches";
|
||||||
|
import { useGroupFactions } from "../hooks/reference";
|
||||||
|
|
||||||
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
||||||
|
|
||||||
export function MatchDetailPage() {
|
export function MatchDetailPage() {
|
||||||
const { matchId } = useParams();
|
const { matchId } = useParams();
|
||||||
const id = matchId ? Number(matchId) : null;
|
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||||||
|
const parsed = matchId ? Number(matchId) : NaN;
|
||||||
|
const id = Number.isInteger(parsed) ? parsed : null;
|
||||||
const { data: match, isLoading, refetch } = useMatch(id);
|
const { data: match, isLoading, refetch } = useMatch(id);
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
|
||||||
const finish = useFinishMatch();
|
const finish = useFinishMatch();
|
||||||
|
const saveDraft = useSaveFinishDraft(id);
|
||||||
|
const updateMatch = useUpdateMatch();
|
||||||
const del = useDeleteMatch();
|
const del = useDeleteMatch();
|
||||||
const uploadAtt = useUploadMatchAttachment(id ?? 0);
|
const uploadAtt = useUploadMatchAttachment(id ?? 0);
|
||||||
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
|
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
|
||||||
@@ -40,6 +51,53 @@ export function MatchDetailPage() {
|
|||||||
const [overall, setOverall] = useState("");
|
const [overall, setOverall] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
// Правка завершённой партии: тот же редактор мест, что и при завершении.
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editFactions, setEditFactions] = useState<Record<number, number>>({});
|
||||||
|
|
||||||
|
// ─── Совместное заполнение формы ───
|
||||||
|
// Отложенная запись черновика: он меняется на каждое движение тайла, слать
|
||||||
|
// каждое было бы перебором. Актуальное состояние держим в ref — таймер иначе
|
||||||
|
// отправил бы то, что было на момент его постановки.
|
||||||
|
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const draftPayload = useRef<MatchFinishDraftData | null>(null);
|
||||||
|
const lastLocalEdit = useRef(0);
|
||||||
|
const appliedDraftAt = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const sendDraft = () => {
|
||||||
|
draftTimer.current = null;
|
||||||
|
if (draftPayload.current && id) saveDraft.mutate(draftPayload.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueDraft = (payload: MatchFinishDraftData) => {
|
||||||
|
draftPayload.current = payload;
|
||||||
|
lastLocalEdit.current = Date.now();
|
||||||
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||||
|
draftTimer.current = setTimeout(sendDraft, 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Чужой черновик применяем, только если человек сейчас ничего не двигает:
|
||||||
|
// иначе правка соседа перетёрла бы тайл прямо под рукой.
|
||||||
|
const incoming = match?.finish_draft;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!incoming || match?.status !== "in_progress") return;
|
||||||
|
if (incoming.updated_by === me?.id) return;
|
||||||
|
if (incoming.updated_at === appliedDraftAt.current) return;
|
||||||
|
if (Date.now() - lastLocalEdit.current < 1500) return;
|
||||||
|
setBlocks(incoming.data.blocks);
|
||||||
|
setElim(incoming.data.eliminated);
|
||||||
|
setComments(incoming.data.comments);
|
||||||
|
setWinReason((incoming.data.win_reason ?? "objectives") as WinReason);
|
||||||
|
setOverall(incoming.data.overall_comment ?? "");
|
||||||
|
appliedDraftAt.current = incoming.updated_at;
|
||||||
|
}, [incoming, match?.status, me?.id]);
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
if (!match) return <div className="muted">Партия не найдена.</div>;
|
||||||
@@ -56,9 +114,27 @@ export function MatchDetailPage() {
|
|||||||
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
|
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
|
||||||
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
|
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
|
||||||
|
|
||||||
|
// Снимок формы для общего черновика: то, что увидят остальные заполняющие.
|
||||||
|
const draftOf = (patch: Partial<MatchFinishDraftData> = {}): MatchFinishDraftData => ({
|
||||||
|
blocks: finishBlocks,
|
||||||
|
eliminated: elim,
|
||||||
|
comments: Object.fromEntries(
|
||||||
|
Object.entries(finishComments).map(([uid, text]) => [String(uid), text]),
|
||||||
|
),
|
||||||
|
win_reason: winReason,
|
||||||
|
overall_comment: overall.trim() || null,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
const submitFinish = async () => {
|
const submitFinish = async () => {
|
||||||
if (!id || !match) return;
|
if (!id || !match) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
|
||||||
|
// но не в черновик, и второй участник увидел бы не то, что записалось.
|
||||||
|
if (draftTimer.current) {
|
||||||
|
clearTimeout(draftTimer.current);
|
||||||
|
sendDraft();
|
||||||
|
}
|
||||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
||||||
let place = 1;
|
let place = 1;
|
||||||
const survivors = finishBlocks.flatMap((ids) => {
|
const survivors = finishBlocks.flatMap((ids) => {
|
||||||
@@ -100,12 +176,85 @@ export function MatchDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Раскладка для правки восстанавливается из сохранённых результатов: одинаковое
|
||||||
|
// место = ничья, выбывшие уходят отдельным списком.
|
||||||
|
const startEditing = () => {
|
||||||
|
const byPlace = new Map<number, number[]>();
|
||||||
|
for (const p of match.participants.filter((x) => !x.eliminated)) {
|
||||||
|
const key = p.place ?? 99;
|
||||||
|
byPlace.set(key, [...(byPlace.get(key) ?? []), p.user_id]);
|
||||||
|
}
|
||||||
|
setBlocks([...byPlace.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids));
|
||||||
|
setElim(match.participants.filter((p) => p.eliminated).map((p) => p.user_id));
|
||||||
|
setComments(
|
||||||
|
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
|
||||||
|
);
|
||||||
|
setWinReason((match.win_reason ?? "objectives") as WinReason);
|
||||||
|
setOverall(match.overall_comment ?? "");
|
||||||
|
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
|
||||||
|
setError(null);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setEditing(false);
|
||||||
|
setBlocks(null);
|
||||||
|
setComments(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitEdit = async () => {
|
||||||
|
if (!id || !match) return;
|
||||||
|
setError(null);
|
||||||
|
const wasRandom = Object.fromEntries(
|
||||||
|
match.participants.map((p) => [p.user_id, p.was_random]),
|
||||||
|
);
|
||||||
|
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
||||||
|
const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({
|
||||||
|
user_id: uid,
|
||||||
|
faction_id: editFactions[uid],
|
||||||
|
place,
|
||||||
|
eliminated,
|
||||||
|
was_random: wasRandom[uid] ?? false,
|
||||||
|
comment: commentOf(uid),
|
||||||
|
});
|
||||||
|
let place = 1;
|
||||||
|
const survivors = finishBlocks.flatMap((ids) => {
|
||||||
|
const rows = ids.map((uid) => rowOf(uid, place, false));
|
||||||
|
place += ids.length; // competition ranking: ничья съедает следующие места
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await updateMatch.mutateAsync({
|
||||||
|
matchId: id,
|
||||||
|
body: {
|
||||||
|
participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))],
|
||||||
|
win_reason: winReason,
|
||||||
|
overall_comment: overall.trim() || null,
|
||||||
|
expected_version: match.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.show("Партия обновлена");
|
||||||
|
cancelEditing();
|
||||||
|
} catch (e) {
|
||||||
|
if (isStale(e)) {
|
||||||
|
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||||||
|
refetch();
|
||||||
|
cancelEditing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!id || !match) return;
|
if (!id || !match) return;
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync({ matchId: id, expectedVersion: match.version });
|
await del.mutateAsync({ matchId: id, expectedVersion: match.version });
|
||||||
toast.show(inProgress ? "Партия отменена" : "Партия удалена");
|
toast.show(inProgress ? "Партия отменена" : "Партия удалена");
|
||||||
navigate(-1);
|
// Не navigate(-1): партию часто открывают по прямой ссылке, и «назад»
|
||||||
|
// уводит из приложения вместо возврата к группе.
|
||||||
|
navigate("/group", { replace: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isStale(e)) {
|
if (isStale(e)) {
|
||||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||||||
@@ -120,6 +269,15 @@ export function MatchDetailPage() {
|
|||||||
(a, b) => (a.place ?? 99) - (b.place ?? 99),
|
(a, b) => (a.place ?? 99) - (b.place ?? 99),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// К доступным группе фракциям добавляем те, что уже стоят в партии: дополнение
|
||||||
|
// могли отключить после игры, но менять её фракцию это мешать не должно.
|
||||||
|
const factionOptions = [
|
||||||
|
...(groupFactions ?? []),
|
||||||
|
...match.participants
|
||||||
|
.filter((p) => !(groupFactions ?? []).some((f) => f.id === p.faction_id))
|
||||||
|
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -143,11 +301,104 @@ export function MatchDetailPage() {
|
|||||||
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
|
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Завершённая партия — результаты */}
|
{/* Завершённая партия — правка результатов */}
|
||||||
{!inProgress && (
|
{!inProgress && editing && (
|
||||||
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Результаты</h3>
|
<h3>Правка результатов</h3>
|
||||||
|
<p className="muted small">
|
||||||
|
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||||
|
игрока, чтобы разделить место (ничья).
|
||||||
|
</p>
|
||||||
|
<PlaceEditor
|
||||||
|
players={match.participants}
|
||||||
|
blocks={finishBlocks}
|
||||||
|
eliminated={elim}
|
||||||
|
comments={finishComments}
|
||||||
|
onChange={(b, e) => {
|
||||||
|
setBlocks(b);
|
||||||
|
setElim(e);
|
||||||
|
}}
|
||||||
|
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Фракции</h3>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
|
{match.participants.map((p) => (
|
||||||
|
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||||||
|
<b style={{ flex: "0 0 40%" }}>{p.nickname}</b>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<PickerSelect
|
||||||
|
selected={factionOptions.find((f) => f.id === editFactions[p.user_id]) ?? null}
|
||||||
|
options={factionOptions.filter(
|
||||||
|
(f) =>
|
||||||
|
f.id === editFactions[p.user_id] ||
|
||||||
|
!Object.values(editFactions).includes(f.id),
|
||||||
|
)}
|
||||||
|
placeholder="— фракция —"
|
||||||
|
renderOption={(f) => f.name_ru}
|
||||||
|
onPick={(f) => setEditFactions({ ...editFactions, [p.user_id]: f.id })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Причина победы</h3>
|
||||||
|
<PickerSelect
|
||||||
|
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
|
||||||
|
options={REASON_OPTIONS}
|
||||||
|
placeholder="— причина —"
|
||||||
|
renderOption={(o) => o.label}
|
||||||
|
onPick={(o) => setWinReason(o.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>О партии</h3>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
style={{ resize: "none" }}
|
||||||
|
placeholder="Комментарий о партии"
|
||||||
|
value={overall}
|
||||||
|
onChange={(e) => setOverall(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={submitEdit}
|
||||||
|
disabled={updateMatch.isPending}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={cancelEditing}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Завершённая партия — результаты */}
|
||||||
|
{!inProgress && !editing && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="row-between">
|
||||||
|
<h3 style={{ margin: 0 }}>Результаты</h3>
|
||||||
|
{canModify && (
|
||||||
|
<button className="btn btn-ghost small" onClick={startEditing}>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="stack" style={{ marginTop: 10 }}>
|
||||||
{sorted.map((p) => (
|
{sorted.map((p) => (
|
||||||
<div key={p.user_id}>
|
<div key={p.user_id}>
|
||||||
<PlayerLink userId={p.user_id} className="row" style={{ gap: 8 }}>
|
<PlayerLink userId={p.user_id} className="row" style={{ gap: 8 }}>
|
||||||
@@ -203,6 +454,13 @@ export function MatchDetailPage() {
|
|||||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||||
игрока, чтобы разделить место (ничья).
|
игрока, чтобы разделить место (ничья).
|
||||||
</p>
|
</p>
|
||||||
|
{match.finish_draft && match.finish_draft.updated_by !== me?.id && (
|
||||||
|
<p className="small" style={{ color: "var(--accent-2)" }}>
|
||||||
|
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
|
||||||
|
{" · "}
|
||||||
|
{formatTime(match.finish_draft.updated_at)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<PlaceEditor
|
<PlaceEditor
|
||||||
players={match.participants}
|
players={match.participants}
|
||||||
blocks={finishBlocks}
|
blocks={finishBlocks}
|
||||||
@@ -211,10 +469,19 @@ export function MatchDetailPage() {
|
|||||||
onChange={(b, e) => {
|
onChange={(b, e) => {
|
||||||
setBlocks(b);
|
setBlocks(b);
|
||||||
setElim(e);
|
setElim(e);
|
||||||
|
queueDraft(draftOf({ blocks: b, eliminated: e }));
|
||||||
|
}}
|
||||||
|
onComment={(uid, text) => {
|
||||||
|
const next = { ...finishComments, [uid]: text };
|
||||||
|
setComments(next);
|
||||||
|
queueDraft(
|
||||||
|
draftOf({
|
||||||
|
comments: Object.fromEntries(
|
||||||
|
Object.entries(next).map(([k, v]) => [String(k), v]),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onComment={(uid, text) =>
|
|
||||||
setComments({ ...finishComments, [uid]: text })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,7 +492,10 @@ export function MatchDetailPage() {
|
|||||||
options={REASON_OPTIONS}
|
options={REASON_OPTIONS}
|
||||||
placeholder="— причина —"
|
placeholder="— причина —"
|
||||||
renderOption={(o) => o.label}
|
renderOption={(o) => o.label}
|
||||||
onPick={(o) => setWinReason(o.id)}
|
onPick={(o) => {
|
||||||
|
setWinReason(o.id);
|
||||||
|
queueDraft(draftOf({ win_reason: o.id }));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -236,7 +506,10 @@ export function MatchDetailPage() {
|
|||||||
style={{ resize: "none" }}
|
style={{ resize: "none" }}
|
||||||
placeholder="Комментарий о партии"
|
placeholder="Комментарий о партии"
|
||||||
value={overall}
|
value={overall}
|
||||||
onChange={(e) => setOverall(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setOverall(e.target.value);
|
||||||
|
queueDraft(draftOf({ overall_comment: e.target.value.trim() || null }));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<MatchMedia
|
<MatchMedia
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { useParams } from "react-router-dom";
|
|||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
|
import { MatchHistory } from "../components/MatchHistory";
|
||||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
import { useInviteMember } from "../hooks/groups";
|
import { useInviteMember } from "../hooks/groups";
|
||||||
import { usePublicProfile } from "../hooks/stats";
|
import { usePublicProfile } from "../hooks/stats";
|
||||||
|
import { useUserMatches } from "../hooks/users";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от
|
* Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от
|
||||||
@@ -19,6 +21,12 @@ export function PublicProfilePage() {
|
|||||||
const id = Number(userId);
|
const id = Number(userId);
|
||||||
const { data, isLoading, isError } = usePublicProfile(id);
|
const { data, isLoading, isError } = usePublicProfile(id);
|
||||||
const { data: me } = useMe();
|
const { data: me } = useMe();
|
||||||
|
const {
|
||||||
|
data: history,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useUserMatches(Number.isFinite(id) ? id : null);
|
||||||
const invite = useInviteMember();
|
const invite = useInviteMember();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [pickOpen, setPickOpen] = useState(false);
|
const [pickOpen, setPickOpen] = useState(false);
|
||||||
@@ -26,6 +34,7 @@ export function PublicProfilePage() {
|
|||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
if (isError || !data) return <div className="muted">Профиль не найден.</div>;
|
if (isError || !data) return <div className="muted">Профиль не найден.</div>;
|
||||||
|
|
||||||
|
const historyItems = history?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const groups = me?.groups ?? [];
|
const groups = me?.groups ?? [];
|
||||||
const isSelf = me?.id === data.user_id;
|
const isSelf = me?.id === data.user_id;
|
||||||
|
|
||||||
@@ -46,6 +55,7 @@ export function PublicProfilePage() {
|
|||||||
<Avatar url={data.avatar_url} nickname={data.nickname} size={64} />
|
<Avatar url={data.avatar_url} nickname={data.nickname} size={64} />
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<b style={{ fontSize: 18 }}>{data.nickname}</b>
|
<b style={{ fontSize: 18 }}>{data.nickname}</b>
|
||||||
|
<div className="muted small">Партий: {data.stats.overall.games}</div>
|
||||||
{data.bio ? (
|
{data.bio ? (
|
||||||
<div className="muted" style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>
|
<div className="muted" style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>
|
||||||
{data.bio}
|
{data.bio}
|
||||||
@@ -98,6 +108,21 @@ export function PublicProfilePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ProfileStatsCard stats={data.stats} title="Статистика" />
|
<ProfileStatsCard stats={data.stats} title="Статистика" />
|
||||||
|
|
||||||
|
{/* История в том виде, который выбрал владелец профиля; контролов здесь нет. */}
|
||||||
|
{historyItems.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h3>{history!.pages[0].mode === "best" ? "Лучшая партия" : "История игр"}</h3>
|
||||||
|
<MatchHistory
|
||||||
|
items={historyItems}
|
||||||
|
userId={data.user_id}
|
||||||
|
detail={history!.pages[0].detail}
|
||||||
|
hasMore={hasNextPage}
|
||||||
|
loadingMore={isFetchingNextPage}
|
||||||
|
onMore={() => fetchNextPage()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ export function AdminAccountsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleActive = async (id: number, isActive: boolean) => {
|
const toggleActive = async (id: number, isActive: boolean) => {
|
||||||
await update.mutateAsync({ userId: id, is_active: !isActive }).catch(() => {});
|
try {
|
||||||
|
await update.mutateAsync({ userId: id, is_active: !isActive });
|
||||||
toast.show("Сохранено");
|
toast.show("Сохранено");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,10 +11,17 @@ export function AdminFactionsPage() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [names, setNames] = useState<Record<number, string>>({});
|
const [names, setNames] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
// Подставляем серверные названия только там, где поле ещё не трогали: refetch после
|
||||||
|
// сохранения одной фракции иначе затирал бы несохранённый ввод в остальных.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (factions) {
|
if (!factions) return;
|
||||||
setNames(Object.fromEntries(factions.map((f) => [f.id, f.name_ru])));
|
setNames((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const f of factions) {
|
||||||
|
if (next[f.id] === undefined) next[f.id] = f.name_ru;
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, [factions]);
|
}, [factions]);
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
import { MatchMedia } from "../../components/MatchMedia";
|
import { MatchMedia } from "../../components/MatchMedia";
|
||||||
@@ -33,6 +34,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
const uploadAtt = useAdminUploadAttachment(matchId);
|
const uploadAtt = useAdminUploadAttachment(matchId);
|
||||||
const deleteAtt = useAdminDeleteAttachment(matchId);
|
const deleteAtt = useAdminDeleteAttachment(matchId);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [rows, setRows] = useState<Row[]>([]);
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
const [playedAt, setPlayedAt] = useState("");
|
const [playedAt, setPlayedAt] = useState("");
|
||||||
@@ -110,6 +112,20 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
|
|
||||||
{isLoading || !match ? (
|
{isLoading || !match ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
) : match.status === "in_progress" ? (
|
||||||
|
// Результаты идущей партии сервер не примет (409): места без завершения
|
||||||
|
// оставляли бы партию вне статистики. Объясняем это здесь, а не ошибкой.
|
||||||
|
<div className="card">
|
||||||
|
<h3>Партия ещё идёт</h3>
|
||||||
|
<p className="muted small">
|
||||||
|
Результаты незавершённой партии правкой не вносятся — их проставляют
|
||||||
|
при завершении, на странице самой партии. Там же участники заполняют
|
||||||
|
места совместно.
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-block" onClick={() => navigate(`/match/${matchId}`)}>
|
||||||
|
Открыть страницу партии
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ html, body, #root {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Высота каркаса — по видимой области (dvh): на мобильных 100% берётся от layout
|
||||||
|
viewport, который меняется при сворачивании адресной строки. Правило выше остаётся
|
||||||
|
фоллбэком для движков без dvh. */
|
||||||
|
#root { height: 100dvh; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -18,6 +23,7 @@ body {
|
|||||||
Arial, sans-serif;
|
Arial, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overscroll-behavior-y: none;
|
overscroll-behavior-y: none;
|
||||||
|
overflow: hidden; /* прокручивается только .app-main */
|
||||||
}
|
}
|
||||||
|
|
||||||
a { color: var(--accent-2); text-decoration: none; }
|
a { color: var(--accent-2); text-decoration: none; }
|
||||||
@@ -190,7 +196,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: calc(var(--bottombar-height) + 80px);
|
bottom: calc(var(--bottombar-height) + 16px);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -206,7 +212,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
/* ─── Уведомления ─────────────────────────────────────────────────────────── */
|
/* ─── Уведомления ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */
|
/* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */
|
||||||
.top-bar-left { display: flex; align-items: center; min-width: 0; }
|
.top-bar-left { display: flex; align-items: center; min-width: 0; flex: 0 1 auto; }
|
||||||
.top-bar-center {
|
.top-bar-center {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -215,7 +221,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
.top-bar-right { display: flex; align-items: center; }
|
.top-bar-right { display: flex; align-items: center; flex: none; }
|
||||||
.top-bar-home {
|
.top-bar-home {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -223,6 +229,11 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
|
/* На узких экранах длинное название иначе распирает шапку по горизонтали. */
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-bell-wrap { position: relative; }
|
.notif-bell-wrap { position: relative; }
|
||||||
@@ -258,7 +269,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
right: 0;
|
right: 0;
|
||||||
width: min(320px, 86vw);
|
width: min(320px, 86vw);
|
||||||
max-height: 70vh;
|
max-height: 70dvh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -437,7 +448,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
/* Всплывашки сверху экрана в момент прихода */
|
/* Всплывашки сверху экрана в момент прихода */
|
||||||
.toast-top-wrap {
|
.toast-top-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(var(--safe-top) + 10px);
|
top: 10px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: min(var(--app-max-w), 94vw);
|
width: min(var(--app-max-w), 94vw);
|
||||||
|
|||||||
@@ -2,42 +2,44 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
max-width: var(--app-max-w);
|
max-width: var(--app-max-w);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
min-height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Шапка вне прокручиваемой области по построению — sticky ей больше не нужен. */
|
||||||
.top-bar {
|
.top-bar {
|
||||||
position: sticky;
|
flex: none;
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: calc(var(--safe-top) + 12px) 16px 12px;
|
padding: 12px 16px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.top-bar .title { font-weight: 700; font-size: 17px; }
|
.top-bar .title { font-weight: 700; font-size: 17px; }
|
||||||
|
|
||||||
|
/* Единственная прокручиваемая область приложения. */
|
||||||
.app-main {
|
.app-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0; /* без этого flex-элемент не сжимается и прокрутка уходит в body */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
padding-bottom: calc(var(--bottombar-height) + var(--fab-height) + var(--safe-bottom) + 28px);
|
/* Подвал теперь в потоке — компенсировать нужно только выступающий купол FAB. */
|
||||||
|
padding-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Панель — обычный элемент флекс-колонки, не fixed: в мобильных браузерах со
|
||||||
|
скрывающейся адресной строкой fixed-подвал дёргается при прокрутке.
|
||||||
|
position: relative обязателен — на нём держится absolute-позиционирование .fab. */
|
||||||
.bottom-bar {
|
.bottom-bar {
|
||||||
position: fixed;
|
position: relative;
|
||||||
bottom: 0;
|
flex: none;
|
||||||
left: 50%;
|
height: var(--bottombar-height);
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 100%;
|
|
||||||
max-width: var(--app-max-w);
|
|
||||||
height: calc(var(--bottombar-height) + var(--safe-bottom));
|
|
||||||
padding-bottom: var(--safe-bottom);
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -61,13 +63,14 @@
|
|||||||
.bottom-bar .slot .name { color: var(--text); font-weight: 600; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.bottom-bar .slot .name { color: var(--text); font-weight: 600; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.bottom-bar .spacer { width: var(--fab-width); flex: 0 0 var(--fab-width); }
|
.bottom-bar .spacer { width: var(--fab-width); flex: 0 0 var(--fab-width); }
|
||||||
|
|
||||||
/* Круг создания партии: полукруг-купол, по центру разделяет слоты, смещён вниз
|
/* Круг создания партии: полукруг-купол, по центру разделяет слоты, базой стоит на
|
||||||
(база у нижней кромки панели) и поднят на передний план (z-index выше панели). */
|
нижнем крае экрана и поднят на передний план (z-index выше панели). Ширина вдвое
|
||||||
|
больше высоты (140x70) — над кромкой панели купол выступает на 6px. */
|
||||||
.fab {
|
.fab {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
bottom: calc(var(--safe-bottom) + 8px);
|
bottom: 0;
|
||||||
width: var(--fab-width);
|
width: var(--fab-width);
|
||||||
height: var(--fab-height);
|
height: var(--fab-height);
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
@@ -131,6 +134,7 @@
|
|||||||
|
|
||||||
/* Вкладки админки: горизонтальная карусель (не ломают вёрстку при переполнении) */
|
/* Вкладки админки: горизонтальная карусель (не ломают вёрстку при переполнении) */
|
||||||
.admin-tabs {
|
.admin-tabs {
|
||||||
|
flex: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@@ -218,7 +222,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 100%;
|
height: 100dvh;
|
||||||
width: min(300px, 84vw);
|
width: min(300px, 84vw);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
@@ -228,7 +232,6 @@
|
|||||||
z-index: 61;
|
z-index: 61;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-top: var(--safe-top);
|
|
||||||
}
|
}
|
||||||
.side-menu.open { transform: translateX(0); }
|
.side-menu.open { transform: translateX(0); }
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,10 @@
|
|||||||
/* Размеры */
|
/* Размеры */
|
||||||
--app-max-w: 480px;
|
--app-max-w: 480px;
|
||||||
--bottombar-height: 64px;
|
--bottombar-height: 64px;
|
||||||
--fab-width: 125px;
|
--fab-width: 140px;
|
||||||
--fab-height: 62.5px;
|
--fab-height: 70px;
|
||||||
--radius: 14px;
|
--radius: 14px;
|
||||||
--radius-sm: 10px;
|
--radius-sm: 10px;
|
||||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
|
||||||
--safe-top: env(safe-area-inset-top, 0px);
|
|
||||||
|
|
||||||
/* Брейкпоинты (для справки) */
|
/* Брейкпоинты (для справки) */
|
||||||
--bp-lg: 768px;
|
--bp-lg: 768px;
|
||||||
|
|||||||
@@ -82,10 +82,42 @@ switch ($appEnv) {
|
|||||||
Write-Host "node_modules not found. First: cd frontend; npm install" -ForegroundColor Red
|
Write-Host "node_modules not found. First: cd frontend; npm install" -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
# Schema first: a branch may add a migration, and uvicorn would otherwise start
|
||||||
|
# on the old schema and fail with 500 on the first hit of a new table.
|
||||||
|
# Start-Process, not "& python ... 2> file": alembic logs to stderr, and in
|
||||||
|
# Windows PowerShell 5.1 ANY stderr redirection of a native exe turns each line
|
||||||
|
# into a NativeCommandError - which is terminating under $ErrorActionPreference
|
||||||
|
# = "Stop" set at the top of this script, so the launcher died right here.
|
||||||
|
Write-Host "Applying migrations (alembic upgrade head)..." -ForegroundColor Green
|
||||||
|
$migOut = [System.IO.Path]::GetTempFileName()
|
||||||
|
$migErr = [System.IO.Path]::GetTempFileName()
|
||||||
|
$mig = Start-Process -FilePath $py `
|
||||||
|
-ArgumentList "-m", "alembic", "upgrade", "head" `
|
||||||
|
-WorkingDirectory $backend -NoNewWindow -Wait -PassThru `
|
||||||
|
-RedirectStandardOutput $migOut -RedirectStandardError $migErr
|
||||||
|
$migCode = $mig.ExitCode
|
||||||
|
$migText = ((Get-Content $migErr -Raw), (Get-Content $migOut -Raw)) -join "`n"
|
||||||
|
Remove-Item $migOut -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item $migErr -Force -ErrorAction SilentlyContinue
|
||||||
|
if ($migCode -ne 0) {
|
||||||
|
Write-Host $migText
|
||||||
|
if ($migText -match "locate revision") {
|
||||||
|
# The DB carries a migration this branch does not have (switched back from
|
||||||
|
# a feature branch). Extra tables do not bother the older code - go on.
|
||||||
|
Write-Host "DB is newer than this branch - migrations skipped." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host "Migrations failed - not starting. Fix the error above and retry." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "Starting backend (uvicorn --reload) and frontend (vite) in separate windows..." -ForegroundColor Green
|
Write-Host "Starting backend (uvicorn --reload) and frontend (vite) in separate windows..." -ForegroundColor Green
|
||||||
|
# --timeout-graceful-shutdown: an open tab keeps the SSE stream /api/events forever,
|
||||||
|
# and on reload the old process waits for ALL connections to close - with no limit
|
||||||
|
# the reload never finishes and the site hangs on loading with nothing in the log.
|
||||||
Start-Process powershell -ArgumentList @(
|
Start-Process powershell -ArgumentList @(
|
||||||
"-NoExit", "-Command",
|
"-NoExit", "-Command",
|
||||||
"Set-Location '$backend'; & '$py' -m uvicorn app.main:app --reload"
|
"Set-Location '$backend'; & '$py' -m uvicorn app.main:app --reload --timeout-graceful-shutdown 2"
|
||||||
)
|
)
|
||||||
Start-Process powershell -ArgumentList @(
|
Start-Process powershell -ArgumentList @(
|
||||||
"-NoExit", "-Command",
|
"-NoExit", "-Command",
|
||||||
|
|||||||
@@ -50,8 +50,28 @@ case "$app_env" in
|
|||||||
[ -x "$py" ] || py="$root/backend/.venv/Scripts/python.exe" # Git Bash на Windows
|
[ -x "$py" ] || py="$root/backend/.venv/Scripts/python.exe" # Git Bash на Windows
|
||||||
[ -x "$py" ] || { echo "Нет venv. Сначала: cd backend && python -m venv .venv && pip install -e '.[dev]'"; exit 1; }
|
[ -x "$py" ] || { echo "Нет venv. Сначала: cd backend && python -m venv .venv && pip install -e '.[dev]'"; exit 1; }
|
||||||
[ -d "$root/frontend/node_modules" ] || { echo "Нет node_modules. Сначала: cd frontend && npm install"; exit 1; }
|
[ -d "$root/frontend/node_modules" ] || { echo "Нет node_modules. Сначала: cd frontend && npm install"; exit 1; }
|
||||||
|
# Сначала схема: ветка могла принести миграцию, и без неё бэк поднимется на
|
||||||
|
# старой схеме и упадёт 500 на первом же обращении к новой таблице.
|
||||||
|
echo "Применяю миграции (alembic upgrade head)…"
|
||||||
|
mig_log="$( cd "$root/backend" && "$py" -m alembic upgrade head 2>&1 )" && mig_code=0 || mig_code=$?
|
||||||
|
if [ "$mig_code" -ne 0 ]; then
|
||||||
|
echo "$mig_log"
|
||||||
|
case "$mig_log" in
|
||||||
|
*"locate revision"*)
|
||||||
|
# В базе миграция, которой нет в этой ветке (вернулись с ветки задачи).
|
||||||
|
# Лишние таблицы старому коду не мешают — продолжаем запуск.
|
||||||
|
echo "База новее ветки — миграции пропущены." ;;
|
||||||
|
*)
|
||||||
|
echo "Миграции не применились — не запускаю. Исправьте ошибку выше и повторите."
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Запуск бэка (uvicorn --reload) и фронта (vite)… Ctrl+C — остановить всё."
|
echo "Запуск бэка (uvicorn --reload) и фронта (vite)… Ctrl+C — остановить всё."
|
||||||
( cd "$root/backend" && exec "$py" -m uvicorn app.main:app --reload ) &
|
# --timeout-graceful-shutdown: открытая вкладка держит SSE-поток /api/events вечно, а при
|
||||||
|
# reload старый процесс ждёт закрытия ВСЕХ соединений — без лимита перезапуск не
|
||||||
|
# завершается, и сайт висит на загрузке без единой ошибки в логе.
|
||||||
|
( cd "$root/backend" && exec "$py" -m uvicorn app.main:app --reload --timeout-graceful-shutdown 2 ) &
|
||||||
back=$!
|
back=$!
|
||||||
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
||||||
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
||||||
|
|||||||
Reference in New Issue
Block a user