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
+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()