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