Проход /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
132 lines
4.9 KiB
Python
132 lines
4.9 KiB
Python
"""Приглашения в группу: создание (любым участником), список, принятие, отклонение.
|
||
|
||
Хранятся только ожидающие приглашения; ответ удаляет строку (accept → создаётся
|
||
GroupMember). Лимит участников — общий с membership_service.MAX_GROUP_SIZE.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from sqlalchemy.orm import aliased
|
||
from sqlmodel import Session, select
|
||
|
||
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
||
from app.core.timeutil import iso_utc
|
||
from app.models import Group, GroupInvitation, GroupMember, User
|
||
from app.services.membership_service import MAX_GROUP_SIZE
|
||
|
||
|
||
def _member_exists(session: Session, group_id: int, user_id: int) -> bool:
|
||
return (
|
||
session.exec(
|
||
select(GroupMember.id).where(
|
||
GroupMember.group_id == group_id, GroupMember.user_id == user_id
|
||
)
|
||
).first()
|
||
is not None
|
||
)
|
||
|
||
|
||
def _counts(session: Session, group_id: int) -> tuple[int, int]:
|
||
members = len(
|
||
session.exec(select(GroupMember.id).where(GroupMember.group_id == group_id)).all()
|
||
)
|
||
pending = len(
|
||
session.exec(
|
||
select(GroupInvitation.id).where(GroupInvitation.group_id == group_id)
|
||
).all()
|
||
)
|
||
return members, pending
|
||
|
||
|
||
def create_invitation(
|
||
session: Session, group: Group, inviter: User, nickname: str
|
||
) -> tuple[GroupInvitation, User]:
|
||
nickname = (nickname or "").strip()
|
||
if not nickname:
|
||
raise ValidationError("Укажите никнейм игрока.")
|
||
user = session.exec(select(User).where(User.nickname == nickname)).first()
|
||
if user is None:
|
||
raise NotFoundError("Игрок с таким ником не найден.")
|
||
if _member_exists(session, group.id, user.id): # type: ignore[arg-type]
|
||
raise ConflictError("Игрок уже в группе.")
|
||
|
||
existing = session.exec(
|
||
select(GroupInvitation).where(
|
||
GroupInvitation.group_id == group.id, GroupInvitation.user_id == user.id
|
||
)
|
||
).first()
|
||
if existing is not None:
|
||
raise ConflictError("Игрок уже приглашён в эту группу.")
|
||
|
||
members, pending = _counts(session, group.id) # type: ignore[arg-type]
|
||
if members + pending >= MAX_GROUP_SIZE:
|
||
raise ConflictError(f"В группе нет мест (максимум {MAX_GROUP_SIZE}).")
|
||
|
||
inv = GroupInvitation(
|
||
group_id=group.id, user_id=user.id, invited_by_id=inviter.id # type: ignore[arg-type]
|
||
)
|
||
session.add(inv)
|
||
session.commit()
|
||
session.refresh(inv)
|
||
return inv, user
|
||
|
||
|
||
def list_for_user(session: Session, user_id: int) -> list[dict]:
|
||
inviter = aliased(User)
|
||
rows = session.exec(
|
||
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, inviter_nick in rows:
|
||
out.append(
|
||
{
|
||
"id": inv.id,
|
||
"group_id": inv.group_id,
|
||
"group_name": gname,
|
||
"invited_by_nickname": inviter_nick,
|
||
"created_at": iso_utc(inv.created_at),
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _own_invitation(session: Session, user: User, invitation_id: int) -> GroupInvitation:
|
||
inv = session.get(GroupInvitation, invitation_id)
|
||
if inv is None or inv.user_id != user.id:
|
||
raise NotFoundError("Приглашение не найдено.")
|
||
return inv
|
||
|
||
|
||
def accept(session: Session, user: User, invitation_id: int) -> Group:
|
||
inv = _own_invitation(session, user, invitation_id)
|
||
group = session.get(Group, inv.group_id)
|
||
if group is None:
|
||
session.delete(inv)
|
||
session.commit()
|
||
raise NotFoundError("Группа не найдена.")
|
||
|
||
if not _member_exists(session, group.id, user.id): # type: ignore[arg-type]
|
||
members, _ = _counts(session, group.id) # type: ignore[arg-type]
|
||
if members >= MAX_GROUP_SIZE:
|
||
raise ConflictError(f"В группе нет мест (максимум {MAX_GROUP_SIZE}).")
|
||
session.add(
|
||
GroupMember(group_id=group.id, user_id=user.id, role="member") # type: ignore[arg-type]
|
||
)
|
||
if user.active_group_id is None: # первая группа — делаем активной
|
||
user.active_group_id = group.id
|
||
session.add(user)
|
||
|
||
session.delete(inv)
|
||
session.commit()
|
||
return group
|
||
|
||
|
||
def decline(session: Session, user: User, invitation_id: int) -> None:
|
||
inv = _own_invitation(session, user, invitation_id)
|
||
session.delete(inv)
|
||
session.commit()
|