Профиль: история партий игрока и настройки её витрины
Выборки партий по игроку в бэкенде не было — только по группе. Добавлен
stats_service.user_match_list: завершённые партии игрока, свежие сверху;
сборка элементов вынесена из group_match_list в общий _match_items, чтобы
не дублировать её в двух местах.
Витрина профиля задаётся двумя колонками в users (миграция 0011):
history_mode (all/best) и history_detail (compact/full). Режим применяется
на бэкенде, а не на клиенте: это витрина владельца, и в том же виде
профиль видят гости. В режиме best берётся партия с максимальными League
Points из SCORED_CTE (при равных очках — более свежая).
GET /api/users/{user_id}/matches отдаёт список вместе с mode и detail —
гостю хватает одного запроса, чтобы отрисовать историю как задумал владелец.
#1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
"""Пользователь: настройки витрины истории партий в профиле.
|
||||||
|
|
||||||
|
Идемпотентна: на свежей БД столбцы создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
||||||
|
на существующей БД добавляет столбцы. render_as_batch включён в env.py (для SQLite).
|
||||||
|
|
||||||
|
Revision ID: 0011_user_history_prefs
|
||||||
|
Revises: 0010_user_favorite_faction
|
||||||
|
Create Date: 2026-09-07
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0011_user_history_prefs"
|
||||||
|
down_revision: Union[str, None] = "0010_user_favorite_faction"
|
||||||
|
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")}
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
if "history_mode" not in cols:
|
||||||
|
b.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"history_mode", sa.String(8), nullable=False, server_default="all"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "history_detail" not in cols:
|
||||||
|
b.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"history_detail", sa.String(8), nullable=False, server_default="compact"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
if "history_detail" in cols:
|
||||||
|
b.drop_column("history_detail")
|
||||||
|
if "history_mode" in cols:
|
||||||
|
b.drop_column("history_mode")
|
||||||
@@ -73,6 +73,10 @@ class User(SQLModel, table=True):
|
|||||||
"role <> 'admin' OR password_hash IS NOT NULL",
|
"role <> 'admin' OR password_hash IS NOT NULL",
|
||||||
name="ck_users_admin_has_password",
|
name="ck_users_admin_has_password",
|
||||||
),
|
),
|
||||||
|
CheckConstraint("history_mode IN ('all','best')", name="ck_users_history_mode"),
|
||||||
|
CheckConstraint(
|
||||||
|
"history_detail IN ('compact','full')", name="ck_users_history_detail"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
@@ -97,6 +101,16 @@ class User(SQLModel, table=True):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Витрина истории партий в профиле: что показывать (все / только лучшая по очкам)
|
||||||
|
# и насколько подробно. Действует и для гостей профиля, не только для владельца.
|
||||||
|
history_mode: str = Field(
|
||||||
|
default="all",
|
||||||
|
sa_column=Column(String(8), nullable=False, server_default="all"),
|
||||||
|
)
|
||||||
|
history_detail: str = Field(
|
||||||
|
default="compact",
|
||||||
|
sa_column=Column(String(8), nullable=False, server_default="compact"),
|
||||||
|
)
|
||||||
# Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление
|
# Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление
|
||||||
# по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан.
|
# по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан.
|
||||||
favorite_faction_id: int | None = Field(
|
favorite_faction_id: int | None = Field(
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
|||||||
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,
|
favorite_faction_id=user.favorite_faction_id,
|
||||||
|
history_mode=user.history_mode,
|
||||||
|
history_detail=user.history_detail,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,6 +75,8 @@ def update_me(
|
|||||||
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,
|
favorite_faction_id=user.favorite_faction_id,
|
||||||
|
history_mode=user.history_mode,
|
||||||
|
history_detail=user.history_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -90,6 +94,13 @@ def update_my_profile(
|
|||||||
user_service.update_bio(session, user, changed["bio"])
|
user_service.update_bio(session, user, changed["bio"])
|
||||||
if "favorite_faction_id" in changed:
|
if "favorite_faction_id" in changed:
|
||||||
user_service.update_favorite_faction(session, user, changed["favorite_faction_id"])
|
user_service.update_favorite_faction(session, user, changed["favorite_faction_id"])
|
||||||
|
if "history_mode" in changed or "history_detail" in changed:
|
||||||
|
user_service.update_history_prefs(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
mode=changed.get("history_mode"),
|
||||||
|
detail=changed.get("history_detail"),
|
||||||
|
)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=user.id,
|
actor_id=user.id,
|
||||||
@@ -155,6 +166,26 @@ def get_user_profile(
|
|||||||
return user_service.public_profile(session, user_id)
|
return user_service.public_profile(session, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/matches", response_model=s.MatchHistory)
|
||||||
|
def user_matches(
|
||||||
|
user_id: int,
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""История партий игрока. Режим — витрина владельца профиля: её видят и гости."""
|
||||||
|
owner = user_service.get_user(session, user_id)
|
||||||
|
data = stats_service.user_match_list(
|
||||||
|
session,
|
||||||
|
user_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
best_only=owner.history_mode == "best",
|
||||||
|
)
|
||||||
|
return {**data, "mode": owner.history_mode, "detail": owner.history_detail}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/stats", response_model=s.ProfileStats)
|
@router.get("/me/stats", response_model=s.ProfileStats)
|
||||||
def my_stats(
|
def my_stats(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ class UserRead(BaseModel):
|
|||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
|
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
|
||||||
favorite_faction_id: int | None = None
|
favorite_faction_id: int | None = None
|
||||||
|
# Витрина истории партий в профиле.
|
||||||
|
history_mode: str = "all"
|
||||||
|
history_detail: str = "compact"
|
||||||
|
|
||||||
|
|
||||||
class MeRead(UserRead):
|
class MeRead(UserRead):
|
||||||
@@ -107,6 +110,8 @@ class ProfileUpdate(BaseModel):
|
|||||||
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
|
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
|
||||||
bio: str | None = None
|
bio: str | None = None
|
||||||
favorite_faction_id: int | None = None
|
favorite_faction_id: int | None = None
|
||||||
|
history_mode: str | None = None
|
||||||
|
history_detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ActiveGroupUpdate(BaseModel):
|
class ActiveGroupUpdate(BaseModel):
|
||||||
@@ -306,6 +311,20 @@ class LeaderboardEntry(OverallStats):
|
|||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MatchHistory(BaseModel):
|
||||||
|
"""История партий игрока плюс настройки витрины его профиля.
|
||||||
|
|
||||||
|
Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так,
|
||||||
|
как выбрал её владелец, не делая второго запроса за профилем."""
|
||||||
|
|
||||||
|
items: list[MatchListItem] = []
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
mode: str
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
class Leaderboard(BaseModel):
|
class Leaderboard(BaseModel):
|
||||||
entries: list[LeaderboardEntry] = []
|
entries: list[LeaderboardEntry] = []
|
||||||
provisional: list[LeaderboardEntry] = []
|
provisional: list[LeaderboardEntry] = []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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 Faction, Group, GroupMember, Match, User
|
from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User
|
||||||
from app.services import faction_service, 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,
|
||||||
@@ -300,18 +300,10 @@ def group_stats(session: Session, group_id: int) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
def _match_items(session: Session, matches) -> list[dict]:
|
||||||
|
"""Элементы списка партий (общее для списка группы и истории игрока)."""
|
||||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
from app.services.match_service import participants_detail # избегаем цикла импорта
|
||||||
|
|
||||||
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
|
|
||||||
matches = session.exec(
|
|
||||||
select(Match)
|
|
||||||
.where(Match.group_id == group_id)
|
|
||||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for m in matches:
|
for m in matches:
|
||||||
parts = []
|
parts = []
|
||||||
@@ -343,7 +335,77 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
|
|||||||
"participants": parts,
|
"participants": parts,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
||||||
|
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
|
||||||
|
matches = session.exec(
|
||||||
|
select(Match)
|
||||||
|
.where(Match.group_id == group_id)
|
||||||
|
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
return {
|
||||||
|
"items": _match_items(session, matches),
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def user_match_list(
|
||||||
|
session: Session,
|
||||||
|
user_id: int,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
best_only: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
|
||||||
|
|
||||||
|
best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает
|
||||||
|
место и размер стола); при равных очках берём более свежую."""
|
||||||
|
if best_only:
|
||||||
|
sql = f"""
|
||||||
|
{SCORED_CTE}
|
||||||
|
SELECT s.match_id AS match_id
|
||||||
|
FROM scored s
|
||||||
|
WHERE s.user_id = :uid
|
||||||
|
ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
row = session.execute(text(sql), {"uid": user_id}).mappings().first()
|
||||||
|
matches = [session.get(Match, row["match_id"])] if row else []
|
||||||
|
return {
|
||||||
|
"items": _match_items(session, matches),
|
||||||
|
"total": len(matches),
|
||||||
|
"limit": 1,
|
||||||
|
"offset": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
where = (MatchParticipant.user_id == user_id, Match.status == "finished")
|
||||||
|
total = len(
|
||||||
|
session.exec(
|
||||||
|
select(Match.id)
|
||||||
|
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
||||||
|
.where(*where)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
matches = session.exec(
|
||||||
|
select(Match)
|
||||||
|
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
||||||
|
.where(*where)
|
||||||
|
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
return {
|
||||||
|
"items": _match_items(session, matches),
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
||||||
|
|||||||
@@ -185,6 +185,28 @@ def update_bio(session: Session, user: User, bio: str | None) -> User:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
_HISTORY_MODES = {"all", "best"}
|
||||||
|
_HISTORY_DETAILS = {"compact", "full"}
|
||||||
|
|
||||||
|
|
||||||
|
def update_history_prefs(
|
||||||
|
session: Session, user: User, *, mode: str | None = None, detail: str | None = None
|
||||||
|
) -> User:
|
||||||
|
"""Витрина истории партий: что показывать и насколько подробно. None — не менять."""
|
||||||
|
if mode is not None:
|
||||||
|
if mode not in _HISTORY_MODES:
|
||||||
|
raise ValidationError("Неизвестный режим истории партий.")
|
||||||
|
user.history_mode = mode
|
||||||
|
if detail is not None:
|
||||||
|
if detail not in _HISTORY_DETAILS:
|
||||||
|
raise ValidationError("Неизвестная подробность истории партий.")
|
||||||
|
user.history_detail = detail
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User:
|
def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User:
|
||||||
"""Любимая фракция — личный выбор игрока; None очищает выбор."""
|
"""Любимая фракция — личный выбор игрока; None очищает выбор."""
|
||||||
if faction_id is not None and session.get(Faction, faction_id) is None:
|
if faction_id is not None and session.get(Faction, faction_id) is None:
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
"""Профиль: «о себе» (bio), любимая фракция, аватар (загрузка/отдача/удаление),
|
"""Профиль: «о себе» (bio), любимая фракция, история партий, аватар
|
||||||
публичный профиль."""
|
(загрузка/отдача/удаление), публичный профиль."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
from tests.conftest import (
|
||||||
|
add_group_member,
|
||||||
|
create_finished_match,
|
||||||
|
csrf_headers,
|
||||||
|
login,
|
||||||
|
start_match,
|
||||||
|
)
|
||||||
|
|
||||||
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
|
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
|
||||||
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||||
@@ -240,3 +246,156 @@ def test_prepositional_dictionary_and_fallback():
|
|||||||
)
|
)
|
||||||
# Фракция, заведённая админом мимо словаря, не роняет вывод.
|
# Фракция, заведённая админом мимо словаря, не роняет вывод.
|
||||||
assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы"
|
assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── История партий в профиле (#1) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _history(client: TestClient, user_id: int) -> dict:
|
||||||
|
r = client.get(f"/api/users/{user_id}/matches")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _group_with(client: TestClient, engine, *nicknames: str) -> tuple[int, list[int], list[int]]:
|
||||||
|
"""Группа со всеми дополнениями + перечисленные соседи. → (group_id, их user_id, faction_id)."""
|
||||||
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
mates = [add_group_member(engine, gid, nick) for nick in nicknames]
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
return gid, mates, fids
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_lists_only_own_finished_matches(client: TestClient, engine):
|
||||||
|
"""В историю идут только завершённые партии этого игрока."""
|
||||||
|
me = login(client, "Историк")
|
||||||
|
gid, (mate,), fids = _group_with(client, engine, "Сосед")
|
||||||
|
|
||||||
|
create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": mate, "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Незавершённая партия мест не имеет и в историю попадать не должна.
|
||||||
|
assert start_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[2]},
|
||||||
|
{"user_id": mate, "faction_id": fids[3]},
|
||||||
|
],
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
data = _history(client, me["id"])
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert [m["status"] for m in data["items"]] == ["finished"]
|
||||||
|
# Значения по умолчанию едут вместе со списком — гостю хватает одного запроса.
|
||||||
|
assert data["mode"] == "all"
|
||||||
|
assert data["detail"] == "compact"
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_excludes_matches_without_the_player(client: TestClient, engine):
|
||||||
|
"""Чужая партия в историю игрока не попадает, даже внутри его группы."""
|
||||||
|
me = login(client, "Наблюдатель")
|
||||||
|
gid, (mate, third), fids = _group_with(client, engine, "Игрок2", "Игрок3")
|
||||||
|
|
||||||
|
# Партию заводит сосед, сам игрок в ней не участвует.
|
||||||
|
login(client, "Игрок2")
|
||||||
|
create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": mate, "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": third, "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _history(client, me["id"])["total"] == 0
|
||||||
|
assert _history(client, mate)["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_best_mode_picks_highest_points(client: TestClient, engine):
|
||||||
|
"""Режим best берёт партию с максимальными League Points, а не самую свежую.
|
||||||
|
|
||||||
|
Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0."""
|
||||||
|
me = login(client, "Лучший")
|
||||||
|
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
|
||||||
|
|
||||||
|
create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": a, "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
|
||||||
|
{"user_id": b, "faction_id": fids[2], "place": 3},
|
||||||
|
{"user_id": c, "faction_id": fids[3], "place": 4},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Свежее, но по очкам хуже — последнее место на двоих.
|
||||||
|
create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": a, "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
client.patch(
|
||||||
|
"/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
data = _history(client, me["id"])
|
||||||
|
assert data["mode"] == "best"
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["player_count"] == 4 # старшая партия, но с лучшими очками
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_prefs_saved_and_validated(client: TestClient):
|
||||||
|
"""Настройки витрины сохраняются; мусор отклоняется, не сбивая сохранённое."""
|
||||||
|
login(client, "Настройщик")
|
||||||
|
r = client.patch(
|
||||||
|
"/api/users/me/profile",
|
||||||
|
json={"history_mode": "best", "history_detail": "full"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["history_mode"] == "best"
|
||||||
|
assert r.json()["history_detail"] == "full"
|
||||||
|
assert client.get("/api/users/me").json()["history_detail"] == "full"
|
||||||
|
|
||||||
|
bad = client.patch(
|
||||||
|
"/api/users/me/profile", json={"history_mode": "неведомое"}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert bad.status_code == 422
|
||||||
|
assert client.get("/api/users/me").json()["history_mode"] == "best"
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_uses_owner_mode_for_guests(client: TestClient, engine):
|
||||||
|
"""Гость видит историю в том режиме, который выбрал владелец профиля."""
|
||||||
|
me = login(client, "Витрина")
|
||||||
|
gid, (mate,), fids = _group_with(client, engine, "Партнёр")
|
||||||
|
for _ in range(2):
|
||||||
|
create_finished_match(
|
||||||
|
client,
|
||||||
|
gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": mate, "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
client.patch(
|
||||||
|
"/api/users/me/profile",
|
||||||
|
json={"history_mode": "best", "history_detail": "full"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
login(client, "Прохожий")
|
||||||
|
data = _history(client, me["id"])
|
||||||
|
assert data["mode"] == "best"
|
||||||
|
assert data["detail"] == "full"
|
||||||
|
assert data["total"] == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user