Профиль: история партий игрока и настройки её витрины

Выборки партий по игроку в бэкенде не было — только по группе. Добавлен
stats_service.user_match_list: завершённые партии игрока, свежие сверху;
сборка элементов вынесена из group_match_list в общий _match_items, чтобы
не дублировать её в двух местах.

Витрина профиля задаётся двумя колонками в users (миграция 0011):
history_mode (all/best) и history_detail (compact/full). Режим применяется
на бэкенде, а не на клиенте: это витрина владельца, и в том же виде
профиль видят гости. В режиме best берётся партия с максимальными League
Points из SCORED_CTE (при равных очках — более свежая).

GET /api/users/{user_id}/matches отдаёт список вместе с mode и detail —
гостю хватает одного запроса, чтобы отрисовать историю как задумал владелец.

#1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 19:56:06 +03:00
co-authored by Claude Opus 5
parent bca2d7a058
commit be80891449
7 changed files with 372 additions and 15 deletions
+14
View File
@@ -73,6 +73,10 @@ class User(SQLModel, table=True):
"role <> 'admin' OR password_hash IS NOT NULL",
name="ck_users_admin_has_password",
),
CheckConstraint("history_mode IN ('all','best')", name="ck_users_history_mode"),
CheckConstraint(
"history_detail IN ('compact','full')", name="ck_users_history_detail"
),
)
id: int | None = Field(default=None, primary_key=True)
@@ -97,6 +101,16 @@ class User(SQLModel, table=True):
index=True,
)
)
# Витрина истории партий в профиле: что показывать (все / только лучшая по очкам)
# и насколько подробно. Действует и для гостей профиля, не только для владельца.
history_mode: str = Field(
default="all",
sa_column=Column(String(8), nullable=False, server_default="all"),
)
history_detail: str = Field(
default="compact",
sa_column=Column(String(8), nullable=False, server_default="compact"),
)
# Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление
# по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан.
favorite_faction_id: int | None = Field(
+31
View File
@@ -32,6 +32,8 @@ def build_me(session: Session, user: User) -> s.MeRead:
bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
favorite_faction_id=user.favorite_faction_id,
history_mode=user.history_mode,
history_detail=user.history_detail,
groups=groups,
)
@@ -73,6 +75,8 @@ def update_me(
bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
favorite_faction_id=user.favorite_faction_id,
history_mode=user.history_mode,
history_detail=user.history_detail,
)
@@ -90,6 +94,13 @@ def update_my_profile(
user_service.update_bio(session, user, changed["bio"])
if "favorite_faction_id" in changed:
user_service.update_favorite_faction(session, user, changed["favorite_faction_id"])
if "history_mode" in changed or "history_detail" in changed:
user_service.update_history_prefs(
session,
user,
mode=changed.get("history_mode"),
detail=changed.get("history_detail"),
)
audit_service.record(
session,
actor_id=user.id,
@@ -155,6 +166,26 @@ def get_user_profile(
return user_service.public_profile(session, user_id)
@router.get("/{user_id}/matches", response_model=s.MatchHistory)
def user_matches(
user_id: int,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
"""История партий игрока. Режим — витрина владельца профиля: её видят и гости."""
owner = user_service.get_user(session, user_id)
data = stats_service.user_match_list(
session,
user_id,
limit=limit,
offset=offset,
best_only=owner.history_mode == "best",
)
return {**data, "mode": owner.history_mode, "detail": owner.history_detail}
@router.get("/me/stats", response_model=s.ProfileStats)
def my_stats(
session: Session = Depends(get_session),
+19
View File
@@ -92,6 +92,9 @@ class UserRead(BaseModel):
avatar_url: str | None = None
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
favorite_faction_id: int | None = None
# Витрина истории партий в профиле.
history_mode: str = "all"
history_detail: str = "compact"
class MeRead(UserRead):
@@ -107,6 +110,8 @@ class ProfileUpdate(BaseModel):
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
bio: str | None = None
favorite_faction_id: int | None = None
history_mode: str | None = None
history_detail: str | None = None
class ActiveGroupUpdate(BaseModel):
@@ -306,6 +311,20 @@ class LeaderboardEntry(OverallStats):
avatar_url: str | None = None
class MatchHistory(BaseModel):
"""История партий игрока плюс настройки витрины его профиля.
Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так,
как выбрал её владелец, не делая второго запроса за профилем."""
items: list[MatchListItem] = []
total: int
limit: int
offset: int
mode: str
detail: str
class Leaderboard(BaseModel):
entries: list[LeaderboardEntry] = []
provisional: list[LeaderboardEntry] = []
+74 -12
View File
@@ -7,7 +7,7 @@ from sqlalchemy import text
from sqlmodel import Session, select
from app.core.timeutil import iso_utc
from app.models import Faction, Group, GroupMember, Match, User
from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User
from app.services import faction_service, group_service, membership_service, user_service
from app.services.scoring import (
FACTION_MIN_GAMES,
@@ -300,18 +300,10 @@ def group_stats(session: Session, group_id: int) -> dict:
}
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
def _match_items(session: Session, matches) -> list[dict]:
"""Элементы списка партий (общее для списка группы и истории игрока)."""
from app.services.match_service import participants_detail # избегаем цикла импорта
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
matches = session.exec(
select(Match)
.where(Match.group_id == group_id)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
).all()
items = []
for m in matches:
parts = []
@@ -343,7 +335,77 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
"participants": parts,
}
)
return {"items": items, "total": total, "limit": limit, "offset": offset}
return items
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
matches = session.exec(
select(Match)
.where(Match.group_id == group_id)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
).all()
return {
"items": _match_items(session, matches),
"total": total,
"limit": limit,
"offset": offset,
}
def user_match_list(
session: Session,
user_id: int,
limit: int = 20,
offset: int = 0,
best_only: bool = False,
) -> dict:
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает
место и размер стола); при равных очках берём более свежую."""
if best_only:
sql = f"""
{SCORED_CTE}
SELECT s.match_id AS match_id
FROM scored s
WHERE s.user_id = :uid
ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC
LIMIT 1
"""
row = session.execute(text(sql), {"uid": user_id}).mappings().first()
matches = [session.get(Match, row["match_id"])] if row else []
return {
"items": _match_items(session, matches),
"total": len(matches),
"limit": 1,
"offset": 0,
}
where = (MatchParticipant.user_id == user_id, Match.status == "finished")
total = len(
session.exec(
select(Match.id)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(*where)
).all()
)
matches = session.exec(
select(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(*where)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
).all()
return {
"items": _match_items(session, matches),
"total": total,
"limit": limit,
"offset": offset,
}
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
+22
View File
@@ -185,6 +185,28 @@ def update_bio(session: Session, user: User, bio: str | None) -> User:
return user
_HISTORY_MODES = {"all", "best"}
_HISTORY_DETAILS = {"compact", "full"}
def update_history_prefs(
session: Session, user: User, *, mode: str | None = None, detail: str | None = None
) -> User:
"""Витрина истории партий: что показывать и насколько подробно. None — не менять."""
if mode is not None:
if mode not in _HISTORY_MODES:
raise ValidationError("Неизвестный режим истории партий.")
user.history_mode = mode
if detail is not None:
if detail not in _HISTORY_DETAILS:
raise ValidationError("Неизвестная подробность истории партий.")
user.history_detail = detail
session.add(user)
session.commit()
session.refresh(user)
return user
def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User:
"""Любимая фракция — личный выбор игрока; None очищает выбор."""
if faction_id is not None and session.get(Faction, faction_id) is None: