Профиль: любимая фракция — выбор игрока, а не вывод из статистики
Раньше «любимая» вычислялась как самая частая среди партий с ручным выбором фракции. Теперь это поле профиля: 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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user