All checks were successful
State Corp Backend CI/CD / Quality gate (push) Successful in 2m42s
State Corp Backend CI/CD / Build linux/amd64 images once (push) Successful in 5m29s
State Corp Backend CI/CD / Refresh and release internal main (push) Has been skipped
State Corp Backend CI/CD / Release customer main (push) Has been skipped
State Corp Backend CI/CD / Release dev (push) Successful in 36s
134 lines
5.5 KiB
Python
134 lines
5.5 KiB
Python
"""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
|