Simplify: единый источник времени, версии партии и запросов

Проход /simplify по backend/app:

- Три источника «текущего времени» (_utcnow в models.py и match_service.py при
  живом timeutil.utcnow) сведены к одному — это прямо инвариант из CLAUDE.md.
- Бамп версии партии из двух независимых мест собран в match_service.touch:
  следующая точка мутации, не трогающая строку matches, теперь имеет очевидный
  способ сделать правильно.
- membership_service переиспользует group_service.get_membership вместо трёх
  копий одного запроса; защита последнего владельца — один хелпер на удаление
  и смену роли вместо двух похожих блоков.
- Убраны N+1: участники страницы партий и ники пригласивших берутся одним
  запросом вместо запроса на строку (20 партий = 20 лишних запросов с двумя
  join каждый).
- Счётчики партий считает СУБД (COUNT/MAX) вместо выгрузки всех строк ради
  len() и max() в Python.

#8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
2026-09-09 18:45:50 +03:00
co-authored by Claude Opus 5
parent 1831a3e033
commit 8ac2757cd6
6 changed files with 129 additions and 132 deletions
+23 -23
View File
@@ -4,7 +4,7 @@
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from datetime import date, datetime
from sqlalchemy import (
JSON,
@@ -23,9 +23,9 @@ from sqlalchemy import (
)
from sqlmodel import Field, SQLModel
from app.core.timeutil import utcnow
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── Справочники: дополнения и фракции ───────────────────────────────────────
@@ -38,7 +38,7 @@ class Expansion(SQLModel, table=True):
name_ru: str = Field(sa_column=Column(String(64), nullable=False))
is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
sort_order: int = Field(default=0, nullable=False)
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
class Faction(SQLModel, table=True):
@@ -56,7 +56,7 @@ class Faction(SQLModel, table=True):
)
)
sort_order: int = Field(default=0, nullable=False)
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Пользователи и идентичности ─────────────────────────────────────────────
@@ -125,10 +125,10 @@ class User(SQLModel, table=True):
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
# (выдача ачивок игрокам — следующий этап).
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field(
default_factory=_utcnow,
sa_column_kwargs={"onupdate": _utcnow},
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
nullable=False,
)
@@ -150,7 +150,7 @@ class UserAchievement(SQLModel, table=True):
)
)
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
earned_at: datetime = Field(default_factory=_utcnow, nullable=False)
earned_at: datetime = Field(default_factory=utcnow, nullable=False)
class AuthIdentity(SQLModel, table=True):
@@ -167,7 +167,7 @@ class AuthIdentity(SQLModel, table=True):
)
provider: str = Field(sa_column=Column(String(16), nullable=False))
external_id: str = Field(sa_column=Column(String(64), nullable=False))
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Группы и членство ───────────────────────────────────────────────────────
@@ -182,10 +182,10 @@ class Group(SQLModel, table=True):
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
)
)
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field(
default_factory=_utcnow,
sa_column_kwargs={"onupdate": _utcnow},
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
nullable=False,
)
@@ -209,7 +209,7 @@ class GroupMember(SQLModel, table=True):
)
)
role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member"))
joined_at: datetime = Field(default_factory=_utcnow, nullable=False)
joined_at: datetime = Field(default_factory=utcnow, nullable=False)
class GroupInvitation(SQLModel, table=True):
@@ -237,7 +237,7 @@ class GroupInvitation(SQLModel, table=True):
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
)
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
class GroupExpansion(SQLModel, table=True):
@@ -257,7 +257,7 @@ class GroupExpansion(SQLModel, table=True):
Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False
)
)
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Партии и участники ──────────────────────────────────────────────────────
@@ -295,10 +295,10 @@ class Match(SQLModel, table=True):
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
)
)
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field(
default_factory=_utcnow,
sa_column_kwargs={"onupdate": _utcnow},
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
nullable=False,
)
@@ -333,7 +333,7 @@ class MatchParticipant(SQLModel, table=True):
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
class MatchAttachment(SQLModel, table=True):
@@ -359,7 +359,7 @@ class MatchAttachment(SQLModel, table=True):
storage_path: str = Field(sa_column=Column(String(255), nullable=False))
mime_type: str = Field(sa_column=Column(String(64), nullable=False))
size_bytes: int = Field(sa_column=Column(Integer, nullable=False))
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Уведомления ─────────────────────────────────────────────────────────────
@@ -387,7 +387,7 @@ class Notification(SQLModel, table=True):
body: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
link: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
read_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False, index=True)
created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
# ─── Журнал аудита ───────────────────────────────────────────────────────────
@@ -411,7 +411,7 @@ class AuditLog(SQLModel, table=True):
payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True))
ip: str | None = Field(sa_column=Column(String(45), nullable=True))
user_agent: str | None = Field(sa_column=Column(String(256), nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
# Заготовка под будущие вложения (НЕ в v1-миграции, добавится отдельно):
+6 -11
View File
@@ -11,7 +11,6 @@ from sqlmodel import Session, select
from app.core.config import settings
from app.core.errors import ConflictError, NotFoundError
from app.core.timeutil import utcnow
from app.models import Match, MatchAttachment, User
MAX_ATTACHMENTS = 10
@@ -40,17 +39,11 @@ def file_path(att: MatchAttachment) -> Path:
return Path(settings.upload_dir) / att.storage_path
def _touch(session: Session, match: Match) -> None:
"""Двинуть версию партии: набор вложений виден в MatchRead, а сама строка matches
при работе с ними не меняется — без этого оптимистичная блокировка проспала бы
конкурентную правку."""
match.updated_at = utcnow()
session.add(match)
def add_photo(
session: Session, match: Match, user: User, content: bytes, ext: str, mime: str
) -> MatchAttachment:
from app.services import match_service # избегаем цикла импорта
if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type]
raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.")
att = MatchAttachment(
@@ -69,7 +62,7 @@ def add_photo(
abs_path.write_bytes(content)
att.storage_path = rel
session.add(att)
_touch(session, match)
match_service.touch(session, match)
session.commit()
session.refresh(att)
return att
@@ -83,6 +76,8 @@ def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachme
def delete(session: Session, match: Match, att_id: int) -> None:
from app.services import match_service # избегаем цикла импорта
att = get_for_match(session, match.id, att_id) # type: ignore[arg-type]
path = file_path(att)
if path.exists():
@@ -91,7 +86,7 @@ def delete(session: Session, match: Match, att_id: int) -> None:
except OSError:
pass
session.delete(att)
_touch(session, match)
match_service.touch(session, match)
session.commit()
+7 -6
View File
@@ -5,6 +5,7 @@ GroupMember). Лимит участников — общий с membership_servi
"""
from __future__ import annotations
from sqlalchemy.orm import aliased
from sqlmodel import Session, select
from app.core.errors import ConflictError, NotFoundError, ValidationError
@@ -70,17 +71,17 @@ def create_invitation(
def list_for_user(session: Session, user_id: int) -> list[dict]:
inviter = aliased(User)
rows = session.exec(
select(GroupInvitation, Group.name).join(Group, Group.id == GroupInvitation.group_id)
select(GroupInvitation, Group.name, inviter.nickname)
.join(Group, Group.id == GroupInvitation.group_id)
# LEFT JOIN: пригласивший мог быть удалён (invited_by_id → SET NULL).
.join(inviter, inviter.id == GroupInvitation.invited_by_id, isouter=True)
.where(GroupInvitation.user_id == user_id)
.order_by(GroupInvitation.created_at.desc())
).all()
out = []
for inv, gname in rows:
inviter_nick = None
if inv.invited_by_id is not None:
inviter = session.get(User, inv.invited_by_id)
inviter_nick = inviter.nickname if inviter else None
for inv, gname, inviter_nick in rows:
out.append(
{
"id": inv.id,
+16 -13
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import random
from dataclasses import dataclass
from datetime import date, datetime, timezone
from datetime import date
from sqlmodel import Session, select
@@ -16,7 +16,7 @@ from app.core.errors import (
NotFoundError,
ValidationError,
)
from app.core.timeutil import app_today, iso_utc
from app.core.timeutil import app_today, iso_utc, utcnow
from app.models import Faction, GroupMember, Match, MatchParticipant, User
from app.services import group_service
@@ -56,10 +56,6 @@ class ParticipantInput:
comment: str | None = None
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def round_to_30(minutes: float) -> int:
"""Округление длительности до получаса, минимум 30 минут."""
return max(30, int(round(minutes / 30.0)) * 30)
@@ -77,6 +73,17 @@ def match_version(match: Match) -> str:
return iso_utc(match.updated_at)
def touch(session: Session, match: Match) -> None:
"""Пометить партию изменённой (без commit).
Версия для оптимистичной блокировки — это updated_at, а onupdate у SQLAlchemy
срабатывает только при реальном UPDATE строки matches. Правки, меняющие лишь
связанные сущности (участники, вложения), строку не трогают, поэтому каждая
такая точка обязана позвать touch — иначе конкурентная запись пройдёт молча."""
match.updated_at = utcnow()
session.add(match)
def assert_version(match: Match, expected: str | None) -> None:
"""Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию)."""
if expected is not None and expected != match_version(match):
@@ -188,7 +195,7 @@ def create_match(
session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster]
)
now = _utcnow()
now = utcnow()
match = Match(
group_id=group_id,
status="in_progress",
@@ -264,7 +271,7 @@ def finish_match(
p.faction_id = f.faction_id
session.add(p)
now = _utcnow()
now = utcnow()
match.finished_at = now
started = match.started_at
if started is not None:
@@ -351,11 +358,7 @@ def update_match(
)
match.player_count = len(participants)
# Версия партии = updated_at, а onupdate срабатывает только при реальном UPDATE
# строки matches. Правка одних участников его не вызывает, и тогда оптимистичная
# блокировка молча пропускала бы конкурентную запись — поэтому бампаем явно.
match.updated_at = _utcnow()
session.add(match)
touch(session, match)
session.commit()
session.refresh(match)
return match
+24 -33
View File
@@ -5,6 +5,7 @@ from sqlmodel import Session, select
from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
from app.models import Group, GroupMember, User
from app.services import group_service
MAX_GROUP_SIZE = 10
@@ -33,11 +34,7 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
if member_count >= MAX_GROUP_SIZE:
raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).")
existing = session.exec(
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.user_id == user.id
)
).first()
existing = group_service.get_membership(session, group.id, user.id)
if existing is not None:
raise ConflictError("Игрок уже в группе.")
@@ -48,22 +45,26 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
return member, user
def remove_member(session: Session, group: Group, user_id: int) -> None:
member = session.exec(
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.user_id == user_id
def _assert_not_last_owner(session: Session, group: Group, member: GroupMember, message: str) -> None:
"""Группа без владельца неисправима: назначить нового становится некому."""
if member.role != "owner":
return
owners = session.exec(
select(GroupMember.id).where(
GroupMember.group_id == group.id, GroupMember.role == "owner"
)
).first()
).all()
if len(owners) <= 1:
raise ForbiddenError(message)
def remove_member(session: Session, group: Group, user_id: int) -> None:
member = group_service.get_membership(session, group.id, user_id)
if member is None:
raise NotFoundError("Игрок не состоит в группе.")
if member.role == "owner":
owners = session.exec(
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.role == "owner"
)
).all()
if len(owners) <= 1:
raise ForbiddenError("Нельзя удалить последнего владельца группы.")
_assert_not_last_owner(
session, group, member, "Нельзя удалить последнего владельца группы."
)
# Сбросить активную группу у тех, для кого она была активной.
user = session.get(User, user_id)
@@ -78,23 +79,13 @@ def remove_member(session: Session, group: Group, user_id: int) -> None:
def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember:
if role not in ("owner", "member"):
raise ValidationError("Недопустимая роль.")
member = session.exec(
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.user_id == user_id
)
).first()
member = group_service.get_membership(session, group.id, user_id)
if member is None:
raise NotFoundError("Игрок не состоит в группе.")
if member.role == "owner" and role != "owner":
# Без этого единственный владелец мог разжаловать сам себя, и группа
# оставалась без владельца навсегда: назначить нового уже некому.
owners = session.exec(
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.role == "owner"
)
).all()
if len(owners) <= 1:
raise ForbiddenError("Нельзя снять роль с последнего владельца группы.")
if role != "owner":
_assert_not_last_owner(
session, group, member, "Нельзя снять роль с последнего владельца группы."
)
member.role = role
session.add(member)
session.commit()
+53 -46
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any
from sqlalchemy import text
from sqlalchemy import func, text
from sqlmodel import Session, select
from app.core.timeutil import iso_utc
@@ -235,12 +235,13 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
def group_stats(session: Session, group_id: int) -> dict:
board = leaderboard(session, group_id=group_id)
total_matches = session.exec(
select(Match).where(Match.group_id == group_id, Match.status == "finished")
).all()
last_at = None
if total_matches:
last_at = str(max(m.played_at for m in total_matches))
# Нужны только счётчик и дата последней партии — тянуть строки целиком незачем.
games_count, last_played = session.exec(
select(func.count(), func.max(Match.played_at)).where(
Match.group_id == group_id, Match.status == "finished"
)
).one()
last_at = str(last_played) if last_played else None
available_ids = group_service.available_faction_ids(session, group_id)
faction_meta = []
@@ -290,7 +291,7 @@ def group_stats(session: Session, group_id: int) -> dict:
return {
"group_id": group_id,
"total_matches": len(total_matches),
"total_matches": games_count,
"last_match_at": last_at,
"leaderboard": board["entries"],
"provisional": board["provisional"],
@@ -300,26 +301,44 @@ def group_stats(session: Session, group_id: int) -> dict:
}
def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
return {
"user_id": u.id,
"nickname": u.nickname,
"faction_id": f.id,
"faction_name": f.name_ru,
"place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
}
def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int, list[dict]]:
"""Участники сразу всей страницы партий: иначе запрос на каждую партию (N+1)."""
if not match_ids:
return {}
rows = session.exec(
select(MatchParticipant, User, Faction)
.join(User, User.id == MatchParticipant.user_id)
.join(Faction, Faction.id == MatchParticipant.faction_id)
.where(MatchParticipant.match_id.in_(match_ids))
# place может быть NULL (партия идёт) — NULL уходит в конец сортировки.
.order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname)
).all()
out: dict[int, list[dict]] = {}
for p, u, f in rows:
out.setdefault(p.match_id, []).append(_participant_row(p, u, f))
return out
def _match_items(session: Session, matches) -> list[dict]:
"""Элементы списка партий (общее для списка группы и истории игрока)."""
from app.services.match_service import participants_detail # избегаем цикла импорта
by_match = _participants_by_match(session, [m.id for m in matches])
items = []
for m in matches:
parts = []
for p, u, f in participants_detail(session, m.id): # type: ignore[arg-type]
parts.append(
{
"user_id": u.id,
"nickname": u.nickname,
"faction_id": f.id,
"faction_name": f.name_ru,
"place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
}
)
parts = by_match.get(m.id, [])
items.append(
{
"id": m.id,
@@ -339,7 +358,9 @@ def _match_items(session: Session, matches) -> list[dict]:
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())
total = session.exec(
select(func.count()).select_from(Match).where(Match.group_id == group_id)
).one()
matches = session.exec(
select(Match)
.where(Match.group_id == group_id)
@@ -385,13 +406,12 @@ def user_match_list(
}
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()
)
total = session.exec(
select(func.count())
.select_from(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(*where)
).one()
matches = session.exec(
select(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
@@ -410,8 +430,6 @@ def user_match_list(
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
from app.services.match_service import participants_detail # избегаем цикла импорта
group_ids = list(
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
)
@@ -423,21 +441,10 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
.where(Match.status == "in_progress", Match.group_id.in_(group_ids))
.order_by(Match.started_at.desc(), Match.id.desc())
).all()
by_match = _participants_by_match(session, [m.id for m, _ in rows])
out = []
for m, gname in rows:
parts = [
{
"user_id": u.id,
"nickname": u.nickname,
"faction_id": f.id,
"faction_name": f.name_ru,
"place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
}
for p, u, f in participants_detail(session, m.id) # type: ignore[arg-type]
]
parts = by_match.get(m.id, [])
out.append(
{
"id": m.id,