Ревью кода (#8) #34

Merged
NotBigGhost merged 7 commits from issue-8-code-review into dev 2026-09-09 19:06:54 +03:00
37 changed files with 651 additions and 387 deletions
+2 -1
View File
@@ -10,6 +10,7 @@ from sqlmodel import Session
from app.auth.provider import ExternalIdentity from app.auth.provider import ExternalIdentity
from app.core import security from app.core import security
from app.core.security import client_ip
from app.core.errors import ForbiddenError from app.core.errors import ForbiddenError
from app.models import User from app.models import User
from app.services import audit_service, user_service from app.services import audit_service, user_service
@@ -29,7 +30,7 @@ def establish_session(
entity_type="user", entity_type="user",
entity_id=user.id, entity_id=user.id,
payload={"provider": provider}, payload={"provider": provider},
ip=request.client.host if request.client else None, ip=client_ip(request),
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
+9 -1
View File
@@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone
import bcrypt import bcrypt
import jwt import jwt
from fastapi import Response from fastapi import Request, Response
from app.core.config import settings from app.core.config import settings
@@ -118,3 +118,11 @@ def clear_user_session(response: Response) -> None:
def clear_admin_session(response: Response) -> None: def clear_admin_session(response: Response) -> None:
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value) response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
def client_ip(request: Request) -> str | None:
"""IP клиента для журнала аудита.
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант."""
return request.client.host if request.client else None
+23 -29
View File
@@ -4,7 +4,7 @@
""" """
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, timezone from datetime import date, datetime
from sqlalchemy import ( from sqlalchemy import (
JSON, JSON,
@@ -23,9 +23,9 @@ from sqlalchemy import (
) )
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from app.core.timeutil import utcnow
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── Справочники: дополнения и фракции ─────────────────────────────────────── # ─── Справочники: дополнения и фракции ───────────────────────────────────────
@@ -38,7 +38,7 @@ class Expansion(SQLModel, table=True):
name_ru: str = Field(sa_column=Column(String(64), nullable=False)) name_ru: str = Field(sa_column=Column(String(64), nullable=False))
is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
sort_order: int = Field(default=0, nullable=False) sort_order: int = Field(default=0, nullable=False)
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
class Faction(SQLModel, table=True): class Faction(SQLModel, table=True):
@@ -56,7 +56,7 @@ class Faction(SQLModel, table=True):
) )
) )
sort_order: int = Field(default=0, nullable=False) sort_order: int = Field(default=0, nullable=False)
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Пользователи и идентичности ───────────────────────────────────────────── # ─── Пользователи и идентичности ─────────────────────────────────────────────
@@ -125,10 +125,10 @@ class User(SQLModel, table=True):
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL # Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
# (выдача ачивок игрокам — следующий этап). # (выдача ачивок игрокам — следующий этап).
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True)) title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=_utcnow, default_factory=utcnow,
sa_column_kwargs={"onupdate": _utcnow}, sa_column_kwargs={"onupdate": utcnow},
nullable=False, nullable=False,
) )
@@ -150,7 +150,7 @@ class UserAchievement(SQLModel, table=True):
) )
) )
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False)) achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
earned_at: datetime = Field(default_factory=_utcnow, nullable=False) earned_at: datetime = Field(default_factory=utcnow, nullable=False)
class AuthIdentity(SQLModel, table=True): class AuthIdentity(SQLModel, table=True):
@@ -167,7 +167,7 @@ class AuthIdentity(SQLModel, table=True):
) )
provider: str = Field(sa_column=Column(String(16), nullable=False)) provider: str = Field(sa_column=Column(String(16), nullable=False))
external_id: str = Field(sa_column=Column(String(64), nullable=False)) external_id: str = Field(sa_column=Column(String(64), nullable=False))
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Группы и членство ─────────────────────────────────────────────────────── # ─── Группы и членство ───────────────────────────────────────────────────────
@@ -182,10 +182,10 @@ class Group(SQLModel, table=True):
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
) )
) )
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=_utcnow, default_factory=utcnow,
sa_column_kwargs={"onupdate": _utcnow}, sa_column_kwargs={"onupdate": utcnow},
nullable=False, nullable=False,
) )
@@ -209,7 +209,7 @@ class GroupMember(SQLModel, table=True):
) )
) )
role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member")) role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member"))
joined_at: datetime = Field(default_factory=_utcnow, nullable=False) joined_at: datetime = Field(default_factory=utcnow, nullable=False)
class GroupInvitation(SQLModel, table=True): class GroupInvitation(SQLModel, table=True):
@@ -237,7 +237,7 @@ class GroupInvitation(SQLModel, table=True):
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
) )
) )
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
class GroupExpansion(SQLModel, table=True): class GroupExpansion(SQLModel, table=True):
@@ -257,7 +257,7 @@ class GroupExpansion(SQLModel, table=True):
Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False
) )
) )
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Партии и участники ────────────────────────────────────────────────────── # ─── Партии и участники ──────────────────────────────────────────────────────
@@ -295,10 +295,10 @@ class Match(SQLModel, table=True):
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
) )
) )
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=_utcnow, default_factory=utcnow,
sa_column_kwargs={"onupdate": _utcnow}, sa_column_kwargs={"onupdate": utcnow},
nullable=False, nullable=False,
) )
@@ -333,7 +333,7 @@ class MatchParticipant(SQLModel, table=True):
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
comment: str | None = Field(sa_column=Column(Text, nullable=True)) comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
class MatchAttachment(SQLModel, table=True): class MatchAttachment(SQLModel, table=True):
@@ -359,7 +359,7 @@ class MatchAttachment(SQLModel, table=True):
storage_path: str = Field(sa_column=Column(String(255), nullable=False)) storage_path: str = Field(sa_column=Column(String(255), nullable=False))
mime_type: str = Field(sa_column=Column(String(64), nullable=False)) mime_type: str = Field(sa_column=Column(String(64), nullable=False))
size_bytes: int = Field(sa_column=Column(Integer, nullable=False)) size_bytes: int = Field(sa_column=Column(Integer, nullable=False))
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Уведомления ───────────────────────────────────────────────────────────── # ─── Уведомления ─────────────────────────────────────────────────────────────
@@ -387,7 +387,7 @@ class Notification(SQLModel, table=True):
body: str | None = Field(default=None, sa_column=Column(Text, nullable=True)) body: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
link: str | None = Field(default=None, sa_column=Column(String(255), nullable=True)) link: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
read_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True)) read_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False, index=True) created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
# ─── Журнал аудита ─────────────────────────────────────────────────────────── # ─── Журнал аудита ───────────────────────────────────────────────────────────
@@ -411,12 +411,6 @@ class AuditLog(SQLModel, table=True):
payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True)) payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True))
ip: str | None = Field(sa_column=Column(String(45), nullable=True)) ip: str | None = Field(sa_column=Column(String(45), nullable=True))
user_agent: str | None = Field(sa_column=Column(String(256), nullable=True)) user_agent: str | None = Field(sa_column=Column(String(256), nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
# Заготовка под будущие вложения (НЕ в v1-миграции, добавится отдельно):
# class Attachment(SQLModel, table=True):
# id, match_id (FK CASCADE), participant_id (FK NULL SET NULL),
# uploaded_by (FK), kind ('photo'|'video'), storage_path, mime_type,
# size_bytes, created_at
# Файлы — на томе /data/uploads; в БД только метаданные и относительный путь.
+19 -23
View File
@@ -7,12 +7,14 @@ from sqlmodel import Session
from app.auth.deps import get_current_admin from app.auth.deps import get_current_admin
from app.core import security from app.core import security
from app.core.errors import NotFoundError, ValidationError from app.core.security import client_ip
from app.core.errors import NotFoundError
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
from app.routers.matches import attachment_read, build_match_read from app.routers.matches import attachment_read, build_match_read
from app.schemas import api as s from app.schemas import api as s
from app.services.match_service import ParticipantInput
from app.services import ( from app.services import (
achievement_service, achievement_service,
admin_service, admin_service,
@@ -25,8 +27,6 @@ from app.services import (
) )
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ _ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ
from app.services.match_service import ParticipantInput
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -48,7 +48,7 @@ def admin_login(
action="login", action="login",
entity_type="admin", entity_type="admin",
entity_id=admin.id, entity_id=admin.id,
ip=request.client.host if request.client else None, ip=client_ip(request),
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
@@ -104,7 +104,7 @@ def update_user(
entity_type="user", entity_type="user",
entity_id=user_id, entity_id=user_id,
payload=body.model_dump(exclude_none=True), payload=body.model_dump(exclude_none=True),
ip=request.client.host if request.client else None, ip=client_ip(request),
) )
session.commit() session.commit()
return s.AdminUserRead( return s.AdminUserRead(
@@ -114,7 +114,7 @@ def update_user(
is_active=u.is_active, is_active=u.is_active,
auth_provider=u.auth_provider, auth_provider=u.auth_provider,
telegram_id=u.telegram_id, telegram_id=u.telegram_id,
created_at=u.created_at.isoformat(), created_at=iso_utc(u.created_at),
) )
@@ -148,7 +148,7 @@ def delete_group(
admin_service.delete_group(session, group_id) admin_service.delete_group(session, group_id)
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id, session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id,
ip=request.client.host if request.client else None, ip=client_ip(request),
) )
session.commit() session.commit()
return s.OkResponse() return s.OkResponse()
@@ -239,7 +239,7 @@ def update_match(
action="update", action="update",
entity_type="match", entity_type="match",
entity_id=match.id, entity_id=match.id,
ip=request.client.host if request.client else None, ip=client_ip(request),
) )
session.commit() session.commit()
notify.match_changed(session, match) notify.match_changed(session, match)
@@ -277,7 +277,7 @@ def rename_faction(
entity_type="faction", entity_type="faction",
entity_id=faction_id, entity_id=faction_id,
payload={"name_ru": f.name_ru}, payload={"name_ru": f.name_ru},
ip=request.client.host if request.client else None, ip=client_ip(request),
) )
session.commit() session.commit()
return s.FactionRead( return s.FactionRead(
@@ -296,7 +296,7 @@ def delete_match(
admin_service.delete_match(session, match_id) admin_service.delete_match(session, match_id)
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id, session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
ip=request.client.host if request.client else None, ip=client_ip(request),
) )
session.commit() session.commit()
notify.match_removed(session, match_id, group_id) notify.match_removed(session, match_id, group_id)
@@ -326,12 +326,9 @@ def admin_add_attachment(
admin: User = Depends(get_current_admin), admin: User = Depends(get_current_admin),
) -> s.AttachmentRead: ) -> s.AttachmentRead:
match = match_service.get_match(session, match_id) match = match_service.get_match(session, match_id)
content = file.file.read(_ATTACHMENT_MAX_BYTES + 1) content, ext = user_service.read_capped_image(
if len(content) > _ATTACHMENT_MAX_BYTES: file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)."
raise ValidationError("Файл слишком большой (макс. 10 МБ).") )
ext = user_service.sniff_image_ext(content)
if ext is None:
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
att = attachment_service.add_photo( att = attachment_service.add_photo(
session, match, admin, content, ext, user_service.avatar_media_type(ext) session, match, admin, content, ext, user_service.avatar_media_type(ext)
) )
@@ -389,7 +386,7 @@ def create_achievement(
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="create", entity_type="achievement", session, actor_id=admin.id, action="create", entity_type="achievement",
payload={"slug": ach["slug"], "name": ach["name"]}, payload={"slug": ach["slug"], "name": ach["name"]},
ip=request.client.host if request.client else None, ip=client_ip(request),
) )
session.commit() session.commit()
return ach return ach
@@ -408,7 +405,7 @@ def update_achievement(
) )
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="update", entity_type="achievement", session, actor_id=admin.id, action="update", entity_type="achievement",
payload={"slug": slug}, ip=request.client.host if request.client else None, payload={"slug": slug}, ip=client_ip(request),
) )
session.commit() session.commit()
return ach return ach
@@ -420,10 +417,9 @@ def upload_achievement_icon(
file: UploadFile = File(...), file: UploadFile = File(...),
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> dict: ) -> dict:
content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1) content, ext = user_service.read_capped_image(
if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES: file, _ACHIEVEMENT_ICON_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
raise ValidationError("Файл слишком большой (макс. 2 МБ).") )
ext = achievement_service.validate_icon(content)
return achievement_service.set_icon(slug, content, ext) return achievement_service.set_icon(slug, content, ext)
@@ -437,7 +433,7 @@ def delete_achievement(
achievement_service.delete(slug) achievement_service.delete(slug)
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="achievement", session, actor_id=admin.id, action="delete", entity_type="achievement",
payload={"slug": slug}, ip=request.client.host if request.client else None, payload={"slug": slug}, ip=client_ip(request),
) )
session.commit() session.commit()
return s.OkResponse() return s.OkResponse()
+2 -1
View File
@@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, Request
from sqlmodel import Session, select from sqlmodel import Session, select
from app.auth.deps import get_current_admin from app.auth.deps import get_current_admin
from app.core.security import client_ip
from app.core.errors import NotFoundError, ValidationError from app.core.errors import NotFoundError, ValidationError
from app.db.session import get_session from app.db.session import get_session
from app.models import Group, Match, MatchParticipant, User from app.models import Group, Match, MatchParticipant, User
@@ -63,7 +64,7 @@ def delete_user_hard(
entity_type="user", entity_type="user",
entity_id=user_id, entity_id=user_id,
payload={"hard": True, "nickname": nickname}, payload={"hard": True, "nickname": nickname},
ip=request.client.host if request.client else None, ip=client_ip(request),
) )
session.commit() session.commit()
return s.OkResponse() return s.OkResponse()
+3 -2
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user from app.auth.deps import get_current_user
from app.core.security import client_ip
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
@@ -61,7 +62,7 @@ def create_group(
entity_type="group", entity_type="group",
entity_id=group.id, entity_id=group.id,
payload={"name": group.name}, payload={"name": group.name},
ip=request.client.host if request.client else None, ip=client_ip(request),
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
@@ -157,7 +158,7 @@ def invite_member(
entity_type="group_invitation", entity_type="group_invitation",
entity_id=group_id, entity_id=group_id,
payload={"invited_user_id": invited.id, "nickname": invited.nickname}, payload={"invited_user_id": invited.id, "nickname": invited.nickname},
ip=request.client.host if request.client else None, ip=client_ip(request),
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
+9 -12
View File
@@ -6,7 +6,8 @@ from fastapi.responses import FileResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user from app.auth.deps import get_current_user
from app.core.errors import ConflictError, NoGroupError, NotFoundError, ValidationError from app.core.security import client_ip
from app.core.errors import ConflictError, NoGroupError, NotFoundError
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import Match, MatchAttachment, User from app.models import Match, MatchAttachment, User
@@ -24,7 +25,6 @@ from app.services.match_service import FinishInput, ParticipantInput, RosterInpu
router = APIRouter(prefix="/matches", tags=["matches"]) router = APIRouter(prefix="/matches", tags=["matches"])
_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead: def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
@@ -119,7 +119,7 @@ def start_match(
entity_type="match", entity_type="match",
entity_id=match.id, entity_id=match.id,
payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"}, payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"},
ip=request.client.host if request.client else None, ip=client_ip(request),
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
@@ -165,7 +165,7 @@ def finish_match(
entity_type="match", entity_type="match",
entity_id=match.id, entity_id=match.id,
payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes}, payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes},
ip=request.client.host if request.client else None, ip=client_ip(request),
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
@@ -225,7 +225,7 @@ def update_match(
action="update", action="update",
entity_type="match", entity_type="match",
entity_id=match.id, entity_id=match.id,
ip=request.client.host if request.client else None, ip=client_ip(request),
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
@@ -250,12 +250,9 @@ def add_attachment(
) -> s.AttachmentRead: ) -> s.AttachmentRead:
match = match_service.get_match(session, match_id) match = match_service.get_match(session, match_id)
_assert_can_attach(session, match, user) _assert_can_attach(session, match, user)
content = file.file.read(_ATTACHMENT_MAX_BYTES + 1) content, ext = user_service.read_capped_image(
if len(content) > _ATTACHMENT_MAX_BYTES: file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)."
raise ValidationError("Файл слишком большой (макс. 10 МБ).") )
ext = user_service.sniff_image_ext(content)
if ext is None:
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
att = attachment_service.add_photo( att = attachment_service.add_photo(
session, match, user, content, ext, user_service.avatar_media_type(ext) session, match, user, content, ext, user_service.avatar_media_type(ext)
) )
@@ -315,7 +312,7 @@ def delete_match(
entity_type="match", entity_type="match",
entity_id=match_id_val, entity_id=match_id_val,
payload={"group_id": group_id_val}, payload={"group_id": group_id_val},
ip=request.client.host if request.client else None, ip=client_ip(request),
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
+7 -9
View File
@@ -6,7 +6,8 @@ from fastapi.responses import FileResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user from app.auth.deps import get_current_user
from app.core.errors import NotFoundError, ValidationError from app.core.security import client_ip
from app.core.errors import NotFoundError
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
from app.schemas import api as s from app.schemas import api as s
@@ -61,7 +62,7 @@ def update_me(
entity_type="user", entity_type="user",
entity_id=user.id, entity_id=user.id,
payload={"nickname": user.nickname}, payload={"nickname": user.nickname},
ip=request.client.host if request.client else None, ip=client_ip(request),
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
@@ -108,7 +109,7 @@ def update_my_profile(
entity_type="user", entity_type="user",
entity_id=user.id, entity_id=user.id,
payload={key: True for key in changed}, payload={key: True for key in changed},
ip=request.client.host if request.client else None, ip=client_ip(request),
) )
session.commit() session.commit()
return build_me(session, user) return build_me(session, user)
@@ -120,12 +121,9 @@ def upload_my_avatar(
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.MeRead: ) -> s.MeRead:
content = file.file.read(_AVATAR_MAX_BYTES + 1) content, ext = user_service.read_capped_image(
if len(content) > _AVATAR_MAX_BYTES: file, _AVATAR_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)."
raise ValidationError("Файл слишком большой (макс. 2 МБ).") )
ext = user_service.sniff_image_ext(content)
if ext is None:
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
user_service.set_avatar(session, user, content, ext) user_service.set_avatar(session, user, content, ext)
return build_me(session, user) return build_me(session, user)
@@ -42,6 +42,10 @@ def _root() -> Path:
return Path(settings.achievements_dir) return Path(settings.achievements_dir)
# Формат slug — то, что выдаёт _slugify: только латиница, цифры и дефис.
_SLUG_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,63}")
def _slugify(name: str) -> str: def _slugify(name: str) -> str:
text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower()) text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower())
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-") slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
@@ -49,6 +53,11 @@ def _slugify(name: str) -> str:
def _dir(slug: str) -> Path: def _dir(slug: str) -> Path:
"""Папка ачивки. Slug приходит из URL, поэтому формат проверяем здесь: без этого
`..` или `a/b` увели бы файловые операции (вплоть до rmtree в delete) за пределы
каталога ачивок."""
if not _SLUG_RE.fullmatch(slug or ""):
raise NotFoundError("Ачивка не найдена.")
return _root() / slug return _root() / slug
+10
View File
@@ -6,6 +6,7 @@ from typing import Any
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.errors import ( from app.core.errors import (
ConflictError,
InvalidCredentialsError, InvalidCredentialsError,
NicknameTakenError, NicknameTakenError,
NotFoundError, NotFoundError,
@@ -75,6 +76,10 @@ def delete_group(session: Session, group_id: int) -> None:
group = session.get(Group, group_id) group = session.get(Group, group_id)
if group is None: if group is None:
raise NotFoundError("Группа не найдена.") raise NotFoundError("Группа не найдена.")
# matches.group_id — ON DELETE RESTRICT, поэтому группу с партиями БД не отдаст.
# Проверяем сами, иначе IntegrityError уходит наружу голым 500 без AppError-конверта.
if session.exec(select(Match.id).where(Match.group_id == group_id)).first() is not None:
raise ConflictError("Нельзя удалить группу, в которой есть партии. Сначала удалите их.")
session.delete(group) session.delete(group)
session.commit() session.commit()
@@ -134,11 +139,16 @@ def rename_faction(session: Session, faction_id: int, name_ru: str) -> Faction:
def delete_match(session: Session, match_id: int) -> None: def delete_match(session: Session, match_id: int) -> None:
from app.services import attachment_service # избегаем цикла импорта
match = session.get(Match, match_id) match = session.get(Match, match_id)
if match is None: if match is None:
raise NotFoundError("Партия не найдена.") raise NotFoundError("Партия не найдена.")
session.delete(match) session.delete(match)
session.commit() session.commit()
# Как и в игроцком пути (match_service.delete_match): строки вложений уходят
# каскадом, а файлы с тома нужно убрать руками, иначе они остаются навсегда.
attachment_service.delete_match_files(match_id)
# ─── Журнал аудита ─────────────────────────────────────────────────────────── # ─── Журнал аудита ───────────────────────────────────────────────────────────
@@ -14,6 +14,7 @@ from app.core.errors import ConflictError, NotFoundError
from app.models import Match, MatchAttachment, User from app.models import Match, MatchAttachment, User
MAX_ATTACHMENTS = 10 MAX_ATTACHMENTS = 10
MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 # 10 МБ
_SUBDIR = "matches" _SUBDIR = "matches"
@@ -42,6 +43,8 @@ def file_path(att: MatchAttachment) -> Path:
def add_photo( def add_photo(
session: Session, match: Match, user: User, content: bytes, ext: str, mime: str session: Session, match: Match, user: User, content: bytes, ext: str, mime: str
) -> MatchAttachment: ) -> MatchAttachment:
from app.services import match_service # избегаем цикла импорта
if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type] if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type]
raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.") raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.")
att = MatchAttachment( att = MatchAttachment(
@@ -60,6 +63,7 @@ def add_photo(
abs_path.write_bytes(content) abs_path.write_bytes(content)
att.storage_path = rel att.storage_path = rel
session.add(att) session.add(att)
match_service.touch(session, match)
session.commit() session.commit()
session.refresh(att) session.refresh(att)
return att return att
@@ -73,6 +77,8 @@ def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachme
def delete(session: Session, match: Match, att_id: int) -> None: def delete(session: Session, match: Match, att_id: int) -> None:
from app.services import match_service # избегаем цикла импорта
att = get_for_match(session, match.id, att_id) # type: ignore[arg-type] att = get_for_match(session, match.id, att_id) # type: ignore[arg-type]
path = file_path(att) path = file_path(att)
if path.exists(): if path.exists():
@@ -81,6 +87,7 @@ def delete(session: Session, match: Match, att_id: int) -> None:
except OSError: except OSError:
pass pass
session.delete(att) session.delete(att)
match_service.touch(session, match)
session.commit() session.commit()
+7 -6
View File
@@ -5,6 +5,7 @@ GroupMember). Лимит участников — общий с membership_servi
""" """
from __future__ import annotations from __future__ import annotations
from sqlalchemy.orm import aliased
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.errors import ConflictError, NotFoundError, ValidationError from app.core.errors import ConflictError, NotFoundError, ValidationError
@@ -70,17 +71,17 @@ def create_invitation(
def list_for_user(session: Session, user_id: int) -> list[dict]: def list_for_user(session: Session, user_id: int) -> list[dict]:
inviter = aliased(User)
rows = session.exec( rows = session.exec(
select(GroupInvitation, Group.name).join(Group, Group.id == GroupInvitation.group_id) select(GroupInvitation, Group.name, inviter.nickname)
.join(Group, Group.id == GroupInvitation.group_id)
# LEFT JOIN: пригласивший мог быть удалён (invited_by_id → SET NULL).
.join(inviter, inviter.id == GroupInvitation.invited_by_id, isouter=True)
.where(GroupInvitation.user_id == user_id) .where(GroupInvitation.user_id == user_id)
.order_by(GroupInvitation.created_at.desc()) .order_by(GroupInvitation.created_at.desc())
).all() ).all()
out = [] out = []
for inv, gname in rows: for inv, gname, inviter_nick in rows:
inviter_nick = None
if inv.invited_by_id is not None:
inviter = session.get(User, inv.invited_by_id)
inviter_nick = inviter.nickname if inviter else None
out.append( out.append(
{ {
"id": inv.id, "id": inv.id,
+16 -9
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import random import random
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime, timezone from datetime import date
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -16,7 +16,7 @@ from app.core.errors import (
NotFoundError, NotFoundError,
ValidationError, ValidationError,
) )
from app.core.timeutil import app_today, iso_utc from app.core.timeutil import app_today, iso_utc, utcnow
from app.models import Faction, GroupMember, Match, MatchParticipant, User from app.models import Faction, GroupMember, Match, MatchParticipant, User
from app.services import group_service from app.services import group_service
@@ -56,10 +56,6 @@ class ParticipantInput:
comment: str | None = None comment: str | None = None
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def round_to_30(minutes: float) -> int: def round_to_30(minutes: float) -> int:
"""Округление длительности до получаса, минимум 30 минут.""" """Округление длительности до получаса, минимум 30 минут."""
return max(30, int(round(minutes / 30.0)) * 30) return max(30, int(round(minutes / 30.0)) * 30)
@@ -77,6 +73,17 @@ def match_version(match: Match) -> str:
return iso_utc(match.updated_at) return iso_utc(match.updated_at)
def touch(session: Session, match: Match) -> None:
"""Пометить партию изменённой (без commit).
Версия для оптимистичной блокировки — это updated_at, а onupdate у SQLAlchemy
срабатывает только при реальном UPDATE строки matches. Правки, меняющие лишь
связанные сущности (участники, вложения), строку не трогают, поэтому каждая
такая точка обязана позвать touch — иначе конкурентная запись пройдёт молча."""
match.updated_at = utcnow()
session.add(match)
def assert_version(match: Match, expected: str | None) -> None: def assert_version(match: Match, expected: str | None) -> None:
"""Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию).""" """Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию)."""
if expected is not None and expected != match_version(match): if expected is not None and expected != match_version(match):
@@ -188,7 +195,7 @@ def create_match(
session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster] session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster]
) )
now = _utcnow() now = utcnow()
match = Match( match = Match(
group_id=group_id, group_id=group_id,
status="in_progress", status="in_progress",
@@ -264,7 +271,7 @@ def finish_match(
p.faction_id = f.faction_id p.faction_id = f.faction_id
session.add(p) session.add(p)
now = _utcnow() now = utcnow()
match.finished_at = now match.finished_at = now
started = match.started_at started = match.started_at
if started is not None: if started is not None:
@@ -351,7 +358,7 @@ def update_match(
) )
match.player_count = len(participants) match.player_count = len(participants)
session.add(match) touch(session, match)
session.commit() session.commit()
session.refresh(match) session.refresh(match)
return match return match
+24 -23
View File
@@ -5,6 +5,7 @@ from sqlmodel import Session, select
from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
from app.models import Group, GroupMember, User from app.models import Group, GroupMember, User
from app.services import group_service
MAX_GROUP_SIZE = 10 MAX_GROUP_SIZE = 10
@@ -33,11 +34,7 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
if member_count >= MAX_GROUP_SIZE: if member_count >= MAX_GROUP_SIZE:
raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).") raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).")
existing = session.exec( existing = group_service.get_membership(session, group.id, user.id)
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.user_id == user.id
)
).first()
if existing is not None: if existing is not None:
raise ConflictError("Игрок уже в группе.") raise ConflictError("Игрок уже в группе.")
@@ -48,22 +45,26 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
return member, user return member, user
def remove_member(session: Session, group: Group, user_id: int) -> None: def _assert_not_last_owner(session: Session, group: Group, member: GroupMember, message: str) -> None:
member = session.exec( """Группа без владельца неисправима: назначить нового становится некому."""
select(GroupMember).where( if member.role != "owner":
GroupMember.group_id == group.id, GroupMember.user_id == user_id return
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: if member is None:
raise NotFoundError("Игрок не состоит в группе.") raise NotFoundError("Игрок не состоит в группе.")
if member.role == "owner": _assert_not_last_owner(
owners = session.exec( session, group, member, "Нельзя удалить последнего владельца группы."
select(GroupMember).where( )
GroupMember.group_id == group.id, GroupMember.role == "owner"
)
).all()
if len(owners) <= 1:
raise ForbiddenError("Нельзя удалить последнего владельца группы.")
# Сбросить активную группу у тех, для кого она была активной. # Сбросить активную группу у тех, для кого она была активной.
user = session.get(User, user_id) user = session.get(User, user_id)
@@ -78,13 +79,13 @@ def remove_member(session: Session, group: Group, user_id: int) -> None:
def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember: def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember:
if role not in ("owner", "member"): if role not in ("owner", "member"):
raise ValidationError("Недопустимая роль.") raise ValidationError("Недопустимая роль.")
member = session.exec( member = group_service.get_membership(session, group.id, user_id)
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.user_id == user_id
)
).first()
if member is None: if member is None:
raise NotFoundError("Игрок не состоит в группе.") raise NotFoundError("Игрок не состоит в группе.")
if role != "owner":
_assert_not_last_owner(
session, group, member, "Нельзя снять роль с последнего владельца группы."
)
member.role = role member.role = role
session.add(member) session.add(member)
session.commit() session.commit()
@@ -137,6 +137,9 @@ def mark_read(session: Session, user_id: int, ids: list[int] | None = None) -> i
session.add(row) session.add(row)
if rows: if rows:
session.commit() session.commit()
# Счётчик непрочитанных изменился — толкаем тот же сигнал, что и create_for,
# иначе вкладка на другом устройстве держит устаревший бейдж до перезагрузки.
notify.notifications_changed(user_id)
return len(rows) return len(rows)
+53 -46
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from sqlalchemy import text from sqlalchemy import func, text
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
@@ -235,12 +235,13 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
def group_stats(session: Session, group_id: int) -> dict: def group_stats(session: Session, group_id: int) -> dict:
board = leaderboard(session, group_id=group_id) board = leaderboard(session, group_id=group_id)
total_matches = session.exec( # Нужны только счётчик и дата последней партии — тянуть строки целиком незачем.
select(Match).where(Match.group_id == group_id, Match.status == "finished") games_count, last_played = session.exec(
).all() select(func.count(), func.max(Match.played_at)).where(
last_at = None Match.group_id == group_id, Match.status == "finished"
if total_matches: )
last_at = str(max(m.played_at for m in total_matches)) ).one()
last_at = str(last_played) if last_played else None
available_ids = group_service.available_faction_ids(session, group_id) available_ids = group_service.available_faction_ids(session, group_id)
faction_meta = [] faction_meta = []
@@ -290,7 +291,7 @@ def group_stats(session: Session, group_id: int) -> dict:
return { return {
"group_id": group_id, "group_id": group_id,
"total_matches": len(total_matches), "total_matches": games_count,
"last_match_at": last_at, "last_match_at": last_at,
"leaderboard": board["entries"], "leaderboard": board["entries"],
"provisional": board["provisional"], "provisional": board["provisional"],
@@ -300,26 +301,44 @@ def group_stats(session: Session, group_id: int) -> dict:
} }
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,
}
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]: def _match_items(session: Session, matches) -> list[dict]:
"""Элементы списка партий (общее для списка группы и истории игрока).""" """Элементы списка партий (общее для списка группы и истории игрока)."""
from app.services.match_service import participants_detail # избегаем цикла импорта by_match = _participants_by_match(session, [m.id for m in matches])
items = [] items = []
for m in matches: for m in matches:
parts = [] parts = by_match.get(m.id, [])
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,
}
)
items.append( items.append(
{ {
"id": m.id, "id": m.id,
@@ -339,7 +358,9 @@ def _match_items(session: Session, matches) -> list[dict]:
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict: def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all()) total = session.exec(
select(func.count()).select_from(Match).where(Match.group_id == group_id)
).one()
matches = session.exec( matches = session.exec(
select(Match) select(Match)
.where(Match.group_id == group_id) .where(Match.group_id == group_id)
@@ -385,13 +406,12 @@ def user_match_list(
} }
where = (MatchParticipant.user_id == user_id, Match.status == "finished") where = (MatchParticipant.user_id == user_id, Match.status == "finished")
total = len( total = session.exec(
session.exec( select(func.count())
select(Match.id) .select_from(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id) .join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(*where) .where(*where)
).all() ).one()
)
matches = session.exec( matches = session.exec(
select(Match) select(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id) .join(MatchParticipant, MatchParticipant.match_id == Match.id)
@@ -410,8 +430,6 @@ def user_match_list(
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]: def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху).""" """Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
from app.services.match_service import participants_detail # избегаем цикла импорта
group_ids = list( group_ids = list(
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all() session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
) )
@@ -423,21 +441,10 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
.where(Match.status == "in_progress", Match.group_id.in_(group_ids)) .where(Match.status == "in_progress", Match.group_id.in_(group_ids))
.order_by(Match.started_at.desc(), Match.id.desc()) .order_by(Match.started_at.desc(), Match.id.desc())
).all() ).all()
by_match = _participants_by_match(session, [m.id for m, _ in rows])
out = [] out = []
for m, gname in rows: for m, gname in rows:
parts = [ parts = by_match.get(m.id, [])
{
"user_id": u.id,
"nickname": u.nickname,
"faction_id": f.id,
"faction_name": f.name_ru,
"place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
}
for p, u, f in participants_detail(session, m.id) # type: ignore[arg-type]
]
out.append( out.append(
{ {
"id": m.id, "id": m.id,
+34 -11
View File
@@ -1,9 +1,8 @@
"""Пользователи: создание из внешней личности, ник, активная группа, профиль.""" """Пользователи: создание из внешней личности, ник, активная группа, профиль."""
from __future__ import annotations from __future__ import annotations
import os
import re import re
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -11,7 +10,8 @@ from sqlmodel import Session, select
from app.auth.provider import ExternalIdentity from app.auth.provider import ExternalIdentity
from app.core.config import settings from app.core.config import settings
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
from app.models import AuthIdentity, Faction, GroupMember, User from app.core.timeutil import utcnow
from app.models import AuthIdentity, Faction, User
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
_BIO_MAX = 500 _BIO_MAX = 500
@@ -134,7 +134,7 @@ def register_from_identity(
def update_nickname(session: Session, user: User, new_nickname: str) -> User: def update_nickname(session: Session, user: User, new_nickname: str) -> User:
new_nickname = (new_nickname or "").strip() new_nickname = (new_nickname or "").strip()
if not _NICK_RE.match(new_nickname): if not nickname_format_ok(new_nickname):
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
if not nickname_available(session, new_nickname, exclude_user_id=user.id): if not nickname_available(session, new_nickname, exclude_user_id=user.id):
raise NicknameTakenError() raise NicknameTakenError()
@@ -147,12 +147,9 @@ def update_nickname(session: Session, user: User, new_nickname: str) -> User:
def set_active_group(session: Session, user: User, group_id: int | None) -> User: def set_active_group(session: Session, user: User, group_id: int | None) -> User:
if group_id is not None: if group_id is not None:
member = session.exec( from app.services import group_service # избегаем цикла импорта
select(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id == user.id if group_service.get_membership(session, group_id, user.id) is None:
)
).first()
if member is None:
raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.") raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.")
user.active_group_id = group_id user.active_group_id = group_id
session.add(user) session.add(user)
@@ -170,7 +167,13 @@ def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime |
подтягивал новую картинку после смены (файл перезаписывается по тому же пути).""" подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
if not avatar_path: if not avatar_path:
return None return None
version = int(updated_at.timestamp()) if updated_at else 0 # В БД время наивное и хранится в UTC. .timestamp() у наивного значения считает
# его локальным, и версия разъезжалась с лидербордом, где то же поле считает SQL
# (strftime('%s') читает его как UTC) — один аватар качался браузером дважды.
version = 0
if updated_at is not None:
aware = updated_at if updated_at.tzinfo else updated_at.replace(tzinfo=timezone.utc)
version = int(aware.timestamp())
return f"/api/users/{user_id}/avatar?v={version}" return f"/api/users/{user_id}/avatar?v={version}"
@@ -218,6 +221,21 @@ def update_favorite_faction(session: Session, user: User, faction_id: int | None
return user return user
def read_capped_image(file, max_bytes: int, limit_message: str) -> tuple[bytes, str]:
"""Прочитать загруженный файл с ограничением размера и убедиться, что это картинка.
Читаем на байт больше лимита: так превышение видно, не загружая файл целиком.
Один хелпер на все загрузки (аватар, фото партии, иконка ачивки) — иначе
правка лимита или списка форматов расходится по четырём роутерам."""
content = file.file.read(max_bytes + 1)
if len(content) > max_bytes:
raise ValidationError(limit_message)
ext = sniff_image_ext(content)
if ext is None:
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
return content, ext
def sniff_image_ext(content: bytes) -> str | None: def sniff_image_ext(content: bytes) -> str | None:
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка.""" """Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
if content.startswith(b"\x89PNG\r\n\x1a\n"): if content.startswith(b"\x89PNG\r\n\x1a\n"):
@@ -253,6 +271,10 @@ def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}" rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
(Path(settings.upload_dir) / rel).write_bytes(content) (Path(settings.upload_dir) / rel).write_bytes(content)
user.avatar_path = rel user.avatar_path = rel
# Файл перезаписывается по тому же пути, поэтому при том же расширении avatar_path
# не меняется, UPDATE не эмитится и onupdate не срабатывает. Без явного бампа
# кэш-бастер остаётся прежним, и браузер час показывает старую картинку.
user.updated_at = utcnow()
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)
@@ -267,6 +289,7 @@ def clear_avatar(session: Session, user: User) -> User:
except OSError: except OSError:
pass pass
user.avatar_path = None user.avatar_path = None
user.updated_at = utcnow()
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)
+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) f"/api/admin/achievements/{slug}", headers=csrf_headers(client)
).status_code == 200 ).status_code == 200
assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json()) assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json())
def test_delete_rejects_traversal_slug(client: TestClient, make_admin, monkeypatch, tmp_path):
"""Slug из URL не должен уводить файловые операции за каталог ачивок.
Регрессия: `DELETE /api/admin/achievements/%2E%2E` снимал rmtree'ом родительскую
папку каталога (в проде это /data — БД, uploads и ачивки разом)."""
root = _use_tmp_achievements(monkeypatch, tmp_path / "achievements")
root.mkdir(parents=True, exist_ok=True)
sibling = tmp_path / "db.sqlite3"
sibling.write_bytes(b"data")
_admin(client, make_admin)
# Именно percent-кодированная форма: обычные точки httpx нормализует ещё до
# отправки, запрос уходит на /api/admin/ и до обработчика вовсе не доходит.
r = client.request(
"DELETE", "/api/admin/achievements/%2E%2E", headers=csrf_headers(client)
)
assert r.status_code == 404, r.text
assert r.json()["error"]["code"] == "NOT_FOUND" # ответ обработчика, а не промах роутинга
assert sibling.exists() and root.is_dir()
+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() detail = client.get(f"/api/admin/matches/{mid}").json()
ap = next(p for p in detail["participants"] if p["user_id"] == me["id"]) ap = next(p for p in detail["participants"] if p["user_id"] == me["id"])
assert ap["faction_name"] == "Орки WAAAGH" assert ap["faction_name"] == "Орки WAAAGH"
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
Регрессия: matches.group_id — ON DELETE RESTRICT, и голый session.delete ронял
IntegrityError наружу пятисоткой без конверта ошибки."""
me = login(client, "Owner")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
p2 = add_group_member(engine, gid, "Игрок2")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
create_finished_match(
client, gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": p2, "faction_id": fids[1], "place": 2},
],
)
_admin_login(client, make_admin)
r = client.delete(f"/api/admin/groups/{gid}", headers=csrf_headers(client))
assert r.status_code == 409, r.text
# Важен не только код ответа: группа и её партии должны пережить отказ.
assert any(g["id"] == gid for g in client.get("/api/admin/groups").json())
assert any(m["group_id"] == gid for m in client.get("/api/admin/matches").json())
def test_last_owner_cannot_demote_self(client: TestClient, engine):
"""Единственный владелец не может разжаловать сам себя.
Регрессия: change_role не проверял последнего владельца (в отличие от удаления),
и группа оставалась без владельца навсегда — назначить нового было некому."""
me = login(client, "Owner")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
add_group_member(engine, gid, "Игрок2")
r = client.patch(
f"/api/groups/{gid}/members/{me['id']}",
json={"role": "member"},
headers=csrf_headers(client),
)
assert r.status_code == 403, r.text
members = client.get(f"/api/groups/{gid}/members").json()
assert any(m["user_id"] == me["id"] and m["role"] == "owner" for m in members)
def test_ownership_transfer_still_works(client: TestClient, engine):
"""Обратная сторона защиты последнего владельца: передать роль по-прежнему можно."""
me = login(client, "Owner")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
p2 = add_group_member(engine, gid, "Игрок2")
promote = client.patch(
f"/api/groups/{gid}/members/{p2}", json={"role": "owner"}, headers=csrf_headers(client)
)
assert promote.status_code == 200, promote.text
# Владельцев теперь двое — прежний может сложить полномочия.
demote = client.patch(
f"/api/groups/{gid}/members/{me['id']}",
json={"role": "member"},
headers=csrf_headers(client),
)
assert demote.status_code == 200, demote.text
members = client.get(f"/api/groups/{gid}/members").json()
assert [m["user_id"] for m in members if m["role"] == "owner"] == [p2]
+20
View File
@@ -138,3 +138,23 @@ def test_non_member_cannot_view(client: TestClient, engine, monkeypatch, tmp_pat
login(client, "Чужак") # не состоит в группе login(client, "Чужак") # не состоит в группе
g = client.get(f"/api/matches/{mid}/attachments/{aid}") g = client.get(f"/api/matches/{mid}/attachments/{aid}")
assert g.status_code in (401, 403) assert g.status_code in (401, 403)
def test_attachment_upload_moves_version(client: TestClient, engine, monkeypatch, tmp_path):
"""Вложения видны в MatchRead, но строку matches не трогают.
Регрессия: из-за этого версия партии не двигалась, и правка со старой версией
проходила мимо оптимистичной блокировки."""
_use_tmp_uploads(monkeypatch, tmp_path)
me, gid, p2, mid = _start(client, engine)
v1 = client.get(f"/api/matches/{mid}").json()["version"]
assert _upload(client, mid).status_code == 200
v2 = client.get(f"/api/matches/{mid}").json()["version"]
assert v2 != v1
stale = client.delete(
f"/api/matches/{mid}", params={"expected_version": v1}, headers=csrf_headers(client)
)
assert stale.status_code == 409, stale.text
assert stale.json()["error"]["code"] == "STALE_WRITE"
+36
View File
@@ -75,3 +75,39 @@ def test_stale_finish_rejected(client: TestClient, engine):
headers=csrf_headers(client), headers=csrf_headers(client),
) )
assert r.status_code == 409 and r.json()["error"]["code"] == "STALE_WRITE", r.text assert r.status_code == 409 and r.json()["error"]["code"] == "STALE_WRITE", r.text
def test_participant_edit_moves_version(client: TestClient, engine):
"""Правка одних участников тоже двигает версию партии.
Регрессия: updated_at менялся только при UPDATE строки matches, поэтому после
правки участников версия оставалась прежней и вторая правка со старой версией
проходила вместо 409 — ровно то, от чего защищает блокировка."""
me, p2, mid = _start(client, engine)
fin = finish_match(
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}],
win_reason="objectives",
)
assert fin.status_code == 200, fin.text
v1 = client.get(f"/api/matches/{mid}").json()["version"]
parts = {p["user_id"]: p["faction_id"] for p in client.get(f"/api/matches/{mid}").json()["participants"]}
swap = [
{"user_id": me["id"], "faction_id": parts[me["id"]], "place": 2},
{"user_id": p2, "faction_id": parts[p2], "place": 1},
]
r = client.patch(
f"/api/matches/{mid}",
json={"participants": swap, "expected_version": v1},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
assert client.get(f"/api/matches/{mid}").json()["version"] != v1
stale = client.patch(
f"/api/matches/{mid}",
json={"participants": swap, "expected_version": v1},
headers=csrf_headers(client),
)
assert stale.status_code == 409, stale.text
assert stale.json()["error"]["code"] == "STALE_WRITE"
+48
View File
@@ -3,7 +3,9 @@
from __future__ import annotations from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlmodel import Session
from app.models import User
from tests.conftest import ( from tests.conftest import (
add_group_member, add_group_member,
create_finished_match, create_finished_match,
@@ -399,3 +401,49 @@ def test_history_uses_owner_mode_for_guests(client: TestClient, engine):
assert data["mode"] == "best" assert data["mode"] == "best"
assert data["detail"] == "full" assert data["detail"] == "full"
assert data["total"] == 1 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"])
+11 -1
View File
@@ -1,7 +1,7 @@
import createClient from "openapi-fetch"; import createClient from "openapi-fetch";
import type { paths } from "./schema"; import type { paths } from "./schema";
function readCookie(name: string): string | null { export function readCookie(name: string): string | null {
const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")); const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
return m ? decodeURIComponent(m[1]) : null; return m ? decodeURIComponent(m[1]) : null;
} }
@@ -53,3 +53,13 @@ export function unwrap<T>(res: FetchResult<T>): T {
} }
return res.data as T; return res.data as T;
} }
/**
* Повторы для проб сессии (`/users/me`, `/admin/me`). «Не авторизован» — это
* только ответ сервера (401/403, обрабатывается в самих хуках); обрыв связи
* ответом не является, и без повторов гварды приняли бы его за разлогин и
* увели на страницу входа.
*/
export const authProbeRetry = (count: number, err: unknown): boolean =>
!(err instanceof ApiError) && count < 2;
+14
View File
@@ -26,3 +26,17 @@ export const qk = {
adminLogs: ["adminLogs"] as const, adminLogs: ["adminLogs"] as const,
adminAchievements: ["adminAchievements"] as const, adminAchievements: ["adminAchievements"] as const,
}; };
/**
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один
* список на SSE-обработчик и на завершение партии — иначе переименование ключа
* в этом файле тихо разойдётся с местами, где он написан строкой.
*/
export const matchAffectedKeys = [
qk.home,
qk.leaderboard,
qk.myStats,
["userMatches"],
["publicProfile"],
] as const;
+11 -3
View File
@@ -5,25 +5,33 @@ import { Spinner } from "../components/Spinner";
import { useMe } from "../hooks/auth"; import { useMe } from "../hooks/auth";
import { useAdminMe } from "../hooks/admin"; import { useAdminMe } from "../hooks/admin";
/** Запрос упал, а не ответил «не авторизован»: связи нет — это не повод разлогинивать. */
function OfflineNotice() {
return <div className="muted">Нет связи с сервером. Проверьте подключение и обновите страницу.</div>;
}
export function RequireAuth({ children }: PropsWithChildren) { export function RequireAuth({ children }: PropsWithChildren) {
const { data: me, isLoading } = useMe(); const { data: me, isLoading, isError } = useMe();
const location = useLocation(); const location = useLocation();
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (isError) return <OfflineNotice />;
if (!me) return <Navigate to="/login" replace state={{ from: location }} />; if (!me) return <Navigate to="/login" replace state={{ from: location }} />;
return <>{children}</>; return <>{children}</>;
} }
export function RequireGroup({ children }: PropsWithChildren) { export function RequireGroup({ children }: PropsWithChildren) {
const { data: me, isLoading } = useMe(); const { data: me, isLoading, isError } = useMe();
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (isError) return <OfflineNotice />;
if (!me) return <Navigate to="/login" replace />; if (!me) return <Navigate to="/login" replace />;
if (me.active_group_id == null) return <Navigate to="/" replace />; if (me.active_group_id == null) return <Navigate to="/" replace />;
return <>{children}</>; return <>{children}</>;
} }
export function RequireAdmin({ children }: PropsWithChildren) { export function RequireAdmin({ children }: PropsWithChildren) {
const { data: admin, isLoading } = useAdminMe(); const { data: admin, isLoading, isError } = useAdminMe();
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (isError) return <OfflineNotice />;
if (!admin) return <Navigate to="/admin/login" replace />; if (!admin) return <Navigate to="/admin/login" replace />;
return <>{children}</>; return <>{children}</>;
} }
+11
View File
@@ -53,6 +53,7 @@ export function PlaceEditor({
const elimRef = useRef<HTMLDivElement>(null); const elimRef = useRef<HTMLDivElement>(null);
const indRef = useRef<HTMLDivElement>(null); const indRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
const hintRef = useRef<string | null>(null);
const byId = new Map(players.map((p) => [p.user_id, p])); const byId = new Map(players.map((p) => [p.user_id, p]));
const placeOf = (i: number) => const placeOf = (i: number) =>
@@ -80,6 +81,7 @@ export function PlaceEditor({
}; };
const clearHints = () => { const clearHints = () => {
hintRef.current = null;
if (indRef.current) indRef.current.style.display = "none"; if (indRef.current) indRef.current.style.display = "none";
listRef.current listRef.current
?.querySelectorAll(".merge-target") ?.querySelectorAll(".merge-target")
@@ -88,7 +90,12 @@ export function PlaceEditor({
}; };
const showHint = (t: Target, d: DragState) => { const showHint = (t: Target, d: DragState) => {
// pointermove срабатывает десятки раз в секунду, а цель меняется намного реже:
// без этой проверки на каждое движение шёл querySelectorAll и перестановка классов.
const key = `${t.type}:${t.type === "elim" ? "" : t.idx}`;
if (key === hintRef.current) return;
clearHints(); clearHints();
hintRef.current = key;
if (t.type === "elim") { if (t.type === "elim") {
elimRef.current?.classList.add("highlight"); elimRef.current?.classList.add("highlight");
return; return;
@@ -182,6 +189,10 @@ export function PlaceEditor({
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => startDrag(e, opts), onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => startDrag(e, opts),
onPointerMove: moveDrag, onPointerMove: moveDrag,
onPointerUp: endDrag, onPointerUp: endDrag,
// Браузер отменяет указатель (перехват прокрутки, входящий звонок) — без этого
// блок остаётся с классом dragging и сдвигом: они выставлены в обход React,
// и перерендер их не снимет.
onPointerCancel: endDrag,
}); });
// ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё. // ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё.
+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"; import type { PropsWithChildren } from "react";
interface ToastCtx { interface ToastCtx {
@@ -17,8 +17,13 @@ export function ToastProvider({ children }: PropsWithChildren) {
timer.current = setTimeout(() => setMessage(null), 2800); timer.current = setTimeout(() => setMessage(null), 2800);
}, []); }, []);
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост:
// без useMemo новое значение контекста заставляло бы перерисоваться и всех
// потребителей, к самому тосту отношения не имеющих.
const value = useMemo(() => ({ show }), [show]);
return ( return (
<Ctx.Provider value={{ show }}> <Ctx.Provider value={value}>
{children} {children}
{message && <div className="toast">{message}</div>} {message && <div className="toast">{message}</div>}
</Ctx.Provider> </Ctx.Provider>
+6 -12
View File
@@ -29,12 +29,9 @@ export function formatDuration(minutes: number | null | undefined): string {
export function formatTime(iso: string | null | undefined): string { export function formatTime(iso: string | null | undefined): string {
if (!iso) return "—"; if (!iso) return "—";
try { const d = shifted(iso);
const d = shifted(iso); if (Number.isNaN(d.getTime())) return iso;
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
} catch {
return iso;
}
} }
// «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части, // «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части,
@@ -47,10 +44,7 @@ export function formatDate(iso: string | null | undefined): string {
export function formatDateTime(iso: string | null | undefined): string { export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—"; if (!iso) return "—";
try { const d = shifted(iso);
const d = shifted(iso); if (Number.isNaN(d.getTime())) return iso;
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
} catch {
return iso;
}
} }
+10 -51
View File
@@ -1,14 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, unwrap } from "../api/client"; import { api, authProbeRetry, unwrap } from "../api/client";
import { qk } from "../api/queryKeys"; import { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types"; import type { AdminMe, MatchUpdate } from "../domain/types";
import { resizeImage } from "../lib/image"; import { uploadFile } from "../lib/upload";
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
export function useAdminMe() { export function useAdminMe() {
return useQuery({ return useQuery({
@@ -18,6 +13,7 @@ export function useAdminMe() {
if (r.response.status === 401 || r.response.status === 403) return null; if (r.response.status === 401 || r.response.status === 403) return null;
return unwrap(r); return unwrap(r);
}, },
retry: authProbeRetry,
}); });
} }
@@ -106,29 +102,8 @@ export function useAdminMatchAttachments(matchId: number | null) {
export function useAdminUploadAttachment(matchId: number) { export function useAdminUploadAttachment(matchId: number) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) =>
const blob = await resizeImage(file); uploadFile(`/api/admin/matches/${matchId}/attachments`, file),
const form = new FormData();
form.append("file", blob, "photo.jpg");
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/admin/matches/${matchId}/attachments`, {
method: "POST",
body: form,
credentials: "include",
headers: csrf ? { "X-CSRF-Token": csrf } : {},
});
if (!r.ok) {
let env: { code?: string; message?: string } | undefined;
try {
env = ((await r.json()) as { error?: typeof env }).error;
} catch {
/* тело без JSON */
}
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
}
return await r.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }),
}); });
} }
@@ -283,28 +258,12 @@ export function useDeleteAchievement() {
export function useUploadAchievementIcon() { export function useUploadAchievementIcon() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ slug, file }: { slug: string; file: File }) => { // Иконку шлём оригиналом: ресайз в JPEG убил бы прозрачность герба.
const form = new FormData(); mutationFn: async ({ slug, file }: { slug: string; file: File }) =>
form.append("file", file, file.name); uploadFile(`/api/admin/achievements/${slug}/icon`, file, {
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, {
method: "PUT", method: "PUT",
body: form, maxSide: null,
credentials: "include", }),
headers: csrf ? { "X-CSRF-Token": csrf } : {},
});
if (!r.ok) {
let env: { code?: string; message?: string } | undefined;
try {
env = ((await r.json()) as { error?: typeof env }).error;
} catch {
/* тело без JSON */
}
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
}
return await r.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
}); });
} }
+9 -65
View File
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, unwrap } from "../api/client"; import { api, authProbeRetry, unwrap } from "../api/client";
import { qk } from "../api/queryKeys"; import { qk } from "../api/queryKeys";
import { uploadFile } from "../lib/upload";
import type { AuthConfig, Me } from "../domain/types"; import type { AuthConfig, Me } from "../domain/types";
export function useMe() { export function useMe() {
@@ -12,6 +13,7 @@ export function useMe() {
if (r.response.status === 401) return null; if (r.response.status === 401) return null;
return unwrap(r); return unwrap(r);
}, },
retry: authProbeRetry,
}); });
} }
@@ -154,74 +156,16 @@ export function useUpdateHistoryPrefs() {
}); });
} }
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным // Аватар: картинку уменьшаем на клиенте (≤512px) и грузим общим загрузчиком.
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
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; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер)
}
}
export function useUploadAvatar() { export function useUploadAvatar() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (file: File): Promise<Me> => { mutationFn: async (file: File): Promise<Me> =>
const blob = await resizeImage(file); uploadFile<Me>("/api/users/me/avatar", file, {
const form = new FormData();
form.append("file", blob, "avatar.jpg");
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/users/me/avatar`, {
method: "PUT", method: "PUT",
body: form, fieldName: "avatar.jpg",
credentials: "include", maxSide: 512,
headers: csrf ? { "X-CSRF-Token": csrf } : {}, }),
});
if (!r.ok) {
let env: { code?: string; message?: string; details?: unknown } | undefined;
try {
env = ((await r.json()) as { error?: typeof env }).error;
} catch {
/* тело без JSON */
}
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status, env?.details);
}
return (await r.json()) as Me;
},
onSuccess: (me) => { onSuccess: (me) => {
qc.setQueryData(qk.me, me); qc.setQueryData(qk.me, me);
qc.invalidateQueries(); qc.invalidateQueries();
+9 -35
View File
@@ -1,18 +1,15 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, unwrap } from "../api/client"; import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys"; import { matchAffectedKeys, qk } from "../api/queryKeys";
import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types"; import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types";
import { resizeImage } from "../lib/image"; import { uploadFile } from "../lib/upload";
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
export function useMatch(matchId: number | null) { export function useMatch(matchId: number | null) {
return useQuery({ return useQuery({
queryKey: matchId ? qk.match(matchId) : ["match", "none"], // Ключ и enabled должны сходиться: иначе запрос без валидного id кэшировал бы
// свою ошибку под общим ключом-заглушкой.
queryKey: matchId != null ? qk.match(matchId) : ["match", "none"],
enabled: matchId != null, enabled: matchId != null,
queryFn: async () => queryFn: async () =>
unwrap( unwrap(
@@ -60,9 +57,7 @@ export function useFinishMatch() {
qc.invalidateQueries({ queryKey: qk.match(m.id) }); qc.invalidateQueries({ queryKey: qk.match(m.id) });
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) }); qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) }); qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
qc.invalidateQueries({ queryKey: qk.leaderboard }); for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.myStats });
}, },
}); });
} }
@@ -88,29 +83,8 @@ export function useDeleteMatch() {
export function useUploadMatchAttachment(matchId: number) { export function useUploadMatchAttachment(matchId: number) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) =>
const blob = await resizeImage(file); uploadFile(`/api/matches/${matchId}/attachments`, file),
const form = new FormData();
form.append("file", blob, "photo.jpg");
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/matches/${matchId}/attachments`, {
method: "POST",
body: form,
credentials: "include",
headers: csrf ? { "X-CSRF-Token": csrf } : {},
});
if (!r.ok) {
let env: { code?: string; message?: string } | undefined;
try {
env = ((await r.json()) as { error?: typeof env }).error;
} catch {
/* тело без JSON */
}
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
}
return await r.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }),
}); });
} }
+3 -3
View File
@@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
import { qk } from "../api/queryKeys"; import { matchAffectedKeys, qk } from "../api/queryKeys";
interface ServerEvent { interface ServerEvent {
type: "match" | "group" | "invitations" | "notifications"; type: "match" | "group" | "invitations" | "notifications";
@@ -38,8 +38,8 @@ export function useServerEvents(enabled: boolean) {
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
} }
qc.invalidateQueries({ queryKey: qk.home }); // История игр и публичные профили тоже меняются от чужой партии.
qc.invalidateQueries({ queryKey: qk.leaderboard }); for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
} else if (ev.type === "group") { } else if (ev.type === "group") {
if (ev.group_id != null) { if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
+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;
}
+52 -35
View File
@@ -15,13 +15,21 @@ import { useGroupMembers } from "../hooks/groups";
import { useRandomizeFaction, useStartMatch } from "../hooks/matches"; import { useRandomizeFaction, useStartMatch } from "../hooks/matches";
interface Draft { interface Draft {
// Стабильный ключ строки: индексы сдвигаются при удалении, а барабан рандома
// отложенно дописывает результат в свою строку и промахнулся бы по соседа.
id: number;
user_id: number | ""; user_id: number | "";
faction_id: number | ""; faction_id: number | "";
was_random: boolean; was_random: boolean;
} }
let draftSeq = 0;
function emptyDraft(): Draft { function emptyDraft(): Draft {
return { user_id: "", faction_id: "", was_random: false }; // Счётчик монотонный на весь модуль: id нужны лишь уникальные в пределах
// страницы, а сквозная нумерация избавляет от коллизий при перемонтировании.
draftSeq += 1;
return { id: draftSeq, user_id: "", faction_id: "", was_random: false };
} }
export function CreateMatchPage() { export function CreateMatchPage() {
@@ -51,30 +59,39 @@ export function CreateMatchPage() {
if (lm || lf) return <Spinner />; if (lm || lf) return <Spinner />;
if (!groupId) return <div className="muted">Нет активной группы.</div>; if (!groupId) return <div className="muted">Нет активной группы.</div>;
const takenFactionIds = (exceptIdx: number) => const takenFactionIds = (exceptId: number) =>
rows rows
.filter((_, i) => i !== exceptIdx) .filter((r) => r.id !== exceptId)
.map((r) => r.faction_id) .map((r) => r.faction_id)
.filter((x): x is number => typeof x === "number"); .filter((x): x is number => typeof x === "number");
// Игроки, уже выбранные в других строках, — в списке не предлагаются. // Игроки, уже выбранные в других строках, — в списке не предлагаются.
const takenUserIds = (exceptIdx: number) => const takenUserIds = (exceptId: number) =>
rows rows
.filter((_, i) => i !== exceptIdx) .filter((r) => r.id !== exceptId)
.map((r) => r.user_id) .map((r) => r.user_id)
.filter((x): x is number => typeof x === "number"); .filter((x): x is number => typeof x === "number");
const update = (idx: number, patch: Partial<Draft>) => const update = (id: number, patch: Partial<Draft>) =>
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r))); setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
const addRow = () => setRows((rs) => [...rs, emptyDraft()]); const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx)); const removeRow = (id: number) => {
const timer = reelTimers.current[id];
if (timer) clearTimeout(timer);
delete reelTimers.current[id];
setSpin((sp) => {
const { [id]: _dropped, ...rest } = sp;
return rest;
});
setRows((rs) => rs.filter((r) => r.id !== id));
};
// «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление), // «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление),
// затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только // затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только
// после этого фракция «выбирается» (onReveal: показываем select с результатом). // после этого фракция «выбирается» (onReveal: показываем select с результатом).
const animateReel = ( const animateReel = (
idx: number, id: number,
names: string[], names: string[],
finalName: string, finalName: string,
onReveal: () => void, onReveal: () => void,
@@ -82,16 +99,16 @@ export function CreateMatchPage() {
new Promise((resolve) => { new Promise((resolve) => {
let delay = 50; let delay = 50;
const tick = () => { const tick = () => {
setSpin((s) => ({ ...s, [idx]: names[Math.floor(Math.random() * names.length)] })); setSpin((s) => ({ ...s, [id]: names[Math.floor(Math.random() * names.length)] }));
delay *= 1.18; delay *= 1.18;
if (delay < 300) { if (delay < 300) {
reelTimers.current[idx] = setTimeout(tick, delay); reelTimers.current[id] = setTimeout(tick, delay);
} else { } else {
reelTimers.current[idx] = setTimeout(() => { reelTimers.current[id] = setTimeout(() => {
setSpin((s) => ({ ...s, [idx]: finalName })); // остановка на выбранной setSpin((s) => ({ ...s, [id]: finalName })); // остановка на выбранной
reelTimers.current[idx] = setTimeout(() => { reelTimers.current[id] = setTimeout(() => {
onReveal(); onReveal();
setSpin((s) => ({ ...s, [idx]: null })); setSpin((s) => ({ ...s, [id]: null }));
resolve(); resolve();
}, 500); // пауза перед «выбором» }, 500); // пауза перед «выбором»
}, delay); }, delay);
@@ -100,20 +117,20 @@ export function CreateMatchPage() {
tick(); tick();
}); });
const randomizeRow = async (idx: number) => { const randomizeRow = async (id: number) => {
const pool = factions ?? []; const pool = factions ?? [];
if (pool.length === 0 || spin[idx] != null) return; if (pool.length === 0 || spin[id] != null) return;
try { try {
const faction = await randomize.mutateAsync({ const faction = await randomize.mutateAsync({
group_id: groupId, group_id: groupId,
exclude_faction_ids: takenFactionIds(idx), exclude_faction_ids: takenFactionIds(id),
}); });
await animateReel(idx, pool.map((f) => f.name_ru), faction.name_ru, () => await animateReel(id, pool.map((f) => f.name_ru), faction.name_ru, () =>
update(idx, { faction_id: faction.id, was_random: true }), update(id, { faction_id: faction.id, was_random: true }),
); );
} catch (e) { } catch (e) {
if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]); if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
setSpin((s) => ({ ...s, [idx]: null })); setSpin((s) => ({ ...s, [id]: null }));
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома"); toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
} }
}; };
@@ -130,10 +147,10 @@ export function CreateMatchPage() {
const names = all.map((f) => f.name_ru); const names = all.map((f) => f.name_ru);
// Барабан крутится во всех строках одновременно, каждая садится на свою фракцию. // Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
await Promise.all( await Promise.all(
rows.map((_, idx) => { rows.map((row, idx) => {
const f = pool[idx % pool.length]; const f = pool[idx % pool.length];
return animateReel(idx, names, f.name_ru, () => return animateReel(row.id, names, f.name_ru, () =>
update(idx, { faction_id: f.id, was_random: true }), update(row.id, { faction_id: f.id, was_random: true }),
); );
}), }),
); );
@@ -194,11 +211,11 @@ export function CreateMatchPage() {
</div> </div>
{rows.map((row, idx) => ( {rows.map((row, idx) => (
<div className="participant-row" key={idx}> <div className="participant-row" key={row.id}>
<div className="row-between"> <div className="row-between">
<b>Игрок {idx + 1}</b> <b>Игрок {idx + 1}</b>
{rows.length > 2 && ( {rows.length > 2 && (
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(idx)}> <button className="btn btn-ghost btn-danger small" onClick={() => removeRow(row.id)}>
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
)} )}
@@ -206,29 +223,29 @@ export function CreateMatchPage() {
<PickerSelect <PickerSelect
selected={playerOptions.find((m) => m.id === row.user_id) ?? null} selected={playerOptions.find((m) => m.id === row.user_id) ?? null}
options={playerOptions.filter((m) => !takenUserIds(idx).includes(m.id))} options={playerOptions.filter((m) => !takenUserIds(row.id).includes(m.id))}
placeholder="— игрок —" placeholder="— игрок —"
renderOption={(m) => playerLabel(m, 28)} renderOption={(m) => playerLabel(m, 28)}
renderValue={(m) => playerLabel(m, 24)} renderValue={(m) => playerLabel(m, 24)}
onPick={(m) => update(idx, { user_id: m.id })} onPick={(m) => update(row.id, { user_id: m.id })}
/> />
<div className="row"> <div className="row">
{spin[idx] != null ? ( {spin[row.id] != null ? (
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div> <div className="faction-spin" style={{ flex: 1 }}>{spin[row.id]}</div>
) : ( ) : (
<PickerSelect <PickerSelect
selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null} selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null}
options={(factions ?? []).filter((f) => !takenFactionIds(idx).includes(f.id))} options={(factions ?? []).filter((f) => !takenFactionIds(row.id).includes(f.id))}
placeholder="— фракция —" placeholder="— фракция —"
renderOption={(f) => f.name_ru} renderOption={(f) => f.name_ru}
onPick={(f) => update(idx, { faction_id: f.id, was_random: false })} onPick={(f) => update(row.id, { faction_id: f.id, was_random: false })}
/> />
)} )}
<button <button
className="btn" className="btn"
onClick={() => randomizeRow(idx)} onClick={() => randomizeRow(row.id)}
disabled={spin[idx] != null} disabled={spin[row.id] != null}
title="Случайная фракция" title="Случайная фракция"
> >
<Dices size={18} /> <Dices size={18} />
+6 -2
View File
@@ -23,7 +23,9 @@ const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
export function MatchDetailPage() { export function MatchDetailPage() {
const { matchId } = useParams(); const { matchId } = useParams();
const id = matchId ? Number(matchId) : null; // Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
const parsed = matchId ? Number(matchId) : NaN;
const id = Number.isInteger(parsed) ? parsed : null;
const { data: match, isLoading, refetch } = useMatch(id); const { data: match, isLoading, refetch } = useMatch(id);
const finish = useFinishMatch(); const finish = useFinishMatch();
const del = useDeleteMatch(); const del = useDeleteMatch();
@@ -105,7 +107,9 @@ export function MatchDetailPage() {
try { try {
await del.mutateAsync({ matchId: id, expectedVersion: match.version }); await del.mutateAsync({ matchId: id, expectedVersion: match.version });
toast.show(inProgress ? "Партия отменена" : "Партия удалена"); toast.show(inProgress ? "Партия отменена" : "Партия удалена");
navigate(-1); // Не navigate(-1): партию часто открывают по прямой ссылке, и «назад»
// уводит из приложения вместо возврата к группе.
navigate("/group", { replace: true });
} catch (e) { } catch (e) {
if (isStale(e)) { if (isStale(e)) {
toast.show("Партия изменилась на другом устройстве — обновлено"); toast.show("Партия изменилась на другом устройстве — обновлено");
@@ -36,8 +36,12 @@ export function AdminAccountsPage() {
}; };
const toggleActive = async (id: number, isActive: boolean) => { const toggleActive = async (id: number, isActive: boolean) => {
await update.mutateAsync({ userId: id, is_active: !isActive }).catch(() => {}); try {
toast.show("Сохранено"); await update.mutateAsync({ userId: id, is_active: !isActive });
toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось сохранить");
}
}; };
return ( return (
+10 -3
View File
@@ -11,10 +11,17 @@ export function AdminFactionsPage() {
const toast = useToast(); const toast = useToast();
const [names, setNames] = useState<Record<number, string>>({}); const [names, setNames] = useState<Record<number, string>>({});
// Подставляем серверные названия только там, где поле ещё не трогали: refetch после
// сохранения одной фракции иначе затирал бы несохранённый ввод в остальных.
useEffect(() => { useEffect(() => {
if (factions) { if (!factions) return;
setNames(Object.fromEntries(factions.map((f) => [f.id, f.name_ru]))); setNames((prev) => {
} const next = { ...prev };
for (const f of factions) {
if (next[f.id] === undefined) next[f.id] = f.name_ru;
}
return next;
});
}, [factions]); }, [factions]);
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;