Кастомизация профиля (о себе, аватар) и просмотр профилей других игроков + общая статистика
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user