"""Transactional guards shared by account management API and Django admin.""" import logging from collections.abc import Iterable, Iterator from contextlib import contextmanager from apps.core.exceptions import ( AuthenticationError, BadRequestError, ConflictError, NotFoundError, PermissionDeniedError, ) from apps.core.models import BackgroundJob from django.core.files.storage import Storage from django.db import transaction from django.db.models import Q from django.db.models.deletion import ProtectedError from rest_framework_simplejwt.token_blacklist.models import OutstandingToken from .models import Profile, User logger = logging.getLogger(__name__) @contextmanager def lock_active_user(user_id: int) -> Iterator[User]: """Keep an in-flight self-service save from recreating a deleted account.""" with transaction.atomic(): try: user = User.objects.select_for_update().get(pk=user_id) except User.DoesNotExist as exc: raise NotFoundError("Пользователь не найден.") from exc if not user.is_active: raise AuthenticationError("Учётная запись недоступна.") yield user @contextmanager def lock_managed_users( *, actor_id: int, user_ids: Iterable[int] ) -> Iterator[dict[int, User]]: """Serialize changes that can remove an administrator and recheck the actor.""" target_ids = set(user_ids) with transaction.atomic(): users = { user.pk: user for user in User.objects.select_for_update() .filter( Q(is_active=True, is_staff=True) | Q(pk__in=target_ids | {actor_id}) ) .order_by("pk") } # A request may have authenticated before another administrator deleted or # demoted its actor. Never authorize from the stale request.user object. actor = users.get(actor_id) if actor is None or not actor.is_active or not actor.is_staff: raise PermissionDeniedError( "Доступ разрешён только активному администратору." ) if target_ids - users.keys(): raise NotFoundError("Пользователь не найден.") yield {user_id: users[user_id] for user_id in target_ids} def ensure_admins_remain(*, removed_ids: Iterable[int]) -> None: """Require a remaining active staff account while holding management locks.""" if ( not User.objects.filter(is_active=True, is_staff=True) .exclude(pk__in=removed_ids) .exists() ): raise ConflictError( "Нельзя удалить или отключить последнего активного администратора.", code="last_active_admin", ) def validate_access_change( user: User, *, actor_id: int, is_active: bool, is_staff: bool ) -> None: """Validate a proposed status/role change under the management locks.""" if user.pk == actor_id and (not is_active or not is_staff): raise BadRequestError( "Нельзя деактивировать себя или снять у себя роль администратора.", code="self_deactivation_forbidden", ) if user.is_active and user.is_staff and not (is_active and is_staff): ensure_admins_remain(removed_ids=[user.pk]) def _delete_unused_avatar(*, storage: Storage, name: str, user_id: int) -> None: """Clean up a committed deletion without removing another profile's file.""" try: if not Profile.objects.filter(avatar=name).exists(): storage.delete(name) except Exception: # noqa: BLE001 # The account is already deleted. Storage failure must not pretend the # transaction failed or disclose a storage path in an API response. logger.error("Avatar cleanup failed after deleting user id=%s", user_id) def delete_accounts(*, actor_id: int, user_ids: Iterable[int]) -> None: """Delete accounts atomically, preserving business records and their files.""" with lock_managed_users(actor_id=actor_id, user_ids=user_ids) as users: if actor_id in users: raise BadRequestError( "Нельзя удалить самого себя.", code="self_delete_forbidden" ) ensure_admins_remain(removed_ids=users) for user in users.values(): profile = Profile.objects.filter(user_id=user.pk).first() avatar = profile.avatar if profile is not None else None if avatar: storage, name, user_id = avatar.storage, avatar.name, user.pk transaction.on_commit( lambda storage=storage, name=name, user_id=user_id: _delete_unused_avatar( storage=storage, name=name, user_id=user_id ) ) # OutstandingToken uses SET_NULL, while BackgroundJob stores a plain # integer. Neither is cleaned up automatically by User.delete(). OutstandingToken.objects.filter(user_id=user.pk).delete() BackgroundJob.objects.filter(user_id=user.pk).update(user_id=None) try: user.delete() except ProtectedError as exc: raise ConflictError( "Связанные записи препятствуют удалению пользователя.", code="user_delete_conflict", ) from exc