Профиль: любимая фракция — выбор игрока, а не вывод из статистики

Раньше «любимая» вычислялась как самая частая среди партий с ручным
выбором фракции. Теперь это поле профиля: users.favorite_faction_id
(FK на factions, ON DELETE SET NULL, миграция 0010) и необязательное
поле в PATCH /api/users/me/profile.

Роутер профиля переведён на model_dump(exclude_unset=True) и обновляет
только пришедшие поля — иначе сохранение одного «о себе» обнуляло бы
выбранную фракцию.

«Основная фракция» логику не меняет (она и так считалась по всем партиям,
включая рандомные раздачи) и переезжает в подпись «Чаще всего играет на»:
FactionStat получает name_ru_prepositional из словаря предложного падежа
в faction_service. Словарь в коде, а не в БД: набор фракций задан игрой;
фракция вне словаря выводится в именительном.

Служебное chosen_games существовало только ради старой «любимой» — убрано
из SQL вместе с ней.

#17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk
This commit is contained in:
2026-09-07 16:17:43 +03:00
co-authored by Claude Opus 5
parent 309e31a49a
commit 60b22dd8df
8 changed files with 207 additions and 33 deletions
+24
View File
@@ -18,3 +18,27 @@ def list_factions(session: Session) -> list[Faction]:
select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
).all()
)
# Предложный падеж названий фракций для фразы «Чаще всего играет на …».
# Словарь в коде, а не колонка в БД: набор фракций задан игрой и не меняется.
# Фракция, добавленная админом мимо словаря, выводится в именительном падеже.
_PREPOSITIONAL: dict[str, str] = {
"orks": "Орках",
"ultramarines": "Ультрамаринах",
"eldar": "Эльдарах",
"chaos": "Хаоситах",
"astra_militarum": "Имперской гвардии",
"tau": "Тау",
"necrons": "Некронах",
"tyranids": "Тиранидах",
"inquisition": "Инквизиции",
"sisters_of_battle": "Сёстрах битвы",
"drukhari": "Друкхари",
"adeptus_mechanicus": "Адептус Механикус",
}
def prepositional(code: str, name_ru: str) -> str:
"""Название фракции в предложном падеже; вне словаря — именительный как есть."""
return _PREPOSITIONAL.get(code, name_ru)
+26 -14
View File
@@ -7,8 +7,8 @@ from sqlalchemy import text
from sqlmodel import Session, select
from app.core.timeutil import iso_utc
from app.models import Group, GroupMember, Match
from app.services import group_service, membership_service, user_service
from app.models import Faction, Group, GroupMember, Match, User
from app.services import faction_service, group_service, membership_service, user_service
from app.services.scoring import (
FACTION_MIN_GAMES,
MATCH_POINTS_SQL,
@@ -141,9 +141,8 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) ->
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place,
AVG(s.points) * 100 AS score, -- фракции: чистое среднее (служебная метрика
-- «лучшая/худшая», сглаживание задавило бы её к 50)
SUM(CASE WHEN s.was_random THEN 0 ELSE 1 END) AS chosen_games
AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика
-- «лучшая/худшая», сглаживание задавило бы её к 50)
FROM scored s
JOIN factions f ON f.id = s.faction_id
JOIN expansions e ON e.id = f.expansion_id
@@ -168,9 +167,9 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) ->
"win_rate": _round(r["win_rate"] or 0.0, 4),
"avg_place": _round(r["avg_place"], 2),
"score": _round(r["score"], 1),
# Служебное (в API-схему не входит): партии, где фракция выбрана
# вручную, — для «любимой фракции» рандомные раздачи не в счёт.
"chosen_games": int(r["chosen_games"] or 0),
"name_ru_prepositional": faction_service.prepositional(
r["code"], r["name_ru"]
),
}
)
return out
@@ -196,17 +195,30 @@ def _recent_form(session: Session, user_id: int, group_id: int | None, limit: in
]
def _favorite_faction(session: Session, user_id: int) -> dict | None:
"""Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики."""
user = session.get(User, user_id)
if user is None or user.favorite_faction_id is None:
return None
faction = session.get(Faction, user.favorite_faction_id)
if faction is None:
return None
return {
"id": faction.id,
"code": faction.code,
"name_ru": faction.name_ru,
"expansion_id": faction.expansion_id,
}
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
overall = _overall_for_user(session, user_id, group_id)
factions = _faction_breakdown(session, user_id, group_id)
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
# Любимая — по числу партий, где игрок выбрал фракцию САМ (was_random=false):
# рандомная раздача о предпочтениях не говорит. Нет ручных выборов — нет любимой.
chosen = [f for f in factions if f["chosen_games"] > 0]
most_played = max(chosen, key=lambda f: f["chosen_games"]) if chosen else None
# Основная — просто самая игранная, включая рандомные раздачи.
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая
# рандомные раздачи.
main = max(factions, key=lambda f: f["games"]) if factions else None
return {
"user_id": user_id,
@@ -214,7 +226,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
"factions": factions,
"best_faction": best,
"worst_faction": worst,
"most_played_faction": most_played,
"favorite_faction": _favorite_faction(session, user_id),
"main_faction": main,
"recent_form": _recent_form(session, user_id, group_id),
"min_games": MIN_GAMES,
+12 -1
View File
@@ -11,7 +11,7 @@ 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
from app.models import AuthIdentity, Faction, GroupMember, User
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
_BIO_MAX = 500
@@ -185,6 +185,17 @@ def update_bio(session: Session, user: User, bio: str | None) -> 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:
raise ValidationError("Такой фракции не существует.")
user.favorite_faction_id = faction_id
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"):