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-миграции, добавится отдельно):