Files
state-corp-backend/src/apps/user/management.py
Aleksandr Meshchryakov e99616ed6d
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
feat(users): add transactional employee deletion and token revocation
2026-09-13 22:55:31 +02:00

134 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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