v0.3 - кастомизация аккаунта, просмотр чужих аккаунтов, создание кастомных титулов

This commit is contained in:
2026-06-17 19:30:50 +03:00
48 changed files with 3061 additions and 145 deletions
+2 -1
View File
@@ -13,9 +13,10 @@ frontend/node_modules/
frontend/dist/
frontend/.vite/
# DEV-вход (по нику) физически НЕ попадает в прод-образ
# DEV-код физически НЕ попадает в прод-образ (вход по нику, удаление аккаунтов)
backend/app/auth/dev_stub.py
backend/app/routers/dev_auth.py
backend/app/routers/dev_admin.py
# Тесты и dev-манифест зависимостей в прод-образе не нужны (ставим из requirements.txt)
backend/tests/
+1 -1
View File
@@ -37,7 +37,7 @@ COPY --from=frontend /app/frontend/dist /app/static
RUN sed -i 's/\r$//' /app/entrypoint.sh \
&& chmod +x /app/entrypoint.sh \
&& useradd --create-home --uid 10001 appuser \
&& mkdir -p /data/uploads \
&& mkdir -p /data/uploads /data/achievements \
&& chown -R appuser:appuser /app /data
USER appuser
@@ -0,0 +1,47 @@
"""Кастомизация профиля: о себе (bio) и аватар (avatar_path).
Идемпотентна: на свежей БД (0001 создаёт схему из актуальных моделей через create_all)
столбцы уже есть → no-op; на существующей БД — добавляет столбцы. render_as_batch включён
в env.py (нужен для ALTER в SQLite).
Revision ID: 0004_user_profile
Revises: 0003_match_lifecycle
Create Date: 2026-06-17
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0004_user_profile"
down_revision: Union[str, None] = "0003_match_lifecycle"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
add = []
if "bio" not in cols:
add.append(sa.Column("bio", sa.Text(), nullable=True))
if "avatar_path" not in cols:
add.append(sa.Column("avatar_path", sa.String(255), nullable=True))
if add:
with op.batch_alter_table("users") as b:
for col in add:
b.add_column(col)
def downgrade() -> None:
with op.batch_alter_table("users") as b:
for name in ("avatar_path", "bio"):
try:
b.drop_column(name)
except Exception: # noqa: BLE001
pass
@@ -0,0 +1,61 @@
"""Задел под титулы/ачивки: users.title_achievement_slug + таблица user_achievements.
Сами определения ачивок хранятся файлами (settings.achievements_dir), в БД — только
будущая выдача игрокам. Идемпотентна (как 0003/0004): на свежей БД (0001 = create_all из
актуальных моделей) столбец/таблица уже есть → no-op; на существующей — добавляет.
Revision ID: 0005_achievements
Revises: 0004_user_profile
Create Date: 2026-06-17
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0005_achievements"
down_revision: Union[str, None] = "0004_user_profile"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
if "title_achievement_slug" not in cols:
with op.batch_alter_table("users") as b:
b.add_column(sa.Column("title_achievement_slug", sa.String(64), nullable=True))
if "user_achievements" not in insp.get_table_names():
op.create_table(
"user_achievements",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("achievement_slug", sa.String(64), nullable=False),
sa.Column("earned_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"),
)
op.create_index(
"ix_user_achievements_user_id", "user_achievements", ["user_id"]
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "user_achievements" in insp.get_table_names():
op.drop_table("user_achievements")
with op.batch_alter_table("users") as b:
try:
b.drop_column("title_achievement_slug")
except Exception: # noqa: BLE001
pass
+13 -5
View File
@@ -15,22 +15,30 @@ from app.models import User
from app.services import audit_service, user_service
def login_with_identity(
session: Session, response: Response, request: Request, identity: ExternalIdentity
def establish_session(
session: Session, response: Response, request: Request, user: User, provider: str
) -> User:
user = user_service.get_or_create_from_identity(session, identity)
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
if not user.is_active:
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
security.set_user_session(response, user.id, identity.provider) # type: ignore[arg-type]
security.set_user_session(response, user.id, provider) # type: ignore[arg-type]
audit_service.record(
session,
actor_id=user.id,
action="login",
entity_type="user",
entity_id=user.id,
payload={"provider": identity.provider},
payload={"provider": provider},
ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.commit()
return user
def login_with_identity(
session: Session, response: Response, request: Request, identity: ExternalIdentity
) -> User:
"""Вход с авто-созданием (dev-вход по нику): ник подбирается автоматически."""
user = user_service.get_or_create_from_identity(session, identity)
return establish_session(session, response, request, user, identity.provider)
+8
View File
@@ -45,6 +45,10 @@ class Settings(BaseSettings):
dev_upload_dir: str = "./data/dev/uploads"
prod_upload_dir: str = "/data/uploads"
# Каталог ачивок (папка на ачивку: meta.json, condition.py, icon.*), раздельно.
dev_achievements_dir: str = "./data/dev/achievements"
prod_achievements_dir: str = "/data/achievements"
# JWT / cookie
secret_key: str = "change-me-dev-secret-not-for-production"
jwt_algorithm: str = "HS256"
@@ -97,6 +101,10 @@ class Settings(BaseSettings):
def upload_dir(self) -> str:
return self.dev_upload_dir if self.is_development else self.prod_upload_dir
@property
def achievements_dir(self) -> str:
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
@property
def cookie_secure(self) -> bool:
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
+14
View File
@@ -94,6 +94,20 @@ class NicknameTakenError(ConflictError):
super().__init__("Никнейм уже занят.")
class TelegramNicknameRequiredError(ConflictError):
"""Первый вход через Telegram, но предлагаемый ник (тег) занят/некорректен —
фронт должен показать выбор ника и повторить регистрацию через /telegram/register.
`suggested` идёт в details для предзаполнения поля."""
code = "TELEGRAM_NICKNAME_REQUIRED"
def __init__(self, suggested: str, message: str | None = None) -> None:
super().__init__(
message or f"Ник «{suggested}» уже занят — выберите другой.",
details={"suggested": suggested},
)
class FactionNotAvailableError(ValidationError):
code = "FACTION_NOT_AVAILABLE"
+8 -7
View File
@@ -15,7 +15,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.core import security
from app.core.config import settings
from app.core.errors import AppError, app_error_handler
from app.routers import admin, auth, groups, matches, reference, stats, users
from app.routers import achievements, admin, auth, groups, matches, reference, stats, users
# Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать).
_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static")))
@@ -107,17 +107,18 @@ def create_app() -> FastAPI:
# API-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, matches.router,
reference.router, stats.router, admin.router]
reference.router, stats.router, achievements.router, admin.router]
for r in api_routers:
app.include_router(r, prefix="/api")
# DEV-вход (по нику) — только в development и только если код физически есть
# (в test/prod-образе dev_auth/dev_stub исключены, импорт просто не выполнится).
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
# и только если код физически есть (в test/prod-образе dev_*-файлы исключены
# .dockerignore, импорт просто не выполнится).
if settings.is_development:
for mod_name in ("dev_auth", "dev_admin"):
try:
from app.routers import dev_auth
app.include_router(dev_auth.router, prefix="/api")
mod = __import__(f"app.routers.{mod_name}", fromlist=["router"])
app.include_router(mod.router, prefix="/api")
except ImportError:
pass
+26
View File
@@ -80,6 +80,9 @@ class User(SQLModel, table=True):
sa_column=Column(BigInteger, nullable=True, unique=True)
)
nickname: str = Field(sa_column=Column(String(64), nullable=False, unique=True))
bio: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
# Относительный путь файла аватара (под settings.upload_dir); наличие = есть аватар.
avatar_path: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
role: str = Field(default="player", sa_column=Column(String(16), nullable=False, index=True, server_default="player"))
auth_provider: str = Field(
default="stub",
@@ -95,6 +98,9 @@ class User(SQLModel, table=True):
)
)
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
# (выдача ачивок игрокам — следующий этап).
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
updated_at: datetime = Field(
default_factory=_utcnow,
@@ -103,6 +109,26 @@ class User(SQLModel, table=True):
)
class UserAchievement(SQLModel, table=True):
"""Полученные игроком ачивки. Задел под будущую выдачу — сейчас не заполняется.
Ссылается на ачивку по slug (источник правды — папки на диске, не таблица БД)."""
__tablename__ = "user_achievements"
__table_args__ = (
UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
)
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
earned_at: datetime = Field(default_factory=_utcnow, nullable=False)
class AuthIdentity(SQLModel, table=True):
__tablename__ = "auth_identity"
__table_args__ = (
+27
View File
@@ -0,0 +1,27 @@
"""Публичное чтение ачивок: список и иконки (для отображения; выдача — на будущее)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
from app.auth.deps import get_current_user
from app.models import User
from app.schemas import api as s
from app.services import achievement_service
router = APIRouter(prefix="/achievements", tags=["achievements"])
@router.get("", response_model=list[s.AchievementRead])
def list_achievements(_user: User = Depends(get_current_user)) -> list[dict]:
return achievement_service.list_achievements()
# Иконка — без auth: косметический ассет, нужен и игроку, и в админ-панели (cookie
# админа scoped на /api/admin и сюда не отправляется). Отдаётся по известному slug.
@router.get("/{slug}/icon")
def get_icon(slug: str) -> FileResponse:
path, media_type = achievement_service.icon_file(slug)
return FileResponse(
path, media_type=media_type, headers={"Cache-Control": "public, max-age=3600"}
)
+89 -16
View File
@@ -1,7 +1,7 @@
"""Админ-роутер: отдельный вход (логин+пароль) и управление сущностями."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
from sqlmodel import Session
from app.auth.deps import get_current_admin
@@ -11,7 +11,15 @@ from app.db.session import get_session
from app.models import User
from app.routers.matches import build_match_read
from app.schemas import api as s
from app.services import admin_service, audit_service, faction_service, match_service
from app.services import (
achievement_service,
admin_service,
audit_service,
faction_service,
match_service,
)
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
from app.services.match_service import ParticipantInput
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -104,20 +112,9 @@ def update_user(
)
@router.delete("/users/{user_id}", response_model=s.OkResponse)
def delete_user(
user_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
admin_service.delete_user(session, user_id)
audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="user", entity_id=user_id,
ip=request.client.host if request.client else None,
)
session.commit()
return s.OkResponse()
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только
# отключается (PATCH is_active), удалять нельзя.
# ─── Группы ──────────────────────────────────────────────────────────────────
@@ -295,6 +292,82 @@ def delete_match(
return s.OkResponse()
# ─── Ачивки (определения; выдача игрокам — на будущее) ────────────────────────
@router.get("/achievements", response_model=list[s.AchievementRead])
def list_achievements(
_admin: User = Depends(get_current_admin),
) -> list[dict]:
return achievement_service.list_achievements()
@router.post("/achievements", response_model=s.AchievementRead)
def create_achievement(
body: s.AchievementCreate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> dict:
ach = achievement_service.create(body.name, body.description, body.condition)
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,
)
session.commit()
return ach
@router.patch("/achievements/{slug}", response_model=s.AchievementRead)
def update_achievement(
slug: str,
body: s.AchievementUpdate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> dict:
ach = achievement_service.update(
slug, name=body.name, description=body.description, condition=body.condition
)
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,
)
session.commit()
return ach
@router.put("/achievements/{slug}/icon", response_model=s.AchievementRead)
def upload_achievement_icon(
slug: str,
file: UploadFile = File(...),
_admin: User = Depends(get_current_admin),
) -> dict:
from app.core.errors import ValidationError
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)
return achievement_service.set_icon(slug, content, ext)
@router.delete("/achievements/{slug}", response_model=s.OkResponse)
def delete_achievement(
slug: str,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
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,
)
session.commit()
return s.OkResponse()
# ─── Журнал аудита ───────────────────────────────────────────────────────────
@router.get("/audit-logs", response_model=s.AuditLogList)
+40 -2
View File
@@ -7,14 +7,16 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, Request, Response
from sqlmodel import Session
from app.auth.login import login_with_identity
from app.auth.login import establish_session
from app.auth.registry import enabled_methods
from app.auth.telegram import TelegramProvider
from app.core import security
from app.core.config import settings
from app.core.errors import TelegramNicknameRequiredError
from app.db.session import get_session
from app.routers.users import build_me
from app.schemas import api as s
from app.services import user_service
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -34,8 +36,44 @@ def telegram_login(
response: Response,
session: Session = Depends(get_session),
) -> s.MeRead:
"""Вход/регистрация через Telegram.
Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход —
регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409
TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register
с выбранным ником.
"""
identity = TelegramProvider().authenticate(body.model_dump())
user = login_with_identity(session, response, request, identity)
user = user_service.find_by_identity(session, identity)
if user is None:
suggested = (identity.suggested_nickname or "").strip()
if not user_service.nickname_format_ok(suggested) or not user_service.nickname_available(
session, suggested
):
raise TelegramNicknameRequiredError(suggested)
user = user_service.register_from_identity(session, identity, suggested)
establish_session(session, response, request, user, identity.provider)
return build_me(session, user)
@router.post("/telegram/register", response_model=s.MeRead)
def telegram_register(
body: s.TelegramRegister,
request: Request,
response: Response,
session: Session = Depends(get_session),
) -> s.MeRead:
"""Завершение регистрации через Telegram с выбранным ником.
Подпись виджета проверяется заново (поле nickname в HMAC не входит), поэтому личность
доверенная — подделать чужой telegram_id нельзя. Если пользователь уже успел
зарегистрироваться (гонка) — просто входим.
"""
identity = TelegramProvider().authenticate(body.model_dump(exclude={"nickname"}))
user = user_service.find_by_identity(session, identity)
if user is None:
user = user_service.register_from_identity(session, identity, body.nickname)
establish_session(session, response, request, user, identity.provider)
return build_me(session, user)
+69
View File
@@ -0,0 +1,69 @@
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только
отключить (PATCH is_active).
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
строки участия игрока удаляются (player_count партий НЕ пересчитывается, поэтому
их статистика может стать несогласованной — осознанный выбор). Созданные им партии
и группы в его владении переназначаются на админа (FK RESTRICT не даёт удалить
пользователя, а сами партии/группы мы сохраняем).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session, select
from app.auth.deps import get_current_admin
from app.core.errors import NotFoundError, ValidationError
from app.db.session import get_session
from app.models import Group, Match, MatchParticipant, User
from app.schemas import api as s
from app.services import audit_service
router = APIRouter(prefix="/admin/dev", tags=["admin-dev"])
@router.delete("/users/{user_id}", response_model=s.OkResponse)
def delete_user_hard(
user_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
user = session.get(User, user_id)
if user is None:
raise NotFoundError("Пользователь не найден.")
if user.role == "admin":
raise ValidationError("Нельзя удалить администратора.")
nickname = user.nickname # сохраняем до удаления (для аудита)
# 1. Вычёркиваем игрока из всех партий (его строки участия).
for part in session.exec(
select(MatchParticipant).where(MatchParticipant.user_id == user_id)
).all():
session.delete(part)
# 2. Партии и группы, где он создатель/владелец, переназначаем на админа.
for match in session.exec(select(Match).where(Match.created_by == user_id)).all():
match.created_by = admin.id # type: ignore[assignment]
session.add(match)
for group in session.exec(select(Group).where(Group.owner_id == user_id)).all():
group.owner_id = admin.id # type: ignore[assignment]
session.add(group)
session.flush()
# 3. group_members и auth_identity уйдут каскадом, audit_log.actor_id → NULL.
session.delete(user)
audit_service.record(
session,
actor_id=admin.id,
action="delete",
entity_type="user",
entity_id=user_id,
payload={"hard": True, "nickname": nickname},
ip=request.client.host if request.client else None,
)
session.commit()
return s.OkResponse()
+82 -1
View File
@@ -1,10 +1,12 @@
"""Роутер текущего пользователя."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
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.db.session import get_session
from app.models import User
from app.schemas import api as s
@@ -12,6 +14,8 @@ from app.services import audit_service, group_service, stats_service, user_servi
router = APIRouter(prefix="/users", tags=["users"])
_AVATAR_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
def build_me(session: Session, user: User) -> s.MeRead:
groups = [
@@ -25,6 +29,8 @@ def build_me(session: Session, user: User) -> s.MeRead:
auth_provider=user.auth_provider,
telegram_id=user.telegram_id,
active_group_id=user.active_group_id,
bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
groups=groups,
)
@@ -63,9 +69,84 @@ def update_me(
auth_provider=user.auth_provider,
telegram_id=user.telegram_id,
active_group_id=user.active_group_id,
bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
)
@router.patch("/me/profile", response_model=s.MeRead)
def update_my_profile(
body: s.ProfileUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
user_service.update_bio(session, user, body.bio)
audit_service.record(
session,
actor_id=user.id,
action="update",
entity_type="user",
entity_id=user.id,
payload={"bio": True},
ip=request.client.host if request.client else None,
)
session.commit()
return build_me(session, user)
@router.put("/me/avatar", response_model=s.MeRead)
def upload_my_avatar(
file: UploadFile = File(...),
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.")
user_service.set_avatar(session, user, content, ext)
return build_me(session, user)
@router.delete("/me/avatar", response_model=s.MeRead)
def delete_my_avatar(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
user_service.clear_avatar(session, user)
return build_me(session, user)
@router.get("/{user_id}/avatar")
def get_user_avatar(
user_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> FileResponse:
target = session.get(User, user_id)
path = user_service.avatar_abs_path(target.avatar_path) if target else None
if path is None or not path.exists():
raise NotFoundError("Аватар не найден.")
ext = path.suffix.lstrip(".")
return FileResponse(
path,
media_type=user_service.avatar_media_type(ext),
headers={"Cache-Control": "public, max-age=3600"},
)
@router.get("/{user_id}/profile", response_model=s.PublicProfile)
def get_user_profile(
user_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
return user_service.public_profile(session, user_id)
@router.get("/me/stats", response_model=s.ProfileStats)
def my_stats(
session: Session = Depends(get_session),
+43
View File
@@ -29,6 +29,12 @@ class TelegramAuthPayload(BaseModel):
photo_url: str | None = None
class TelegramRegister(TelegramAuthPayload):
# Регистрация через Telegram с явно выбранным ником (когда тег занят/некорректен).
# Подпись виджета проверяется по тем же полям; nickname в HMAC не входит.
nickname: str
class DevLogin(BaseModel):
nickname: str
@@ -82,6 +88,8 @@ class UserRead(BaseModel):
auth_provider: str
telegram_id: int | None = None
active_group_id: int | None = None
bio: str | None = None
avatar_url: str | None = None
class MeRead(UserRead):
@@ -92,6 +100,10 @@ class NicknameUpdate(BaseModel):
nickname: str
class ProfileUpdate(BaseModel):
bio: str | None = None
class ActiveGroupUpdate(BaseModel):
group_id: int | None = None
@@ -231,6 +243,7 @@ class LeaderboardEntry(OverallStats):
user_id: int
nickname: str
rank: int | None = None
avatar_url: str | None = None
class Leaderboard(BaseModel):
@@ -267,6 +280,15 @@ class ProfileStats(BaseModel):
recent_form: list[RecentFormItem] = []
class PublicProfile(BaseModel):
# Профиль другого игрока (read-only): шапка + глобальная статистика.
user_id: int
nickname: str
bio: str | None = None
avatar_url: str | None = None
stats: ProfileStats
class FactionMeta(BaseModel):
faction_id: int
code: str
@@ -388,6 +410,27 @@ class AdminMatchRead(BaseModel):
created_at: str
class AchievementRead(BaseModel):
slug: str
name: str
description: str = ""
condition: str = "" # текст condition.py (задел; не исполняется)
has_condition: bool = False
icon_url: str | None = None
class AchievementCreate(BaseModel):
name: str
description: str | None = None
condition: str | None = None
class AchievementUpdate(BaseModel):
name: str | None = None
description: str | None = None
condition: str | None = None
class AuditLogItem(BaseModel):
id: int
actor_id: int | None = None
+194
View File
@@ -0,0 +1,194 @@
"""Ачивки: определения хранятся файлами на диске (settings.achievements_dir).
Папка на ачивку: achievements/<slug>/
meta.json — {"name", "description", "created_at"}
condition.py — скрипт-условие выдачи (ЗАДЕЛ: только хранится как текст, НЕ исполняется)
icon.<ext> — картинка-герб (png/jpg/webp), опционально
Источник правды — файлы. В БД (user_achievements / users.title_achievement_slug) — только
будущая выдача игрокам (сейчас не используется). Выдача и исполнение condition.py — следующий
этап; здесь админ лишь создаёт/именует/описывает ачивки.
"""
from __future__ import annotations
import json
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from app.core.config import settings
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.services.user_service import avatar_media_type, sniff_image_ext
_CONDITION_FILE = "condition.py"
_META_FILE = "meta.json"
_CONDITION_PLACEHOLDER = (
"# Условие выдачи ачивки (ЗАДЕЛ на будущее — пока не исполняется).\n"
"# Здесь будет описана логика автоматической выдачи этого титула игроку.\n"
)
# Транслитерация для slug из русского названия.
_TRANSLIT = {
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh",
"з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o",
"п": "p", "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "h", "ц": "ts",
"ч": "ch", "ш": "sh", "щ": "sch", "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu",
"я": "ya",
}
def _root() -> Path:
return Path(settings.achievements_dir)
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("-")
return slug[:48]
def _dir(slug: str) -> Path:
return _root() / slug
def _icon_path(slug: str) -> Path | None:
folder = _dir(slug)
if not folder.is_dir():
return None
for f in sorted(folder.glob("icon.*")):
if f.is_file():
return f
return None
def _read(slug: str) -> dict:
folder = _dir(slug)
meta_file = folder / _META_FILE
if not meta_file.is_file():
raise NotFoundError("Ачивка не найдена.")
meta = json.loads(meta_file.read_text(encoding="utf-8"))
condition = ""
cond_file = folder / _CONDITION_FILE
if cond_file.is_file():
condition = cond_file.read_text(encoding="utf-8")
icon = _icon_path(slug)
icon_url = None
if icon is not None:
version = int(icon.stat().st_mtime)
icon_url = f"/api/achievements/{slug}/icon?v={version}"
return {
"slug": slug,
"name": meta.get("name", slug),
"description": meta.get("description", ""),
"condition": condition,
"has_condition": bool(condition.strip()),
"icon_url": icon_url,
}
def list_achievements() -> list[dict]:
root = _root()
if not root.is_dir():
return []
out = []
for folder in sorted(root.iterdir()):
if folder.is_dir() and (folder / _META_FILE).is_file():
out.append(_read(folder.name))
return out
def get(slug: str) -> dict:
return _read(slug)
def _validate_name(name: str) -> str:
name = (name or "").strip()
if not (1 <= len(name) <= 64):
raise ValidationError("Название ачивки: 1–64 символа.")
return name
def create(name: str, description: str | None, condition: str | None) -> dict:
name = _validate_name(name)
slug = _slugify(name)
if not slug:
raise ValidationError("Не удалось сформировать идентификатор из названия.")
folder = _dir(slug)
if folder.exists():
raise ConflictError("Ачивка с таким названием уже существует.")
folder.mkdir(parents=True, exist_ok=True)
_write_meta(slug, name, (description or "").strip())
(folder / _CONDITION_FILE).write_text(
condition if condition else _CONDITION_PLACEHOLDER, encoding="utf-8"
)
return _read(slug)
def update(
slug: str,
*,
name: str | None = None,
description: str | None = None,
condition: str | None = None,
) -> dict:
folder = _dir(slug)
meta_file = folder / _META_FILE
if not meta_file.is_file():
raise NotFoundError("Ачивка не найдена.")
meta = json.loads(meta_file.read_text(encoding="utf-8"))
# slug не меняем (он стабильный идентификатор/путь), даже если правят название.
new_name = _validate_name(name) if name is not None else meta.get("name", slug)
new_desc = description.strip() if description is not None else meta.get("description", "")
_write_meta(slug, new_name, new_desc, created_at=meta.get("created_at"))
if condition is not None:
(folder / _CONDITION_FILE).write_text(condition, encoding="utf-8")
return _read(slug)
def set_icon(slug: str, content: bytes, ext: str) -> dict:
folder = _dir(slug)
if not (folder / _META_FILE).is_file():
raise NotFoundError("Ачивка не найдена.")
old = _icon_path(slug)
if old is not None:
try:
old.unlink()
except OSError:
pass
(folder / f"icon.{ext}").write_bytes(content)
return _read(slug)
def icon_file(slug: str) -> tuple[Path, str]:
"""Путь к файлу иконки и его media_type; NotFound, если иконки нет."""
icon = _icon_path(slug)
if icon is None:
raise NotFoundError("Иконка не найдена.")
return icon, avatar_media_type(icon.suffix.lstrip("."))
def delete(slug: str) -> None:
folder = _dir(slug)
if not folder.is_dir():
raise NotFoundError("Ачивка не найдена.")
shutil.rmtree(folder)
def validate_icon(content: bytes) -> str:
"""Проверяет, что это поддерживаемая картинка, и возвращает расширение."""
ext = sniff_image_ext(content)
if ext is None:
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
return ext
def _write_meta(slug: str, name: str, description: str, created_at: str | None = None) -> None:
meta = {
"name": name,
"description": description,
"created_at": created_at or datetime.now(timezone.utc).isoformat(),
}
(_dir(slug) / _META_FILE).write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
)
+17 -8
View File
@@ -5,10 +5,16 @@ from typing import Any
from sqlmodel import Session, select
from app.core.errors import InvalidCredentialsError, NotFoundError, ValidationError
from app.core.errors import (
InvalidCredentialsError,
NicknameTakenError,
NotFoundError,
ValidationError,
)
from app.core.security import verify_password
from app.core.timeutil import iso_utc
from app.models import AuditLog, Faction, Group, Match, MatchParticipant, User
from app.services import user_service
def authenticate_admin(session: Session, username: str, password: str) -> User:
@@ -39,7 +45,14 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
if user is None:
raise NotFoundError("Пользователь не найден.")
if nickname is not None:
user.nickname = nickname.strip()
nickname = nickname.strip()
# Та же валидация, что и для самостоятельной смены ника игроком:
# корректный формат и уникальность (иначе занятый ник упал бы в 500).
if not user_service.nickname_format_ok(nickname):
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
if not user_service.nickname_available(session, nickname, exclude_user_id=user_id):
raise NicknameTakenError()
user.nickname = nickname
if is_active is not None:
user.is_active = is_active
session.add(user)
@@ -48,12 +61,8 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
return user
def delete_user(session: Session, user_id: int) -> None:
user = session.get(User, user_id)
if user is None:
raise NotFoundError("Пользователь не найден.")
session.delete(user)
session.commit()
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
# ─── Группы ──────────────────────────────────────────────────────────────────
+8 -1
View File
@@ -49,6 +49,10 @@ def _round(value: Any, ndigits: int) -> float | None:
def _normalize(row: dict) -> dict:
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL.
avatar_url = None
if row.get("avatar_path"):
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}"
return {
"user_id": row["user_id"],
"nickname": row["nickname"],
@@ -57,6 +61,7 @@ def _normalize(row: dict) -> dict:
"win_rate": _round(row["win_rate"] or 0.0, 4),
"avg_place": _round(row["avg_place"], 2),
"score": _round(row["score"], 1),
"avatar_url": avatar_url,
}
@@ -65,6 +70,8 @@ def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
sql = f"""
{SCORED_CTE}
SELECT u.id AS user_id, u.nickname AS nickname,
u.avatar_path AS avatar_path,
CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version,
COUNT(*) AS games,
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
@@ -73,7 +80,7 @@ def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
FROM scored s
JOIN users u ON u.id = s.user_id
{where}
GROUP BY u.id, u.nickname
GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at
"""
params = {"gid": group_id} if group_id is not None else {}
result = session.execute(text(sql), params).mappings().all()
+145 -5
View File
@@ -1,15 +1,24 @@
"""Пользователи: создание из внешней личности, ник, активная группа."""
"""Пользователи: создание из внешней личности, ник, активная группа, профиль."""
from __future__ import annotations
import os
import re
from datetime import datetime
from pathlib import Path
from sqlmodel import Session, select
from app.auth.provider import ExternalIdentity
from app.core.config import settings
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
from app.models import AuthIdentity, GroupMember, User
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
_BIO_MAX = 500
# Аватары: файл на диске под settings.upload_dir/avatars, путь относительный.
_AVATAR_SUBDIR = "avatars"
_IMAGE_MEDIA = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
def get_user(session: Session, user_id: int) -> User:
@@ -25,6 +34,11 @@ def nickname_available(session: Session, nickname: str, exclude_user_id: int | N
return existing is None or existing.id == exclude_user_id
def nickname_format_ok(nickname: str) -> bool:
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
return bool(_NICK_RE.match((nickname or "").strip()))
def _unique_nickname(session: Session, base: str) -> str:
base = (base or "Игрок").strip()[:60] or "Игрок"
candidate = base
@@ -35,18 +49,24 @@ def _unique_nickname(session: Session, base: str) -> str:
return candidate
def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User:
def find_by_identity(session: Session, identity: ExternalIdentity) -> User | None:
"""Существующий пользователь по внешней личности (provider+external_id).
Для Telegram external_id — это НЕИЗМЕНЯЕМЫЙ числовой id аккаунта (не @тег), поэтому
вход с любого устройства/после смены тега ведёт к одному и тому же пользователю.
"""
link = session.exec(
select(AuthIdentity).where(
AuthIdentity.provider == identity.provider,
AuthIdentity.external_id == identity.external_id,
)
).first()
if link is not None:
return get_user(session, link.user_id)
return get_user(session, link.user_id) if link is not None else None
def _create_from_identity(session: Session, identity: ExternalIdentity, nickname: str) -> User:
user = User(
nickname=_unique_nickname(session, identity.suggested_nickname or identity.external_id),
nickname=nickname,
role="player",
auth_provider=identity.provider,
telegram_id=identity.telegram_id,
@@ -65,6 +85,31 @@ def get_or_create_from_identity(session: Session, identity: ExternalIdentity) ->
return user
def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User:
"""Найти по личности или создать, авто-подбирая уникальный ник (dev-вход по нику)."""
user = find_by_identity(session, identity)
if user is not None:
return user
nickname = _unique_nickname(session, identity.suggested_nickname or identity.external_id)
return _create_from_identity(session, identity, nickname)
def register_from_identity(
session: Session, identity: ExternalIdentity, nickname: str
) -> User:
"""Создать пользователя с ЯВНО выбранным ником (регистрация через Telegram).
В отличие от get_or_create — ник не подбирается автоматически: занятый/неподходящий
ник вызывает ошибку, чтобы пользователь выбрал другой.
"""
nickname = (nickname or "").strip()
if not nickname_format_ok(nickname):
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
if not nickname_available(session, nickname):
raise NicknameTakenError()
return _create_from_identity(session, identity, nickname)
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
new_nickname = (new_nickname or "").strip()
if not _NICK_RE.match(new_nickname):
@@ -92,3 +137,98 @@ def set_active_group(session: Session, user: User, group_id: int | None) -> User
session.commit()
session.refresh(user)
return user
# ─── Профиль: «о себе», аватар ────────────────────────────────────────────────
def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime | None) -> str | None:
"""URL аватара с кэш-бастингом по updated_at. None, если аватара нет.
Один источник для build_me, публичного профиля и лидерборда — чтобы клиент
подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
if not avatar_path:
return None
version = int(updated_at.timestamp()) if updated_at else 0
return f"/api/users/{user_id}/avatar?v={version}"
def update_bio(session: Session, user: User, bio: str | None) -> User:
bio = (bio or "").strip()
if len(bio) > _BIO_MAX:
raise ValidationError(f"«О себе»: не более {_BIO_MAX} символов.")
user.bio = bio or None
session.add(user)
session.commit()
session.refresh(user)
return user
def sniff_image_ext(content: bytes) -> str | None:
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return "png"
if content.startswith(b"\xff\xd8\xff"):
return "jpg"
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
return "webp"
return None
def avatar_media_type(ext: str) -> str:
return _IMAGE_MEDIA.get(ext, "application/octet-stream")
def avatar_abs_path(avatar_path: str | None) -> Path | None:
"""Абсолютный путь файла аватара (или None), для отдачи через FileResponse."""
if not avatar_path:
return None
return Path(settings.upload_dir) / avatar_path
def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
avatars_dir = Path(settings.upload_dir) / _AVATAR_SUBDIR
avatars_dir.mkdir(parents=True, exist_ok=True)
# Удаляем прежний файл (мог быть с другим расширением), затем пишем новый.
old = avatar_abs_path(user.avatar_path)
if old and old.exists():
try:
old.unlink()
except OSError:
pass
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
(Path(settings.upload_dir) / rel).write_bytes(content)
user.avatar_path = rel
session.add(user)
session.commit()
session.refresh(user)
return user
def clear_avatar(session: Session, user: User) -> User:
path = avatar_abs_path(user.avatar_path)
if path and path.exists():
try:
path.unlink()
except OSError:
pass
user.avatar_path = None
session.add(user)
session.commit()
session.refresh(user)
return user
def public_profile(session: Session, user_id: int) -> dict:
"""Публичный профиль игрока: ник/«о себе»/аватар + глобальная статистика."""
from app.services import stats_service # ленивый импорт (избегаем цикла)
user = session.get(User, user_id)
if user is None:
raise NotFoundError("Пользователь не найден.")
return {
"user_id": user.id,
"nickname": user.nickname,
"bio": user.bio,
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
"stats": stats_service.profile_stats(session, user_id, group_id=None),
}
+113
View File
@@ -0,0 +1,113 @@
"""Ачивки: админ создаёт/правит/удаляет, грузит иконку; condition хранится как текст."""
from __future__ import annotations
import json
from pathlib import Path
from fastapi.testclient import TestClient
from tests.conftest import csrf_headers
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
def _admin(client: TestClient, make_admin) -> None:
make_admin("admin", "secret123")
r = client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
def _use_tmp_achievements(monkeypatch, tmp_path) -> Path:
from app.core.config import settings
monkeypatch.setattr(settings, "dev_achievements_dir", str(tmp_path))
return tmp_path
def test_create_list_and_files(client: TestClient, make_admin, monkeypatch, tmp_path):
root = _use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
r = client.post(
"/api/admin/achievements",
json={"name": "Великий полководец", "description": "10 побед подряд"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
ach = r.json()
slug = ach["slug"]
assert slug and ach["name"] == "Великий полководец"
# На диске создана папка с meta.json и condition.py (задел, не исполняется).
folder = root / slug
assert (folder / "meta.json").is_file()
assert (folder / "condition.py").is_file()
meta = json.loads((folder / "meta.json").read_text(encoding="utf-8"))
assert meta["name"] == "Великий полководец"
# Виден в админском списке (редактор использует именно его).
assert any(a["slug"] == slug for a in client.get("/api/admin/achievements").json())
def test_duplicate_name_conflicts(client: TestClient, make_admin, monkeypatch, tmp_path):
_use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
body = {"name": "Берсерк"}
assert client.post("/api/admin/achievements", json=body, headers=csrf_headers(client)).status_code == 200
r = client.post("/api/admin/achievements", json=body, headers=csrf_headers(client))
assert r.status_code == 409, r.text
def test_icon_upload_and_serve(client: TestClient, make_admin, monkeypatch, tmp_path):
_use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
slug = client.post(
"/api/admin/achievements", json={"name": "Герой"}, headers=csrf_headers(client)
).json()["slug"]
# Не картинка → 422.
bad = client.put(
f"/api/admin/achievements/{slug}/icon",
files={"file": ("x.txt", b"nope", "text/plain")},
headers=csrf_headers(client),
)
assert bad.status_code == 422, bad.text
# Валидный PNG → icon_url, файл отдаётся.
ok = client.put(
f"/api/admin/achievements/{slug}/icon",
files={"file": ("h.png", PNG, "image/png")},
headers=csrf_headers(client),
)
assert ok.status_code == 200 and ok.json()["icon_url"], ok.text
g = client.get(f"/api/achievements/{slug}/icon")
assert g.status_code == 200 and g.content == PNG
def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path):
_use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
slug = client.post(
"/api/admin/achievements", json={"name": "Стратег"}, headers=csrf_headers(client)
).json()["slug"]
# Правка описания и текста условия (condition хранится как текст).
r = client.patch(
f"/api/admin/achievements/{slug}",
json={"description": "Победа по целям", "condition": "wins_by_objectives >= 5"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
assert r.json()["description"] == "Победа по целям"
assert r.json()["has_condition"] is True
assert "wins_by_objectives" in r.json()["condition"]
# Удаление.
assert client.delete(
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())
+81
View File
@@ -106,6 +106,87 @@ def test_admin_edit_match_and_filters(client: TestClient, make_admin):
assert bp["faction_name"] == "Хаоситы"
def test_admin_rename_account(client: TestClient, make_admin):
"""Админ переименовывает аккаунт: валидируется формат и уникальность ника."""
a = login(client, "Старое")
client.post("/api/auth/dev/users", json={"nickname": "Занятое"}, headers=csrf_headers(client))
_admin_login(client, make_admin)
r = client.patch(
f"/api/admin/users/{a['id']}", json={"nickname": "Новое"}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
assert r.json()["nickname"] == "Новое"
# Занятый ник → 409, некорректный формат → 422.
r = client.patch(
f"/api/admin/users/{a['id']}", json={"nickname": "Занятое"}, headers=csrf_headers(client)
)
assert r.status_code == 409 and r.json()["error"]["code"] == "NICKNAME_TAKEN", r.text
r = client.patch(
f"/api/admin/users/{a['id']}", json={"nickname": "x"}, headers=csrf_headers(client)
)
assert r.status_code == 422, r.text
def test_dev_delete_account_keeps_matches(client: TestClient, make_admin):
"""DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается),
его созданные партии и группы переназначаются на админа."""
me = login(client, "Аня") # владелец группы и создатель партии
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client))
b = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
).json()["user_id"]
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
mid = create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
{"user_id": b, "faction_id": factions["eldar"], "place": 2},
],
)["id"]
admin_id = make_admin("admin", "secret123")
r = client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
# Удаляем участника Борю → партия жива, его строка участия вычеркнута.
r = client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client))
assert r.status_code == 200, r.text
assert all(u["id"] != b for u in client.get("/api/admin/users").json())
detail = client.get(f"/api/admin/matches/{mid}").json()
assert [p["user_id"] for p in detail["participants"]] == [me["id"]] # Боря вычеркнут
assert detail["player_count"] == 2 # player_count НЕ пересчитывается (осознанно)
# Удаляем создателя/владельца Аню → партия и группа сохраняются, переходят к админу.
r = client.delete(f"/api/admin/dev/users/{me['id']}", headers=csrf_headers(client))
assert r.status_code == 200, r.text
assert client.get(f"/api/admin/matches/{mid}").json()["created_by"] == admin_id
grp = next(g for g in client.get("/api/admin/groups").json() if g["id"] == gid)
assert grp["owner_id"] == admin_id
def test_dev_delete_account_rejects_admin(client: TestClient, make_admin):
"""Админа удалить нельзя."""
admin_id = make_admin("admin", "secret123")
r = client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
r = client.delete(f"/api/admin/dev/users/{admin_id}", headers=csrf_headers(client))
assert r.status_code == 422, r.text
def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
me = login(client, "Кто-то")
gid = client.post(
+61
View File
@@ -79,3 +79,64 @@ def test_telegram_login_bad_signature_rejected(client: TestClient, monkeypatch):
r = client.post("/api/auth/telegram", json=payload)
assert r.status_code == 401
_ = csrf_headers # (для единообразия импорта)
def test_telegram_same_id_no_duplicate_account(client: TestClient, monkeypatch):
"""Вход с двух устройств / после смены тега — один аккаунт (ключ = числовой id)."""
from app.core.config import settings
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
r1 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="firsttag"))
assert r1.status_code == 200, r1.text
client.cookies.clear() # «другое устройство» — без сессии
# Тот же telegram id (777), но другой тег и свежий auth_date.
r2 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="changedtag"))
assert r2.status_code == 200, r2.text
assert r2.json()["id"] == r1.json()["id"] # тот же пользователь
assert r2.json()["nickname"] == r1.json()["nickname"] # ник не пересоздаётся
def test_telegram_register_nickname_collision_then_pick(client: TestClient, engine, monkeypatch):
"""Первый вход через ТГ, тег совпал с чужим ником → просим выбрать ник → регистрируем."""
from sqlmodel import Session
from app.core.config import settings
from app.models import User
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
with Session(engine) as s: # существующий игрок с ником = тегу из payload (ivan_tg)
s.add(User(nickname="ivan_tg", role="player", auth_provider="stub"))
s.commit()
payload = _telegram_payload("TEST_BOT_TOKEN") # id=777, username=ivan_tg
r = client.post("/api/auth/telegram", json=payload)
assert r.status_code == 409, r.text
err = r.json()["error"]
assert err["code"] == "TELEGRAM_NICKNAME_REQUIRED"
assert err["details"]["suggested"] == "ivan_tg"
# Повтор с выбранным свободным ником (та же подпись виджета).
r2 = client.post("/api/auth/telegram/register", json={**payload, "nickname": "Иван-новый"})
assert r2.status_code == 200, r2.text
assert r2.json()["nickname"] == "Иван-новый"
assert r2.json()["telegram_id"] == 777
def test_telegram_register_rejects_taken_nickname(client: TestClient, engine, monkeypatch):
"""На шаге выбора ника всё ещё занятый ник отклоняется (NICKNAME_TAKEN)."""
from sqlmodel import Session
from app.core.config import settings
from app.models import User
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
with Session(engine) as s:
s.add(User(nickname="ivan_tg", role="player", auth_provider="stub"))
s.add(User(nickname="занятый", role="player", auth_provider="stub"))
s.commit()
payload = _telegram_payload("TEST_BOT_TOKEN")
r = client.post("/api/auth/telegram/register", json={**payload, "nickname": "занятый"})
assert r.status_code == 409, r.text
assert r.json()["error"]["code"] == "NICKNAME_TAKEN"
+129
View File
@@ -0,0 +1,129 @@
"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import create_finished_match, csrf_headers, login
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
def _use_tmp_uploads(monkeypatch, tmp_path) -> None:
from app.core.config import settings
monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path))
def _finished_match_for(client: TestClient, me: dict) -> int:
"""Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд)."""
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Соперник"}, headers=csrf_headers(client))
p2 = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Соперник"}, headers=csrf_headers(client)
).json()["user_id"]
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},
],
)
return gid
def test_update_bio(client: TestClient):
login(client, "Игрок")
r = client.patch("/api/users/me/profile", json={"bio": "Люблю орков"}, headers=csrf_headers(client))
assert r.status_code == 200, r.text
assert r.json()["bio"] == "Люблю орков"
# Слишком длинное «о себе» → 422.
r = client.patch(
"/api/users/me/profile", json={"bio": "x" * 501}, headers=csrf_headers(client)
)
assert r.status_code == 422, r.text
def test_avatar_upload_get_delete(client: TestClient, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path)
me = login(client, "Аватарыч")
# Загрузка валидного PNG → avatar_url выставлен.
r = client.put(
"/api/users/me/avatar",
files={"file": ("a.png", PNG, "image/png")},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
avatar_url = r.json()["avatar_url"]
assert avatar_url and "/avatar?v=" in avatar_url
# Отдача файла.
g = client.get(f"/api/users/{me['id']}/avatar")
assert g.status_code == 200, g.text
assert g.content == PNG
assert g.headers["content-type"] == "image/png"
# Удаление → avatar_url пропадает, файл больше не отдаётся.
r = client.delete("/api/users/me/avatar", headers=csrf_headers(client))
assert r.status_code == 200 and r.json()["avatar_url"] is None, r.text
assert client.get(f"/api/users/{me['id']}/avatar").status_code == 404
def test_avatar_rejects_non_image_and_oversize(client: TestClient, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path)
login(client, "Игрок")
r = client.put(
"/api/users/me/avatar",
files={"file": ("x.txt", b"not an image", "text/plain")},
headers=csrf_headers(client),
)
assert r.status_code == 422, r.text
big = PNG + b"\x00" * (2 * 1024 * 1024 + 10)
r = client.put(
"/api/users/me/avatar",
files={"file": ("big.png", big, "image/png")},
headers=csrf_headers(client),
)
assert r.status_code == 422, r.text
def test_public_profile(client: TestClient):
me = login(client, "Публичный")
client.patch("/api/users/me/profile", json={"bio": "Привет"}, headers=csrf_headers(client))
r = client.get(f"/api/users/{me['id']}/profile")
assert r.status_code == 200, r.text
body = r.json()
assert body["nickname"] == "Публичный"
assert body["bio"] == "Привет"
assert body["avatar_url"] is None
assert "overall" in body["stats"]
assert client.get("/api/users/999999/profile").status_code == 404
def test_leaderboard_includes_avatar_url(client: TestClient, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path)
me = login(client, "Топчик")
_finished_match_for(client, me)
def entry_for(uid: int) -> dict:
board = client.get("/api/stats/leaderboard").json()
return next(e for e in board["entries"] + board["provisional"] if e["user_id"] == uid)
assert entry_for(me["id"])["avatar_url"] is None # без аватара — null
client.put(
"/api/users/me/avatar",
files={"file": ("a.png", PNG, "image/png")},
headers=csrf_headers(client),
)
assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка
+2
View File
@@ -23,6 +23,7 @@ services:
volumes:
- db-data-test:/data # изолированные тестовые данные
- uploads-data-test:/data/uploads
- achievements-data-test:/data/achievements # определения ачивок (файлы)
healthcheck:
test:
- CMD
@@ -58,3 +59,4 @@ services:
volumes:
db-data-test:
uploads-data-test:
achievements-data-test:
+2
View File
@@ -15,6 +15,7 @@ services:
volumes:
- db-data:/data # БД SQLite + WAL-сайдкары
- uploads-data:/data/uploads # задел под фото/видео
- achievements-data:/data/achievements # определения ачивок (файлы)
healthcheck:
test:
- CMD
@@ -56,3 +57,4 @@ services:
volumes:
db-data:
uploads-data:
achievements-data:
+2
View File
@@ -6,6 +6,7 @@ export const qk = {
expansions: ["expansions"] as const,
factions: ["factions"] as const,
myStats: ["myStats"] as const,
publicProfile: (id: number) => ["publicProfile", id] as const,
groups: ["groups"] as const,
group: (id: number) => ["group", id] as const,
groupFactions: (id: number) => ["group", id, "factions"] as const,
@@ -19,4 +20,5 @@ export const qk = {
adminGroups: ["adminGroups"] as const,
adminMatches: ["adminMatches"] as const,
adminLogs: ["adminLogs"] as const,
adminAchievements: ["adminAchievements"] as const,
};
+724 -34
View File
@@ -30,7 +30,15 @@ export interface paths {
};
get?: never;
put?: never;
/** Telegram Login */
/**
* Telegram Login
* @description Вход/регистрация через Telegram.
*
* Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход —
* регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409
* TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register
* с выбранным ником.
*/
post: operations["telegram_login_api_auth_telegram_post"];
delete?: never;
options?: never;
@@ -38,6 +46,30 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/auth/telegram/register": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Telegram Register
* @description Завершение регистрации через Telegram с выбранным ником.
*
* Подпись виджета проверяется заново (поле nickname в HMAC не входит), поэтому личность
* доверенная — подделать чужой telegram_id нельзя. Если пользователь уже успел
* зарегистрироваться (гонка) — просто входим.
*/
post: operations["telegram_register_api_auth_telegram_register_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/logout": {
parameters: {
query?: never;
@@ -73,6 +105,75 @@ export interface paths {
patch: operations["update_me_api_users_me_patch"];
trace?: never;
};
"/api/users/me/profile": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** Update My Profile */
patch: operations["update_my_profile_api_users_me_profile_patch"];
trace?: never;
};
"/api/users/me/avatar": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** Upload My Avatar */
put: operations["upload_my_avatar_api_users_me_avatar_put"];
post?: never;
/** Delete My Avatar */
delete: operations["delete_my_avatar_api_users_me_avatar_delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/{user_id}/avatar": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get User Avatar */
get: operations["get_user_avatar_api_users__user_id__avatar_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/{user_id}/profile": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get User Profile */
get: operations["get_user_profile_api_users__user_id__profile_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/me/stats": {
parameters: {
query?: never;
@@ -408,6 +509,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/achievements": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Achievements */
get: operations["list_achievements_api_achievements_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/achievements/{slug}/icon": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Icon */
get: operations["get_icon_api_achievements__slug__icon_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/login": {
parameters: {
query?: never;
@@ -486,8 +621,7 @@ export interface paths {
get?: never;
put?: never;
post?: never;
/** Delete User */
delete: operations["delete_user_api_admin_users__user_id__delete"];
delete?: never;
options?: never;
head?: never;
/** Update User */
@@ -598,6 +732,59 @@ export interface paths {
patch: operations["rename_faction_api_admin_factions__faction_id__patch"];
trace?: never;
};
"/api/admin/achievements": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Achievements */
get: operations["list_achievements_api_admin_achievements_get"];
put?: never;
/** Create Achievement */
post: operations["create_achievement_api_admin_achievements_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/achievements/{slug}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete Achievement */
delete: operations["delete_achievement_api_admin_achievements__slug__delete"];
options?: never;
head?: never;
/** Update Achievement */
patch: operations["update_achievement_api_admin_achievements__slug__patch"];
trace?: never;
};
"/api/admin/achievements/{slug}/icon": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** Upload Achievement Icon */
put: operations["upload_achievement_icon_api_admin_achievements__slug__icon_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/audit-logs": {
parameters: {
query?: never;
@@ -650,6 +837,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/dev/users/{user_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete User Hard */
delete: operations["delete_user_hard_api_admin_dev_users__user_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/health": {
parameters: {
query?: never;
@@ -671,6 +875,48 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** AchievementCreate */
AchievementCreate: {
/** Name */
name: string;
/** Description */
description?: string | null;
/** Condition */
condition?: string | null;
};
/** AchievementRead */
AchievementRead: {
/** Slug */
slug: string;
/** Name */
name: string;
/**
* Description
* @default
*/
description: string;
/**
* Condition
* @default
*/
condition: string;
/**
* Has Condition
* @default false
*/
has_condition: boolean;
/** Icon Url */
icon_url?: string | null;
};
/** AchievementUpdate */
AchievementUpdate: {
/** Name */
name?: string | null;
/** Description */
description?: string | null;
/** Condition */
condition?: string | null;
};
/** ActiveGroupUpdate */
ActiveGroupUpdate: {
/** Group Id */
@@ -795,6 +1041,16 @@ export interface components {
/** Telegram Bot Username */
telegram_bot_username?: string | null;
};
/** Body_upload_achievement_icon_api_admin_achievements__slug__icon_put */
Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: {
/** File */
file: string;
};
/** Body_upload_my_avatar_api_users_me_avatar_put */
Body_upload_my_avatar_api_users_me_avatar_put: {
/** File */
file: string;
};
/** DevLogin */
DevLogin: {
/** Nickname */
@@ -1049,6 +1305,8 @@ export interface components {
nickname: string;
/** Rank */
rank?: number | null;
/** Avatar Url */
avatar_url?: string | null;
};
/** MatchCreate */
MatchCreate: {
@@ -1224,6 +1482,10 @@ export interface components {
telegram_id?: number | null;
/** Active Group Id */
active_group_id?: number | null;
/** Bio */
bio?: string | null;
/** Avatar Url */
avatar_url?: string | null;
/**
* Groups
* @default []
@@ -1315,6 +1577,23 @@ export interface components {
*/
recent_form: components["schemas"]["RecentFormItem"][];
};
/** ProfileUpdate */
ProfileUpdate: {
/** Bio */
bio?: string | null;
};
/** PublicProfile */
PublicProfile: {
/** User Id */
user_id: number;
/** Nickname */
nickname: string;
/** Bio */
bio?: string | null;
/** Avatar Url */
avatar_url?: string | null;
stats: components["schemas"]["ProfileStats"];
};
/** RandomizeRequest */
RandomizeRequest: {
/** Group Id */
@@ -1369,6 +1648,27 @@ export interface components {
} & {
[key: string]: unknown;
};
/** TelegramRegister */
TelegramRegister: {
/** Id */
id: number;
/** Auth Date */
auth_date: number;
/** Hash */
hash: string;
/** First Name */
first_name?: string | null;
/** Last Name */
last_name?: string | null;
/** Username */
username?: string | null;
/** Photo Url */
photo_url?: string | null;
/** Nickname */
nickname: string;
} & {
[key: string]: unknown;
};
/** UserRead */
UserRead: {
/** Id */
@@ -1383,6 +1683,10 @@ export interface components {
telegram_id?: number | null;
/** Active Group Id */
active_group_id?: number | null;
/** Bio */
bio?: string | null;
/** Avatar Url */
avatar_url?: string | null;
};
/** ValidationError */
ValidationError: {
@@ -1459,6 +1763,39 @@ export interface operations {
};
};
};
telegram_register_api_auth_telegram_register_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TelegramRegister"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
logout_api_auth_logout_post: {
parameters: {
query?: never;
@@ -1532,6 +1869,154 @@ export interface operations {
};
};
};
update_my_profile_api_users_me_profile_patch: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ProfileUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
upload_my_avatar_api_users_me_avatar_put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_my_avatar_api_users_me_avatar_put"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_my_avatar_api_users_me_avatar_delete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeRead"];
};
};
};
};
get_user_avatar_api_users__user_id__avatar_get: {
parameters: {
query?: never;
header?: never;
path: {
user_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_user_profile_api_users__user_id__profile_get: {
parameters: {
query?: never;
header?: never;
path: {
user_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PublicProfile"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
my_stats_api_users_me_stats_get: {
parameters: {
query?: never;
@@ -2289,6 +2774,57 @@ export interface operations {
};
};
};
list_achievements_api_achievements_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"][];
};
};
};
};
get_icon_api_achievements__slug__icon_get: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
admin_login_api_admin_auth_login_post: {
parameters: {
query?: never;
@@ -2393,37 +2929,6 @@ export interface operations {
};
};
};
delete_user_api_admin_users__user_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
user_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_user_api_admin_users__user_id__patch: {
parameters: {
query?: never;
@@ -2697,6 +3202,160 @@ export interface operations {
};
};
};
list_achievements_api_admin_achievements_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"][];
};
};
};
};
create_achievement_api_admin_achievements_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AchievementCreate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_achievement_api_admin_achievements__slug__delete: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_achievement_api_admin_achievements__slug__patch: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AchievementUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
upload_achievement_icon_api_admin_achievements__slug__icon_put: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_achievement_icon_api_admin_achievements__slug__icon_put"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
audit_logs_api_admin_audit_logs_get: {
parameters: {
query?: {
@@ -2817,6 +3476,37 @@ export interface operations {
};
};
};
delete_user_hard_api_admin_dev_users__user_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
user_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
health_api_health_get: {
parameters: {
query?: never;
+6
View File
@@ -9,7 +9,10 @@ import { GroupSettingsPage } from "../pages/GroupSettingsPage";
import { HomePage } from "../pages/HomePage";
import { LoginPage } from "../pages/LoginPage";
import { MatchDetailPage } from "../pages/MatchDetailPage";
import { OverallStatsPage } from "../pages/OverallStatsPage";
import { PublicProfilePage } from "../pages/PublicProfilePage";
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
import { AdminLayout } from "../pages/admin/AdminLayout";
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
@@ -37,6 +40,8 @@ export const router = createBrowserRouter([
),
},
{ path: "match/:matchId", element: <MatchDetailPage /> },
{ path: "stats", element: <OverallStatsPage /> },
{ path: "u/:userId", element: <PublicProfilePage /> },
{ path: "group", element: <GroupPage /> },
{
path: "group/settings",
@@ -61,6 +66,7 @@ export const router = createBrowserRouter([
{ path: "records", element: <AdminRecordsPage /> },
{ path: "accounts", element: <AdminAccountsPage /> },
{ path: "factions", element: <AdminFactionsPage /> },
{ path: "achievements", element: <AdminAchievementsPage /> },
{ path: "logs", element: <AdminLogsPage /> },
],
},
+4 -1
View File
@@ -8,12 +8,15 @@ const TITLES: Record<string, string> = {
"/group/settings": "Настройки группы",
"/account": "Аккаунт",
"/match/new": "Новая партия",
"/stats": "Общая статистика",
};
export function AppShell() {
const location = useLocation();
const navigate = useNavigate();
const title = TITLES[location.pathname] ?? "Forbidden Stars";
const title =
TITLES[location.pathname] ??
(location.pathname.startsWith("/u/") ? "Профиль игрока" : "Forbidden Stars");
const isHome = location.pathname === "/";
return (
+52
View File
@@ -0,0 +1,52 @@
import type { CSSProperties } from "react";
/**
* Чисто презентационный аватар: картинка по url или инициал ника как заглушка.
* Используется и в своём, и в чужом профиле, и в списках (общий примитив без логики).
*/
export function Avatar({
url,
nickname,
size = 40,
}: {
url?: string | null;
nickname: string;
size?: number;
}) {
const box: CSSProperties = {
width: size,
height: size,
borderRadius: "50%",
flexShrink: 0,
};
if (url) {
return (
<img
src={url}
alt={nickname}
width={size}
height={size}
style={{ ...box, objectFit: "cover", border: "1px solid var(--border)" }}
/>
);
}
const initial = (nickname.trim()[0] ?? "?").toUpperCase();
return (
<div
aria-hidden
style={{
...box,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--surface-2)",
border: "1px solid var(--border)",
color: "var(--text-muted)",
fontWeight: 700,
fontSize: size * 0.42,
}}
>
{initial}
</div>
);
}
+12 -1
View File
@@ -1,4 +1,7 @@
import { Link } from "react-router-dom";
import type { LeaderboardEntry } from "../domain/types";
import { Avatar } from "./Avatar";
function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`;
@@ -10,8 +13,16 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
<div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}>
{entry.rank ?? "—"}
</div>
<Avatar url={entry.avatar_url} nickname={entry.nickname} size={32} />
<div>
<div style={{ fontWeight: 600 }}>{entry.nickname}</div>
<Link
to={`/u/${entry.user_id}`}
// stopPropagation: ник кликабелен даже когда вся карточка-обёртка кликабельна.
onClick={(e) => e.stopPropagation()}
style={{ fontWeight: 600, color: "inherit" }}
>
{entry.nickname}
</Link>
<div className="lb-metrics">
<span>Игр: {entry.games}</span>
<span>Побед: {entry.wins}</span>
+8 -2
View File
@@ -4,11 +4,17 @@ function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`;
}
export function ProfileStatsCard({ stats }: { stats: ProfileStats }) {
export function ProfileStatsCard({
stats,
title = "Моя статистика",
}: {
stats: ProfileStats;
title?: string;
}) {
const o = stats.overall;
return (
<div className="card">
<h3>Моя статистика</h3>
<h3>{title}</h3>
<div className="row-between">
<div>Игр</div>
<b>{o.games}</b>
@@ -28,9 +28,18 @@ export function TelegramLoginButton({
onAuth: (user: TelegramUser) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
// onAuth держим в ref, чтобы он НЕ был в зависимостях эффекта: иначе новая инлайн-
// функция на каждый рендер пересоздавала бы виджет (а cleanup сносит его innerHTML
// в момент отрисовки — в dev со StrictMode кнопка из-за этого не появлялась).
const onAuthRef = useRef(onAuth);
onAuthRef.current = onAuth;
useEffect(() => {
window.__fsTelegramAuth = (user) => onAuth(user);
window.__fsTelegramAuth = (user) => onAuthRef.current(user);
const node = ref.current;
if (!node) return;
node.innerHTML = ""; // на случай повторного монтирования (StrictMode)
const script = document.createElement("script");
script.src = "https://telegram.org/js/telegram-widget.js?22";
script.async = true;
@@ -39,14 +48,12 @@ export function TelegramLoginButton({
script.setAttribute("data-userpic", "false");
script.setAttribute("data-request-access", "write");
script.setAttribute("data-onauth", "__fsTelegramAuth(user)");
node.appendChild(script);
const node = ref.current;
node?.appendChild(script);
return () => {
if (node) node.innerHTML = "";
delete window.__fsTelegramAuth;
node.innerHTML = "";
};
}, [botUsername, onAuth]);
}, [botUsername]); // только смена бота пересоздаёт виджет
return <div ref={ref} />;
}
+2
View File
@@ -15,6 +15,7 @@ export type MatchListItem = S["MatchListItem"];
export type Leaderboard = S["Leaderboard"];
export type LeaderboardEntry = S["LeaderboardEntry"];
export type ProfileStats = S["ProfileStats"];
export type PublicProfile = S["PublicProfile"];
export type GroupStats = S["GroupStats"];
export type HomeResponse = S["HomeResponse"];
export type HomeInProgressMatch = S["HomeInProgressMatch"];
@@ -25,6 +26,7 @@ export type AdminUserRead = S["AdminUserRead"];
export type AdminGroupRead = S["AdminGroupRead"];
export type AdminMatchRead = S["AdminMatchRead"];
export type AuditLogList = S["AuditLogList"];
export type AchievementRead = S["AchievementRead"];
export type ParticipantInput = S["ParticipantInput"];
export type MatchUpdate = S["MatchUpdate"];
export type MatchCreate = S["MatchCreate"];
+87 -1
View File
@@ -1,9 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { ApiError, api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types";
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
export function useAdminMe() {
return useQuery({
queryKey: qk.adminMe,
@@ -163,3 +168,84 @@ export function useAdminUpdateUser() {
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
});
}
// ─── Ачивки (редактор; выдача игрокам — на будущее) ──────────────────────────
interface AchievementInput {
name?: string;
description?: string;
condition?: string;
}
export function useAdminAchievements() {
return useQuery({
queryKey: qk.adminAchievements,
queryFn: async () => unwrap(await api.GET("/api/admin/achievements")),
});
}
export function useCreateAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: AchievementInput & { name: string }) =>
unwrap(await api.POST("/api/admin/achievements", { body })),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
export function useUpdateAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { slug: string; body: AchievementInput }) =>
unwrap(
await api.PATCH("/api/admin/achievements/{slug}", {
params: { path: { slug: args.slug } },
body: args.body,
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
export function useDeleteAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (slug: string) =>
unwrap(
await api.DELETE("/api/admin/achievements/{slug}", {
params: { path: { slug } },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
// Иконка ачивки — multipart отдельным fetch (как аватар), с CSRF из cookie.
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`, {
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();
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
+116 -1
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { ApiError, api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AuthConfig, Me } from "../domain/types";
@@ -44,6 +44,8 @@ export function useStubLogin() {
}
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
export function useTelegramLogin() {
const qc = useQueryClient();
return useMutation({
@@ -56,6 +58,20 @@ export function useTelegramLogin() {
});
}
// Регистрация через Telegram с выбранным ником (когда тег занят/некорректен).
// payload — те же данные виджета + nickname; подпись проверяется заново на сервере.
export function useTelegramRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (payload: Record<string, unknown>) =>
unwrap(await api.POST("/api/auth/telegram/register", { body: payload as never })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
export function useCreateDevUser() {
const qc = useQueryClient();
return useMutation({
@@ -88,6 +104,105 @@ export function useUpdateNickname() {
});
}
// «О себе».
export function useUpdateBio() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (bio: string): Promise<Me> =>
unwrap(await api.PATCH("/api/users/me/profile", { body: { bio } })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries({ queryKey: qk.home });
},
});
}
// Аватар. Картинку уменьшаем на клиенте (≤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; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер)
}
}
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`, {
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;
},
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
export function useDeleteAvatar() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (): Promise<Me> => unwrap(await api.DELETE("/api/users/me/avatar")),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
export function useSetActiveGroup() {
const qc = useQueryClient();
return useMutation({
+13
View File
@@ -24,3 +24,16 @@ export function useMyStats() {
queryFn: async () => unwrap(await api.GET("/api/users/me/stats")),
});
}
// Публичный профиль другого игрока (read-only).
export function usePublicProfile(userId: number) {
return useQuery({
queryKey: qk.publicProfile(userId),
queryFn: async () =>
unwrap(
await api.GET("/api/users/{user_id}/profile", {
params: { path: { user_id: userId } },
}),
),
});
}
+103 -11
View File
@@ -1,26 +1,42 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { Avatar } from "../components/Avatar";
import { GroupSwitcher } from "../components/GroupSwitcher";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { useToast } from "../context/ToastContext";
import { useLogout, useMe, useUpdateNickname } from "../hooks/auth";
import {
useDeleteAvatar,
useLogout,
useMe,
useUpdateBio,
useUpdateNickname,
useUploadAvatar,
} from "../hooks/auth";
import { useMyStats } from "../hooks/stats";
export function AccountPage() {
const { data: me, isLoading } = useMe();
const { data: stats } = useMyStats();
const updateNick = useUpdateNickname();
const updateBio = useUpdateBio();
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const logout = useLogout();
const toast = useToast();
const navigate = useNavigate();
const fileRef = useRef<HTMLInputElement>(null);
const [nickname, setNickname] = useState("");
const [bio, setBio] = useState<string | null>(null);
if (isLoading || !me) return <Spinner />;
const save = async () => {
// bio редактируется локально; до первого ввода берём значение из профиля.
const bioValue = bio ?? me.bio ?? "";
const saveNick = async () => {
const nick = nickname.trim();
if (!nick) return;
try {
@@ -32,6 +48,36 @@ export function AccountPage() {
}
};
const saveBio = async () => {
try {
await updateBio.mutateAsync(bioValue.trim());
toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const onPickFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ""; // позволяем выбрать тот же файл повторно
if (!file) return;
try {
await uploadAvatar.mutateAsync(file);
toast.show("Аватар обновлён");
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
}
};
const removeAvatar = async () => {
try {
await deleteAvatar.mutateAsync();
toast.show("Аватар удалён");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const doLogout = async () => {
await logout.mutateAsync().catch(() => {});
navigate("/login", { replace: true });
@@ -41,15 +87,64 @@ export function AccountPage() {
<div>
<div className="card">
<h3>Профиль</h3>
<div className="row-between">
<span className="muted">Никнейм</span>
<b>{me.nickname}</b>
<div className="row" style={{ gap: 14 }}>
<Avatar url={me.avatar_url} nickname={me.nickname} size={64} />
<div style={{ flex: 1 }}>
<b style={{ fontSize: 18 }}>{me.nickname}</b>
<div className="row" style={{ gap: 8, marginTop: 8 }}>
<button
className="btn small"
onClick={() => fileRef.current?.click()}
disabled={uploadAvatar.isPending}
>
{me.avatar_url ? "Сменить аватар" : "Загрузить аватар"}
</button>
{me.avatar_url && (
<button
className="btn btn-ghost btn-danger small"
onClick={removeAvatar}
disabled={deleteAvatar.isPending}
>
Удалить
</button>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
style={{ display: "none" }}
onChange={onPickFile}
/>
</div>
</div>
<div className="field" style={{ marginTop: 14 }}>
<label className="label">О себе</label>
<textarea
rows={3}
maxLength={500}
placeholder="Пара слов о себе…"
value={bioValue}
onChange={(e) => setBio(e.target.value)}
/>
<div className="row-between" style={{ marginTop: 6 }}>
<span className="muted small">{bioValue.length}/500</span>
<button
className="btn btn-primary small"
onClick={saveBio}
disabled={updateBio.isPending || bioValue.trim() === (me.bio ?? "")}
>
Сохранить
</button>
</div>
</div>
<div className="field" style={{ marginTop: 10 }}>
<label className="label">Сменить никнейм</label>
<div className="row">
<input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={me.nickname} />
<button className="btn btn-primary" onClick={save} disabled={updateNick.isPending}>
<button className="btn btn-primary" onClick={saveNick} disabled={updateNick.isPending}>
ОК
</button>
</div>
@@ -65,10 +160,7 @@ export function AccountPage() {
</button>
<p className="center" style={{ marginTop: 24 }}>
<button
className="btn btn-ghost small muted"
onClick={() => navigate("/admin/login")}
>
<button className="btn btn-ghost small muted" onClick={() => navigate("/admin/login")}>
Вход для администратора
</button>
</p>
+12 -2
View File
@@ -1,3 +1,5 @@
import { useNavigate } from "react-router-dom";
import { InProgressMatches } from "../components/InProgressMatches";
import { Leaderboard } from "../components/Leaderboard";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
@@ -6,16 +8,24 @@ import { useHome } from "../hooks/stats";
export function HomePage() {
const { data, isLoading, isError } = useHome();
const navigate = useNavigate();
if (isLoading) return <Spinner />;
if (isError || !data) return <div className="muted">Не удалось загрузить данные.</div>;
return (
<div>
<div className="card">
<h3>Топ игроков</h3>
{/* Карточка кликабельна целиком → общая статистика; клик по нику внутри
ведёт в профиль (Leaderboard останавливает всплытие на ссылках ников). */}
<div className="card" style={{ cursor: "pointer" }} onClick={() => navigate("/stats")}>
<div className="row-between">
<h3 style={{ margin: 0 }}>Топ игроков</h3>
<span className="muted small">Вся статистика →</span>
</div>
<div style={{ marginTop: 10 }}>
<Leaderboard entries={data.leaderboard} provisional={data.provisional} />
</div>
</div>
<ProfileStatsCard stats={data.profile} />
+89 -2
View File
@@ -11,13 +11,21 @@ import {
useMe,
useStubLogin,
useTelegramLogin,
useTelegramRegister,
} from "../hooks/auth";
// Данные виджета Telegram, отложенные до выбора ника (когда тег занят).
interface PendingTelegram {
payload: Record<string, unknown>;
suggested: string;
}
export function LoginPage() {
const { data: me, isLoading } = useMe();
const { data: config } = useAuthConfig();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
const methods = config?.methods ?? [];
const hasTelegram = methods.includes("telegram");
@@ -36,6 +44,12 @@ export function LoginPage() {
await telegram.mutateAsync(user);
navigate("/", { replace: true });
} catch (e) {
// Тег занят/некорректен → просим выбрать ник и регистрируем тем же payload.
if (e instanceof ApiError && e.code === "TELEGRAM_NICKNAME_REQUIRED") {
const suggested = (e.details as { suggested?: string } | undefined)?.suggested ?? "";
setPendingTg({ payload: user, suggested });
return;
}
setError(e instanceof ApiError ? e.message : "Не удалось войти через Telegram");
}
};
@@ -48,7 +62,17 @@ export function LoginPage() {
</h1>
<p className="muted center">Учёт партий</p>
{hasTelegram && (
{pendingTg ? (
<TelegramNicknamePicker
pending={pendingTg}
onCancel={() => {
setPendingTg(null);
setError(null);
}}
onDone={() => navigate("/", { replace: true })}
/>
) : (
hasTelegram && (
<div className="card">
<h3>Вход через Telegram</h3>
{config?.telegram_bot_username ? (
@@ -63,9 +87,10 @@ export function LoginPage() {
</p>
)}
</div>
)
)}
{showStub && <DevLogin onError={setError} />}
{showStub && !pendingTg && <DevLogin onError={setError} />}
{error && <p className="error-text center">{error}</p>}
@@ -83,6 +108,68 @@ export function LoginPage() {
);
}
// Выбор ника при регистрации через Telegram, когда тег занят/некорректен.
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
function TelegramNicknamePicker({
pending,
onCancel,
onDone,
}: {
pending: PendingTelegram;
onCancel: () => void;
onDone: () => void;
}) {
const register = useTelegramRegister();
const [nickname, setNickname] = useState(pending.suggested);
const [error, setError] = useState<string | null>(
pending.suggested ? `Ник «${pending.suggested}» уже занят — выберите другой.` : null,
);
const submit = async () => {
const nick = nickname.trim();
if (!nick) {
setError("Введите никнейм.");
return;
}
setError(null);
try {
await register.mutateAsync({ ...pending.payload, nickname: nick });
onDone();
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось зарегистрироваться");
}
};
return (
<div className="card">
<h3>Выберите никнейм</h3>
<p className="muted small">
Ваш ник в Telegram уже занят другим игроком. Укажите другой никнейм для регистрации.
</p>
<div className="field">
<div className="row">
<input
placeholder="Никнейм"
value={nickname}
autoFocus
onChange={(e) => setNickname(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
/>
<button className="btn btn-primary" onClick={submit} disabled={register.isPending}>
Зарегистрироваться
</button>
</div>
</div>
{error && <p className="error-text">{error}</p>}
<button className="btn btn-ghost small" onClick={onCancel}>
Назад
</button>
</div>
);
}
// DEV-вход по нику. Вынесен в отдельный компонент; импортируется только в dev-ветке
// LoginPage, поэтому в прод-сборке (import.meta.env.DEV=false) удаляется тришейкингом.
function DevLogin({ onError }: { onError: (msg: string | null) => void }) {
+92
View File
@@ -0,0 +1,92 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Avatar } from "../components/Avatar";
import { Spinner } from "../components/Spinner";
import type { LeaderboardEntry } from "../domain/types";
import { useLeaderboard } from "../hooks/stats";
type SortKey = "rank" | "games" | "wins" | "win_rate" | "score";
function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`;
}
export function OverallStatsPage() {
const { data, isLoading, isError } = useLeaderboard(200);
const [q, setQ] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("rank");
const [dir, setDir] = useState<1 | -1>(1); // 1 — по возрастанию
const toggleSort = (k: SortKey) => {
if (k === sortKey) setDir((d) => (d === 1 ? -1 : 1));
else {
setSortKey(k);
setDir(k === "rank" ? 1 : -1); // для метрик по умолчанию «лучшие сверху»
}
};
const filterSort = useMemo(() => {
const needle = q.trim().toLowerCase();
const num = (e: LeaderboardEntry) => Number(e[sortKey] ?? 0);
return (list: LeaderboardEntry[]) =>
list
.filter((e) => e.nickname.toLowerCase().includes(needle))
.sort((a, b) => (num(a) - num(b)) * dir);
}, [q, sortKey, dir]);
if (isLoading) return <Spinner />;
if (isError || !data) return <div className="muted">Не удалось загрузить статистику.</div>;
const entries = filterSort(data.entries);
const provisional = filterSort(data.provisional);
const arrow = (k: SortKey) => (k === sortKey ? (dir === 1 ? " ↑" : " ↓") : "");
const row = (e: LeaderboardEntry) => (
<Link key={e.user_id} to={`/u/${e.user_id}`} className="st-row" style={{ color: "inherit" }}>
<span className={"st-num" + (e.rank && e.rank <= 3 ? " gold" : "")}>{e.rank ?? "—"}</span>
<span className="st-player">
<Avatar url={e.avatar_url} nickname={e.nickname} size={28} />
<span className="nick">{e.nickname}</span>
</span>
<span className="st-num">{e.games}</span>
<span className="st-num">{e.wins}</span>
<span className="st-num">{pct(e.win_rate)}</span>
<span className="st-num lb-score">{e.score ?? "—"}</span>
</Link>
);
return (
<div>
<div className="card">
<h3>Общая статистика</h3>
<input placeholder="Поиск по нику…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="card">
<div className="st-head">
<button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button>
<span>Игрок</span>
<button onClick={() => toggleSort("games")}>Игр{arrow("games")}</button>
<button onClick={() => toggleSort("wins")}>Поб{arrow("wins")}</button>
<button onClick={() => toggleSort("win_rate")}>WR{arrow("win_rate")}</button>
<button onClick={() => toggleSort("score")}>Очки{arrow("score")}</button>
</div>
{entries.length === 0 ? (
<div className="muted small" style={{ paddingTop: 8 }}>
Никого не найдено.
</div>
) : (
entries.map(row)
)}
</div>
{provisional.length > 0 && (
<div className="card">
<div className="label">Новички (меньше {data.min_games} игр)</div>
{provisional.map(row)}
</div>
)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { useParams } from "react-router-dom";
import { Avatar } from "../components/Avatar";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { usePublicProfile } from "../hooks/stats";
/**
* Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от
* редактируемого AccountPage: здесь нет никаких контролов правки, только просмотр).
*/
export function PublicProfilePage() {
const { userId } = useParams();
const id = Number(userId);
const { data, isLoading, isError } = usePublicProfile(id);
if (isLoading) return <Spinner />;
if (isError || !data) return <div className="muted">Профиль не найден.</div>;
return (
<div>
<div className="card">
<div className="row" style={{ gap: 14 }}>
<Avatar url={data.avatar_url} nickname={data.nickname} size={64} />
<div style={{ flex: 1 }}>
<b style={{ fontSize: 18 }}>{data.nickname}</b>
{data.bio ? (
<div className="muted" style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>
{data.bio}
</div>
) : (
<div className="muted small" style={{ marginTop: 6 }}>
Игрок пока не рассказал о себе.
</div>
)}
</div>
</div>
</div>
<ProfileStatsCard stats={data.stats} title="Статистика" />
</div>
);
}
+67 -7
View File
@@ -1,20 +1,75 @@
import { Pencil } from "lucide-react";
import { useState } from "react";
import { ApiError } from "../../api/client";
import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
export function AdminAccountsPage() {
const { data: users, isLoading } = useAdminUsers();
const update = useAdminUpdateUser();
const toast = useToast();
const [editId, setEditId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
if (isLoading) return <Spinner />;
const startEdit = (id: number, nickname: string) => {
setEditId(id);
setEditName(nickname);
};
const saveName = async () => {
const name = editName.trim();
if (editId == null || !name) return;
try {
await update.mutateAsync({ userId: editId, nickname: name });
toast.show("Имя обновлено");
setEditId(null);
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать");
}
};
const toggleActive = async (id: number, isActive: boolean) => {
await update.mutateAsync({ userId: id, is_active: !isActive }).catch(() => {});
toast.show("Сохранено");
};
return (
<div>
<h3>Аккаунты</h3>
<div className="stack">
{(users ?? []).map((u) => (
<div key={u.id} className="card" style={{ margin: 0 }}>
{editId === u.id ? (
// Режим переименования.
<div className="row">
<input
value={editName}
autoFocus
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveName();
if (e.key === "Escape") setEditId(null);
}}
/>
<button
className="btn btn-primary small"
disabled={update.isPending || !editName.trim() || editName.trim() === u.nickname}
onClick={saveName}
>
ОК
</button>
<button className="btn btn-ghost small" onClick={() => setEditId(null)}>
Отмена
</button>
</div>
) : (
<div className="row-between">
<div>
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">admin</span>}
@@ -23,19 +78,24 @@ export function AdminAccountsPage() {
</div>
</div>
{u.role !== "admin" && (
<div className="row" style={{ gap: 4 }}>
<button
className="btn small"
onClick={async () => {
await update
.mutateAsync({ userId: u.id, is_active: !u.is_active })
.catch(() => {});
toast.show("Сохранено");
}}
className="btn btn-ghost small"
title="Переименовать"
onClick={() => startEdit(u.id, u.nickname)}
>
<Pencil size={16} />
</button>
{import.meta.env.DEV && (
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
)}
<button className="btn small" onClick={() => toggleActive(u.id, u.is_active)}>
{u.is_active ? "Отключить" : "Включить"}
</button>
</div>
)}
</div>
)}
</div>
))}
</div>
@@ -0,0 +1,192 @@
import { useRef, useState } from "react";
import { ApiError } from "../../api/client";
import { Avatar } from "../../components/Avatar";
import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import {
useAdminAchievements,
useCreateAchievement,
useDeleteAchievement,
useUpdateAchievement,
useUploadAchievementIcon,
} from "../../hooks/admin";
import type { AchievementRead } from "../../domain/types";
export function AdminAchievementsPage() {
const { data: items, isLoading } = useAdminAchievements();
const create = useCreateAchievement();
const toast = useToast();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [condition, setCondition] = useState("");
if (isLoading) return <Spinner />;
const onCreate = async () => {
if (!name.trim()) return;
try {
await create.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
condition: condition.trim() || undefined,
});
setName("");
setDescription("");
setCondition("");
toast.show("Ачивка создана");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div>
<div className="card">
<h3>Новая ачивка</h3>
<div className="field">
<label className="label">Название</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Например: Великий полководец" />
</div>
<div className="field">
<label className="label">Краткое описание</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="За что выдаётся"
/>
</div>
<div className="field">
<label className="label">Скрипт-условие (задел на будущее, пока не исполняется)</label>
<textarea
rows={3}
value={condition}
onChange={(e) => setCondition(e.target.value)}
placeholder="# условие выдачи — добавится позже"
/>
</div>
<button className="btn btn-primary" onClick={onCreate} disabled={create.isPending || !name.trim()}>
Создать
</button>
</div>
<div className="stack">
{(items ?? []).map((a) => (
<AchievementCard key={a.slug} ach={a} />
))}
{(items ?? []).length === 0 && <div className="muted small">Ачивок пока нет.</div>}
</div>
</div>
);
}
function AchievementCard({ ach }: { ach: AchievementRead }) {
const update = useUpdateAchievement();
const del = useDeleteAchievement();
const uploadIcon = useUploadAchievementIcon();
const toast = useToast();
const fileRef = useRef<HTMLInputElement>(null);
const [editing, setEditing] = useState(false);
const [name, setName] = useState(ach.name);
const [description, setDescription] = useState(ach.description);
const [condition, setCondition] = useState(ach.condition);
const save = async () => {
try {
await update.mutateAsync({
slug: ach.slug,
body: { name: name.trim(), description, condition },
});
setEditing(false);
toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const onIcon = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
await uploadIcon.mutateAsync({ slug: ach.slug, file });
toast.show("Иконка обновлена");
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
}
};
const remove = async () => {
if (!window.confirm(`Удалить ачивку «${ach.name}»?`)) return;
try {
await del.mutateAsync(ach.slug);
toast.show("Удалено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div className="card" style={{ margin: 0 }}>
<div className="row" style={{ gap: 12, alignItems: "flex-start" }}>
<Avatar url={ach.icon_url} nickname={ach.name} size={48} />
<div style={{ flex: 1, minWidth: 0 }}>
{editing ? (
<div className="stack" style={{ gap: 8 }}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Название" />
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Описание"
/>
<textarea
rows={3}
value={condition}
onChange={(e) => setCondition(e.target.value)}
placeholder="Скрипт-условие (задел)"
/>
<div className="row" style={{ gap: 8 }}>
<button className="btn btn-primary small" onClick={save} disabled={update.isPending || !name.trim()}>
Сохранить
</button>
<button className="btn btn-ghost small" onClick={() => setEditing(false)}>
Отмена
</button>
</div>
</div>
) : (
<>
<b>{ach.name}</b>
<div className="muted small">{ach.slug}</div>
{ach.description && <div style={{ marginTop: 4 }}>{ach.description}</div>}
<div className="muted small" style={{ marginTop: 4 }}>
{ach.has_condition ? "условие задано (не исполняется)" : "условие не задано"}
</div>
</>
)}
</div>
</div>
{!editing && (
<div className="row" style={{ gap: 8, marginTop: 10 }}>
<button className="btn small" onClick={() => fileRef.current?.click()} disabled={uploadIcon.isPending}>
Иконка
</button>
<button className="btn small" onClick={() => setEditing(true)}>
Изменить
</button>
<button className="btn btn-ghost btn-danger small" onClick={remove} disabled={del.isPending}>
Удалить
</button>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
style={{ display: "none" }}
onChange={onIcon}
/>
</div>
)}
</div>
);
}
+3
View File
@@ -32,6 +32,9 @@ export function AdminLayout() {
<NavLink to="/admin/factions" className={tab}>
Фракции
</NavLink>
<NavLink to="/admin/achievements" className={tab}>
Ачивки
</NavLink>
<NavLink to="/admin/logs" className={tab}>
Логи
</NavLink>
@@ -0,0 +1,60 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, unwrap } from "../../api/client";
import { qk } from "../../api/queryKeys";
import { useToast } from "../../context/ToastContext";
/**
* DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта.
*
* Эндпоинт `DELETE /api/admin/dev/users/{id}` существует только в dev-сборке бэкенда
* (backend/app/routers/dev_admin.py, исключён из прод/тест-образа). Этот модуль
* рендерится лишь под `import.meta.env.DEV` в AdminAccountsPage, поэтому в прод-сборке
* он не используется и вырезается тришейкингом — в прод/тест удаление недоступно.
*/
export function DevDeleteAccountButton({
userId,
nickname,
}: {
userId: number;
nickname: string;
}) {
const qc = useQueryClient();
const toast = useToast();
const del = useMutation({
mutationFn: async () =>
unwrap(
await api.DELETE("/api/admin/dev/users/{user_id}", {
params: { path: { user_id: userId } },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
});
return (
<button
className="btn small"
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
// чтобы в прод/тест-стили не попало ничего, связанного с удалением.
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
disabled={del.isPending}
onClick={async () => {
if (
!window.confirm(
`Удалить аккаунт «${nickname}»? Партии сохранятся, но игрок будет ` +
`вычеркнут из них. Действие необратимо.`,
)
)
return;
try {
await del.mutateAsync();
toast.show("Аккаунт удалён");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
}
}}
>
Удалить
</button>
);
}
+21 -1
View File
@@ -90,7 +90,7 @@
/* Таблицы / списки лидерборда */
.lb-row {
display: grid;
grid-template-columns: 28px 1fr auto;
grid-template-columns: 28px 32px 1fr auto;
align-items: center;
gap: 10px;
padding: 8px 4px;
@@ -102,6 +102,26 @@
.lb-metrics { display: flex; gap: 12px; font-size: 13px; color: var(--text-muted); }
.lb-score { font-weight: 700; color: var(--accent-2); }
/* Таблица общей статистики (сортируемая, кликабельные строки) */
.st-row, .st-head {
display: grid;
grid-template-columns: 26px 1fr 40px 40px 48px 52px;
align-items: center;
gap: 8px;
padding: 8px 2px;
border-bottom: 1px solid var(--border);
}
.st-head { color: var(--text-muted); font-size: 12px; }
.st-head button {
background: none; border: none; color: inherit; font: inherit;
cursor: pointer; padding: 0; text-align: right;
}
.st-row:last-child { border-bottom: none; }
.st-num { text-align: right; font-variant-numeric: tabular-nums; font-size: 14px; }
.st-num.gold { color: var(--gold); font-weight: 700; }
.st-player { display: flex; align-items: center; gap: 8px; min-width: 0; font-weight: 600; }
.st-player .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.participant-row {
display: grid;
grid-template-columns: 1fr;
+29
View File
@@ -0,0 +1,29 @@
commit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6 (HEAD -> dev)
Author: NotBigGhost <ivan@arseniev.info>
Date: Wed Jun 17 04:45:33 2026 +0300
Добавление ssh-ключа к контейнеру tunnel
commit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366
Author: NotBigGhost <ivan@arseniev.info>
Date: Wed Jun 17 04:27:11 2026 +0300
Правки локального размещения дева, перенос тунеля в контейнер
commit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 19:15:54 2026 +0300
Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель
commit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f (origin/main, main)
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 17:41:06 2026 +0300
v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
commit 6ab74f01aaf1ac042a269c7660b3e564b93350c7
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 16:53:41 2026 +0300
first commit