- IP клиента для аудита брался инлайном в 19 местах шести модулей; теперь security.client_ip — за привратником адрес придётся читать из X-Forwarded-For, и одна точка правки для этого обязательна. - Чтение загруженной картинки (лимит размера + sniff формата) было скопировано в четыре обработчика; вынесено в user_service.read_capped_image. - Лимит размера вложения жил двумя одинаковыми константами в игроцком и админском роутере — перенесён к самим вложениям. - update_nickname и set_active_group переиспользуют nickname_format_ok и group_service.get_membership вместо собственных копий проверки. - Убраны осиротевшие импорты и комментарий-заготовка о вложениях, которые давно реализованы (MatchAttachment). #8 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
71 lines
3.5 KiB
Python
71 lines
3.5 KiB
Python
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
|
||
|
||
Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер
|
||
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
|
||
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
|
||
возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только
|
||
отключить (PATCH is_active).
|
||
|
||
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
||
строки участия игрока удаляются (player_count партий НЕ пересчитывается, поэтому
|
||
их статистика может стать несогласованной — осознанный выбор). Созданные им партии
|
||
и группы в его владении переназначаются на админа (FK RESTRICT не даёт удалить
|
||
пользователя, а сами партии/группы мы сохраняем).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, Depends, Request
|
||
from sqlmodel import Session, select
|
||
|
||
from app.auth.deps import get_current_admin
|
||
from app.core.security import client_ip
|
||
from app.core.errors import NotFoundError, ValidationError
|
||
from app.db.session import get_session
|
||
from app.models import Group, Match, MatchParticipant, User
|
||
from app.schemas import api as s
|
||
from app.services import audit_service
|
||
|
||
router = APIRouter(prefix="/admin/dev", tags=["admin-dev"])
|
||
|
||
|
||
@router.delete("/users/{user_id}", response_model=s.OkResponse)
|
||
def delete_user_hard(
|
||
user_id: int,
|
||
request: Request,
|
||
session: Session = Depends(get_session),
|
||
admin: User = Depends(get_current_admin),
|
||
) -> s.OkResponse:
|
||
user = session.get(User, user_id)
|
||
if user is None:
|
||
raise NotFoundError("Пользователь не найден.")
|
||
if user.role == "admin":
|
||
raise ValidationError("Нельзя удалить администратора.")
|
||
nickname = user.nickname # сохраняем до удаления (для аудита)
|
||
|
||
# 1. Вычёркиваем игрока из всех партий (его строки участия).
|
||
for part in session.exec(
|
||
select(MatchParticipant).where(MatchParticipant.user_id == user_id)
|
||
).all():
|
||
session.delete(part)
|
||
# 2. Партии и группы, где он создатель/владелец, переназначаем на админа.
|
||
for match in session.exec(select(Match).where(Match.created_by == user_id)).all():
|
||
match.created_by = admin.id # type: ignore[assignment]
|
||
session.add(match)
|
||
for group in session.exec(select(Group).where(Group.owner_id == user_id)).all():
|
||
group.owner_id = admin.id # type: ignore[assignment]
|
||
session.add(group)
|
||
session.flush()
|
||
# 3. group_members и auth_identity уйдут каскадом, audit_log.actor_id → NULL.
|
||
session.delete(user)
|
||
audit_service.record(
|
||
session,
|
||
actor_id=admin.id,
|
||
action="delete",
|
||
entity_type="user",
|
||
entity_id=user_id,
|
||
payload={"hard": True, "nickname": nickname},
|
||
ip=client_ip(request),
|
||
)
|
||
session.commit()
|
||
return s.OkResponse()
|