Изменение логики любимых и основных фракций (#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, 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")) is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL # Выбранный титул (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, active_group_id=user.active_group_id,
bio=user.bio, bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] 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, groups=groups,
) )
@@ -71,6 +72,7 @@ def update_me(
active_group_id=user.active_group_id, active_group_id=user.active_group_id,
bio=user.bio, bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] 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), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.MeRead: ) -> 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( audit_service.record(
session, session,
actor_id=user.id, actor_id=user.id,
action="update", action="update",
entity_type="user", entity_type="user",
entity_id=user.id, entity_id=user.id,
payload={"bio": True}, payload={key: True for key in changed},
ip=request.client.host if request.client else None, ip=request.client.host if request.client else None,
) )
session.commit() session.commit()
+10 -2
View File
@@ -90,6 +90,8 @@ class UserRead(BaseModel):
active_group_id: int | None = None active_group_id: int | None = None
bio: str | None = None bio: str | None = None
avatar_url: str | None = None avatar_url: str | None = None
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
favorite_faction_id: int | None = None
class MeRead(UserRead): class MeRead(UserRead):
@@ -101,7 +103,10 @@ class NicknameUpdate(BaseModel):
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
# Оба поля необязательны и обновляются, только если реально переданы
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
bio: str | None = None bio: str | None = None
favorite_faction_id: int | None = None
class ActiveGroupUpdate(BaseModel): class ActiveGroupUpdate(BaseModel):
@@ -311,6 +316,8 @@ class FactionStat(BaseModel):
faction_id: int faction_id: int
code: str code: str
name_ru: str name_ru: str
# Название в предложном падеже — для строки «Чаще всего играет на …».
name_ru_prepositional: str
expansion_code: str expansion_code: str
games: int games: int
wins: int wins: int
@@ -331,8 +338,9 @@ class ProfileStats(BaseModel):
factions: list[FactionStat] = [] factions: list[FactionStat] = []
best_faction: FactionStat | None = None best_faction: FactionStat | None = None
worst_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 main_faction: FactionStat | None = None
recent_form: list[RecentFormItem] = [] recent_form: list[RecentFormItem] = []
# Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал # Порог «Новичков» (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) select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
).all() ).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 sqlmodel import Session, select
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.models import Group, GroupMember, Match from app.models import Faction, Group, GroupMember, Match, User
from app.services import group_service, membership_service, user_service from app.services import faction_service, group_service, membership_service, user_service
from app.services.scoring import ( from app.services.scoring import (
FACTION_MIN_GAMES, FACTION_MIN_GAMES,
MATCH_POINTS_SQL, 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, SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate, AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place, AVG(s.place) AS avg_place,
AVG(s.points) * 100 AS score, -- фракции: чистое среднее (служебная метрика AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика
-- «лучшая/худшая», сглаживание задавило бы её к 50) -- «лучшая/худшая», сглаживание задавило бы её к 50)
SUM(CASE WHEN s.was_random THEN 0 ELSE 1 END) AS chosen_games
FROM scored s FROM scored s
JOIN factions f ON f.id = s.faction_id JOIN factions f ON f.id = s.faction_id
JOIN expansions e ON e.id = f.expansion_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), "win_rate": _round(r["win_rate"] or 0.0, 4),
"avg_place": _round(r["avg_place"], 2), "avg_place": _round(r["avg_place"], 2),
"score": _round(r["score"], 1), "score": _round(r["score"], 1),
# Служебное (в API-схему не входит): партии, где фракция выбрана "name_ru_prepositional": faction_service.prepositional(
# вручную, — для «любимой фракции» рандомные раздачи не в счёт. r["code"], r["name_ru"]
"chosen_games": int(r["chosen_games"] or 0), ),
} }
) )
return out 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: def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
overall = _overall_for_user(session, user_id, group_id) overall = _overall_for_user(session, user_id, group_id)
factions = _faction_breakdown(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] 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 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 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 main = max(factions, key=lambda f: f["games"]) if factions else None
return { return {
"user_id": user_id, "user_id": user_id,
@@ -214,7 +226,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
"factions": factions, "factions": factions,
"best_faction": best, "best_faction": best,
"worst_faction": worst, "worst_faction": worst,
"most_played_faction": most_played, "favorite_faction": _favorite_faction(session, user_id),
"main_faction": main, "main_faction": main,
"recent_form": _recent_form(session, user_id, group_id), "recent_form": _recent_form(session, user_id, group_id),
"min_games": MIN_GAMES, "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.auth.provider import ExternalIdentity
from app.core.config import settings from app.core.config import settings
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError 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) _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
_BIO_MAX = 500 _BIO_MAX = 500
@@ -185,6 +185,17 @@ def update_bio(session: Session, user: User, bio: str | None) -> User:
return 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: def sniff_image_ext(content: bytes) -> str | None:
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка.""" """Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
if content.startswith(b"\x89PNG\r\n\x1a\n"): if content.startswith(b"\x89PNG\r\n\x1a\n"):
+69 -14
View File
@@ -1,4 +1,5 @@
"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль.""" """Профиль: «о себе» (bio), любимая фракция, аватар (загрузка/отдача/удаление),
публичный профиль."""
from __future__ import annotations from __future__ import annotations
from fastapi.testclient import TestClient 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"] # после загрузки — ссылка 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, "Фракционер") me = login(client, "Фракционер")
exps = [e["id"] for e in client.get("/api/expansions").json()] exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post( 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() stats = client.get("/api/users/me/stats").json()
assert stats["most_played_faction"]["faction_id"] == fids[2] # B, хоть партий с A больше # Условия выбора не важны: A сыграна чаще, пусть и по рандому.
assert stats["main_faction"]["faction_id"] == fids[0] # основная — по всем партиям assert stats["main_faction"]["faction_id"] == fids[0]
assert stats["main_faction"]["name_ru_prepositional"] # падежная форма проставлена
# У соперника все фракции выбраны вручную — любимая считается как раньше. # Любимая больше не выводится из истории — она задаётся игроком вручную.
login(client, "Партнёр") assert stats["favorite_faction"] is None
stats2 = client.get("/api/users/me/stats").json()
assert stats2["most_played_faction"]["faction_id"] == fids[1]
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, "Рандомщик") me = login(client, "Рандомщик")
exps = [e["id"] for e in client.get("/api/expansions").json()] exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post( 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() 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", "Ксеносы") == "Ксеносы"