v1.35 — полировка мелких ошибок вёрстки, оптимизация сетевого кода, унификация и доработка элементов интерфейса

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