Находки прохода /code-review high по backend/app: - achievement_service: slug из URL шёл в путь без проверки, из-за чего DELETE /api/admin/achievements/%2E%2E удалял rmtree'ом родительскую папку каталога ачивок (в проде это /data — БД, uploads, ачивки целиком). - match_service/attachment_service: версия партии = updated_at, но onupdate срабатывает лишь при реальном UPDATE строки matches. Правка одних участников и работа с вложениями его не вызывали, и оптимистичная блокировка молча пропускала конкурентную запись — бампаем updated_at явно. - admin_service: удаление группы с партиями упиралось в RESTRICT и уходило наружу голым 500; теперь понятная ошибка. Админское удаление партии не чистило файлы вложений с тома — они оставались навсегда. - user_service: при повторной загрузке аватара с тем же расширением avatar_path не менялся, updated_at не двигался, и кэш-бастер оставлял старую картинку до часа. Плюс версия считалась из наивного времени как из локального и разъезжалась с лидербордом, где то же поле считает SQL. - membership_service: единственный владелец мог разжаловать сам себя и группа оставалась без владельца навсегда. - notification_service: mark_read не слал SSE-сигнал, и бейдж непрочитанных на других устройствах висел до перезагрузки. - routers/admin: created_at после правки пользователя отдавался без смещения, и дата «создан» прыгала на часовой пояс до следующего обновления списка. #8 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
103 lines
4.0 KiB
Python
103 lines
4.0 KiB
Python
"""Членство в группе: список, добавление по нику, удаление, смена роли."""
|
|
from __future__ import annotations
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
|
from app.models import Group, GroupMember, User
|
|
|
|
MAX_GROUP_SIZE = 10
|
|
|
|
|
|
def list_members(session: Session, group_id: int) -> list[tuple[GroupMember, User]]:
|
|
rows = session.exec(
|
|
select(GroupMember, User)
|
|
.join(User, User.id == GroupMember.user_id)
|
|
.where(GroupMember.group_id == group_id)
|
|
.order_by(GroupMember.role.desc(), User.nickname)
|
|
).all()
|
|
return [(m, u) for m, u in rows]
|
|
|
|
|
|
def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tuple[GroupMember, 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("Игрок с таким ником не найден.")
|
|
|
|
member_count = len(
|
|
session.exec(select(GroupMember.id).where(GroupMember.group_id == group.id)).all()
|
|
)
|
|
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()
|
|
if existing is not None:
|
|
raise ConflictError("Игрок уже в группе.")
|
|
|
|
member = GroupMember(group_id=group.id, user_id=user.id, role="member") # type: ignore[arg-type]
|
|
session.add(member)
|
|
session.commit()
|
|
session.refresh(member)
|
|
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
|
|
)
|
|
).first()
|
|
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("Нельзя удалить последнего владельца группы.")
|
|
|
|
# Сбросить активную группу у тех, для кого она была активной.
|
|
user = session.get(User, user_id)
|
|
if user is not None and user.active_group_id == group.id:
|
|
user.active_group_id = None
|
|
session.add(user)
|
|
|
|
session.delete(member)
|
|
session.commit()
|
|
|
|
|
|
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()
|
|
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("Нельзя снять роль с последнего владельца группы.")
|
|
member.role = role
|
|
session.add(member)
|
|
session.commit()
|
|
session.refresh(member)
|
|
return member
|