Изменение логики любимых и основных фракций (#17) #20

Merged
NotBigGhost merged 2 commits from issue-17-faction-favorite-and-most-played into dev 2026-09-07 19:03:06 +03:00
8 changed files with 207 additions and 33 deletions
Showing only changes of commit 60b22dd8df - Show all commits
@@ -0,0 +1,46 @@
"""Пользователь: любимая фракция (личный выбор в профиле).
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op;
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
Revision ID: 0010_user_favorite_faction
Revises: 0009_participant_eliminated
Create Date: 2026-09-07
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0010_user_favorite_faction"
down_revision: Union[str, None] = "0009_participant_eliminated"
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")}
if "favorite_faction_id" not in cols:
with op.batch_alter_table("users") as b:
b.add_column(sa.Column("favorite_faction_id", sa.Integer(), nullable=True))
b.create_foreign_key(
"fk_users_favorite_faction_id",
"factions",
["favorite_faction_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
if "favorite_faction_id" in cols:
with op.batch_alter_table("users") as b:
b.drop_constraint("fk_users_favorite_faction_id", type_="foreignkey")
b.drop_column("favorite_faction_id")
+10
View File
@@ -97,6 +97,16 @@ class User(SQLModel, table=True):
index=True,
)
)
# Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление
# по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан.
favorite_faction_id: int | None = Field(
default=None,
sa_column=Column(
Integer,
ForeignKey("factions.id", ondelete="SET NULL"),
nullable=True,
),
)
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
# (выдача ачивок игрокам — следующий этап).
+10 -2
View File
@@ -31,6 +31,7 @@ def build_me(session: Session, user: User) -> s.MeRead:
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]
favorite_faction_id=user.favorite_faction_id,
groups=groups,
)
@@ -71,6 +72,7 @@ def update_me(
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]
favorite_faction_id=user.favorite_faction_id,
)
@@ -81,14 +83,20 @@ def update_my_profile(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
user_service.update_bio(session, user, body.bio)
# Обновляем только реально переданные поля: «О себе» и любимая фракция правятся
# по отдельности, и частичный запрос не должен обнулять соседнее поле.
changed = body.model_dump(exclude_unset=True)
if "bio" in changed:
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"])
audit_service.record(
session,
actor_id=user.id,
action="update",
entity_type="user",
entity_id=user.id,
payload={"bio": True},
payload={key: True for key in changed},
ip=request.client.host if request.client else None,
)
session.commit()
+10 -2
View File
@@ -90,6 +90,8 @@ class UserRead(BaseModel):
active_group_id: int | None = None
bio: str | None = None
avatar_url: str | None = None
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
favorite_faction_id: int | None = None
class MeRead(UserRead):
@@ -101,7 +103,10 @@ class NicknameUpdate(BaseModel):
class ProfileUpdate(BaseModel):
# Оба поля необязательны и обновляются, только если реально переданы
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
bio: str | None = None
favorite_faction_id: int | None = None
class ActiveGroupUpdate(BaseModel):
@@ -311,6 +316,8 @@ class FactionStat(BaseModel):
faction_id: int
code: str
name_ru: str
# Название в предложном падеже — для строки «Чаще всего играет на …».
name_ru_prepositional: str
expansion_code: str
games: int
wins: int
@@ -331,8 +338,9 @@ class ProfileStats(BaseModel):
factions: list[FactionStat] = []
best_faction: FactionStat | None = None
worst_faction: FactionStat | None = None
# Любимая — по партиям с ручным выбором фракции; основная — по всем (включая рандом).
most_played_faction: FactionStat | None = None
# Любимая — личный выбор игрока в профиле (не статистика).
favorite_faction: FactionRead | None = None
# «Чаще всего играет на» — самая игранная по всем партиям, включая рандомные раздачи.
main_faction: FactionStat | None = None
recent_form: list[RecentFormItem] = []
# Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал
+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)
+25 -13
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, -- фракции: чистое среднее (служебная метрика
AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика
-- «лучшая/худшая», сглаживание задавило бы её к 50)
SUM(CASE WHEN s.was_random THEN 0 ELSE 1 END) AS chosen_games
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"):
+69 -14
View File
@@ -1,4 +1,5 @@
"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль."""
"""Профиль: «о себе» (bio), любимая фракция, аватар (загрузка/отдача/удаление),
публичный профиль."""
from __future__ import annotations
from fastapi.testclient import TestClient
@@ -126,8 +127,8 @@ def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch
assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка
def test_most_played_faction_ignores_random(client: TestClient, engine):
"""Любимая фракция — только по сознательному выбору: рандомные раздачи не в счёт."""
def test_main_faction_counts_random_games(client: TestClient, engine):
"""«Чаще всего играет на» — по всей истории, включая рандомные раздачи."""
me = login(client, "Фракционер")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
@@ -156,17 +157,15 @@ def test_most_played_faction_ignores_random(client: TestClient, engine):
)
stats = client.get("/api/users/me/stats").json()
assert stats["most_played_faction"]["faction_id"] == fids[2] # B, хоть партий с A больше
assert stats["main_faction"]["faction_id"] == fids[0] # основная — по всем партиям
# У соперника все фракции выбраны вручную — любимая считается как раньше.
login(client, "Партнёр")
stats2 = client.get("/api/users/me/stats").json()
assert stats2["most_played_faction"]["faction_id"] == fids[1]
# Условия выбора не важны: A сыграна чаще, пусть и по рандому.
assert stats["main_faction"]["faction_id"] == fids[0]
assert stats["main_faction"]["name_ru_prepositional"] # падежная форма проставлена
# Любимая больше не выводится из истории — она задаётся игроком вручную.
assert stats["favorite_faction"] is None
def test_most_played_faction_absent_when_all_random(client: TestClient, engine):
"""Если игрок ни разу не выбирал фракцию сам — любимой нет вовсе."""
def test_main_faction_present_for_random_only_player(client: TestClient, engine):
"""Игроку, который всегда играет рандомом, «чаще всего играет на» всё равно считается."""
me = login(client, "Рандомщик")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
@@ -183,5 +182,61 @@ def test_most_played_faction_absent_when_all_random(client: TestClient, engine):
],
)
stats = client.get("/api/users/me/stats").json()
assert stats["most_played_faction"] is None
assert stats["main_faction"]["faction_id"] == fids[0] # основная есть и у рандомщика
assert stats["main_faction"]["faction_id"] == fids[0]
def test_favorite_faction_set_and_clear(client: TestClient):
"""Любимая фракция — личный выбор игрока: сохраняется и снимается."""
login(client, "Выбирающий")
fid = client.get("/api/factions").json()[0]["id"]
r = client.patch(
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
assert r.json()["favorite_faction_id"] == fid
assert client.get("/api/users/me").json()["favorite_faction_id"] == fid
# Партий нет вовсе, а любимая фракция всё равно показывается — она не из статистики.
assert client.get("/api/users/me/stats").json()["favorite_faction"]["id"] == fid
r = client.patch(
"/api/users/me/profile", json={"favorite_faction_id": None}, headers=csrf_headers(client)
)
assert r.status_code == 200
assert r.json()["favorite_faction_id"] is None
assert client.get("/api/users/me/stats").json()["favorite_faction"] is None
def test_favorite_faction_rejects_unknown(client: TestClient):
login(client, "Фантазёр")
r = client.patch(
"/api/users/me/profile", json={"favorite_faction_id": 99999}, headers=csrf_headers(client)
)
assert r.status_code == 422
def test_bio_update_keeps_favorite_faction(client: TestClient):
"""Частичный PATCH: правка «о себе» не должна обнулять выбранную фракцию."""
login(client, "Аккуратный")
fid = client.get("/api/factions").json()[0]["id"]
client.patch(
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
)
r = client.patch("/api/users/me/profile", json={"bio": "Только био"}, headers=csrf_headers(client))
assert r.status_code == 200
assert r.json()["bio"] == "Только био"
assert r.json()["favorite_faction_id"] == fid
def test_prepositional_dictionary_and_fallback():
"""Падежная форма берётся из словаря; незнакомый код — именительный как есть."""
from app.services import faction_service
assert faction_service.prepositional("orks", "Орки") == "Орках"
assert (
faction_service.prepositional("astra_militarum", "Имперская гвардия")
== "Имперской гвардии"
)
# Фракция, заведённая админом мимо словаря, не роняет вывод.
assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы"