Проход /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
98 lines
3.2 KiB
Python
98 lines
3.2 KiB
Python
"""Вложения партии (фото). Файлы — на томе settings.upload_dir, в БД метаданные.
|
|
|
|
Общий сервис для игрового и админского путей (роутеры лишь строят URL с нужным префиксом).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from app.core.config import settings
|
|
from app.core.errors import ConflictError, NotFoundError
|
|
from app.models import Match, MatchAttachment, User
|
|
|
|
MAX_ATTACHMENTS = 10
|
|
_SUBDIR = "matches"
|
|
|
|
|
|
def list_for_match(session: Session, match_id: int) -> list[MatchAttachment]:
|
|
return list(
|
|
session.exec(
|
|
select(MatchAttachment)
|
|
.where(MatchAttachment.match_id == match_id)
|
|
.order_by(MatchAttachment.created_at, MatchAttachment.id)
|
|
).all()
|
|
)
|
|
|
|
|
|
def count(session: Session, match_id: int) -> int:
|
|
return len(
|
|
session.exec(
|
|
select(MatchAttachment.id).where(MatchAttachment.match_id == match_id)
|
|
).all()
|
|
)
|
|
|
|
|
|
def file_path(att: MatchAttachment) -> Path:
|
|
return Path(settings.upload_dir) / att.storage_path
|
|
|
|
|
|
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(
|
|
match_id=match.id, # type: ignore[arg-type]
|
|
uploaded_by=user.id,
|
|
kind="photo",
|
|
storage_path="",
|
|
mime_type=mime,
|
|
size_bytes=len(content),
|
|
)
|
|
session.add(att)
|
|
session.flush() # получаем id для имени файла
|
|
rel = f"{_SUBDIR}/{match.id}/{att.id}.{ext}"
|
|
abs_path = Path(settings.upload_dir) / rel
|
|
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
abs_path.write_bytes(content)
|
|
att.storage_path = rel
|
|
session.add(att)
|
|
match_service.touch(session, match)
|
|
session.commit()
|
|
session.refresh(att)
|
|
return att
|
|
|
|
|
|
def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachment:
|
|
att = session.get(MatchAttachment, att_id)
|
|
if att is None or att.match_id != match_id:
|
|
raise NotFoundError("Вложение не найдено.")
|
|
return att
|
|
|
|
|
|
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():
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
pass
|
|
session.delete(att)
|
|
match_service.touch(session, match)
|
|
session.commit()
|
|
|
|
|
|
def delete_match_files(match_id: int) -> None:
|
|
"""Удалить всю папку медиа партии (при удалении партии; строки БД уйдут каскадом)."""
|
|
folder = Path(settings.upload_dir) / _SUBDIR / str(match_id)
|
|
if folder.is_dir():
|
|
shutil.rmtree(folder, ignore_errors=True)
|