Кастомизация профиля (о себе, аватар) и просмотр профилей других игроков + общая статистика
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -88,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):
|
||||
@@ -98,6 +100,10 @@ class NicknameUpdate(BaseModel):
|
||||
nickname: str
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
bio: str | None = None
|
||||
|
||||
|
||||
class ActiveGroupUpdate(BaseModel):
|
||||
group_id: int | None = None
|
||||
|
||||
@@ -237,6 +243,7 @@ class LeaderboardEntry(OverallStats):
|
||||
user_id: int
|
||||
nickname: str
|
||||
rank: int | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class Leaderboard(BaseModel):
|
||||
@@ -273,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
@@ -128,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),
|
||||
}
|
||||
|
||||
@@ -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"] # после загрузки — ссылка
|
||||
Reference in New Issue
Block a user