Merge pull request 'Изменение логики любимых и основных фракций (#17)' (#20) from issue-17-faction-favorite-and-most-played into dev

Reviewed-on: #20
This commit was merged in pull request #20.
This commit is contained in:
2026-09-07 19:03:05 +03:00
13 changed files with 299 additions and 38 deletions
@@ -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)
+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"):
+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", "Ксеносы") == "Ксеносы"
+9 -1
View File
@@ -1357,6 +1357,8 @@ export interface components {
code: string;
/** Name Ru */
name_ru: string;
/** Name Ru Prepositional */
name_ru_prepositional: string;
/** Expansion Code */
expansion_code: string;
/** Games */
@@ -1761,6 +1763,8 @@ export interface components {
bio?: string | null;
/** Avatar Url */
avatar_url?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
/**
* Groups
* @default []
@@ -1881,7 +1885,7 @@ export interface components {
factions: components["schemas"]["FactionStat"][];
best_faction?: components["schemas"]["FactionStat"] | null;
worst_faction?: components["schemas"]["FactionStat"] | null;
most_played_faction?: components["schemas"]["FactionStat"] | null;
favorite_faction?: components["schemas"]["FactionRead"] | null;
main_faction?: components["schemas"]["FactionStat"] | null;
/**
* Recent Form
@@ -1898,6 +1902,8 @@ export interface components {
ProfileUpdate: {
/** Bio */
bio?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
};
/** PublicProfile */
PublicProfile: {
@@ -2004,6 +2010,8 @@ export interface components {
bio?: string | null;
/** Avatar Url */
avatar_url?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
};
/**
* UserSuggestion
+5 -4
View File
@@ -38,16 +38,17 @@ export function ProfileStatsCard({
<div>Очки (рейтинг)</div>
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
</div>
{stats.most_played_faction && (
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
{stats.favorite_faction && (
<div className="row-between">
<div>Любимая фракция</div>
<b>{stats.most_played_faction.name_ru}</b>
<b>{stats.favorite_faction.name_ru}</b>
</div>
)}
{stats.main_faction && (
<div className="row-between">
<div>Основная фракция</div>
<b>{stats.main_faction.name_ru}</b>
<div>Чаще всего играет на</div>
<b>{stats.main_faction.name_ru_prepositional}</b>
</div>
)}
</div>
+20
View File
@@ -117,6 +117,26 @@ export function useUpdateBio() {
});
}
// Любимая фракция — личный выбор игрока (в статистике не вычисляется). null снимает выбор.
// Шлём только это поле: роутер обновляет лишь пришедшие, «о себе» не затирается.
export function useUpdateFavoriteFaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (factionId: number | null): Promise<Me> =>
unwrap(
await api.PATCH("/api/users/me/profile", {
body: { favorite_faction_id: factionId },
}),
),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
// Карточка статистики показывает любимую фракцию — её тоже перезапрашиваем.
qc.invalidateQueries({ queryKey: qk.myStats });
qc.invalidateQueries({ queryKey: qk.home });
},
});
}
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
function readCsrfToken(): string | null {
+8
View File
@@ -10,6 +10,14 @@ export function useExpansions() {
});
}
// Весь справочник фракций (для выбора любимой в профиле — он не зависит от группы).
export function useFactions() {
return useQuery({
queryKey: qk.factions,
queryFn: async () => unwrap(await api.GET("/api/factions")),
});
}
export function useGroupFactions(groupId: number | null) {
return useQuery({
queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"],
+50
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { Avatar } from "../components/Avatar";
import { PickerSelect } from "../components/PickerSelect";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { useToast } from "../context/ToastContext";
@@ -11,9 +12,11 @@ import {
useLogout,
useMe,
useUpdateBio,
useUpdateFavoriteFaction,
useUpdateNickname,
useUploadAvatar,
} from "../hooks/auth";
import { useExpansions, useFactions } from "../hooks/reference";
import { useMyStats } from "../hooks/stats";
export function AccountPage() {
@@ -21,6 +24,9 @@ export function AccountPage() {
const { data: stats } = useMyStats();
const updateNick = useUpdateNickname();
const updateBio = useUpdateBio();
const updateFavorite = useUpdateFavoriteFaction();
const { data: factions } = useFactions();
const { data: expansions } = useExpansions();
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const logout = useLogout();
@@ -35,6 +41,20 @@ export function AccountPage() {
// bio редактируется локально; до первого ввода берём значение из профиля.
const bioValue = bio ?? me.bio ?? "";
// Любимая фракция выбирается из ПОЛНОГО справочника: это свойство игрока, а не
// группы, и не должно зависеть от подключённых у неё дополнений.
const favorite = factions?.find((f) => f.id === me.favorite_faction_id) ?? null;
const expansionName = (id: number) => expansions?.find((e) => e.id === id)?.name_ru ?? "";
const saveFavorite = async (factionId: number | null) => {
try {
await updateFavorite.mutateAsync(factionId);
toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saveNick = async () => {
const nick = nickname.trim();
if (!nick) return;
@@ -140,6 +160,36 @@ export function AccountPage() {
</div>
</div>
<div className="field" style={{ marginTop: 10 }}>
<label className="label">Любимая фракция</label>
<div className="row">
<PickerSelect
selected={favorite}
options={factions ?? []}
placeholder="Не выбрана"
renderOption={(f) => (
<>
<span className="nick">{f.name_ru}</span>
<span className="muted small" style={{ marginLeft: "auto" }}>
{expansionName(f.expansion_id)}
</span>
</>
)}
renderValue={(f) => <span className="nick">{f.name_ru}</span>}
onPick={(f) => saveFavorite(f.id)}
/>
{favorite && (
<button
className="btn btn-ghost small"
onClick={() => saveFavorite(null)}
disabled={updateFavorite.isPending}
>
Убрать
</button>
)}
</div>
</div>
<div className="field" style={{ marginTop: 10 }}>
<label className="label">Сменить никнейм</label>
<div className="row">