feat(users): add transactional employee deletion and token revocation
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

This commit is contained in:
Aleksandr Meshchryakov
2026-09-13 22:55:31 +02:00
parent ef087d5559
commit e99616ed6d
9 changed files with 971 additions and 69 deletions

View File

@@ -1,13 +1,21 @@
"""Admin configuration for user app."""
from contextlib import suppress
from functools import wraps
from apps.core.exceptions import BaseAPIException
from apps.core.models import BackgroundJob
from apps.user.management import lock_managed_users, validate_access_change
from apps.user.models import Profile, User
from django.contrib import admin
from apps.user.services import UserService
from django.contrib import admin, messages
from django.contrib.admin.sites import NotRegistered
from django.contrib.admin.utils import unquote
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from django.db import transaction
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
@@ -47,6 +55,20 @@ def _unregister(model) -> None:
_unregister(User)
def _management_errors(view):
"""Present guarded account-change failures through Django admin messages."""
@wraps(view)
def wrapped(self, request, *args, **kwargs):
try:
return view(self, request, *args, **kwargs)
except BaseAPIException as exc:
self.message_user(request, exc.message, level=messages.ERROR)
return HttpResponseRedirect(reverse("admin:user_user_changelist"))
return wrapped
class ProfileInline(admin.StackedInline):
"""Inline для профиля пользователя."""
@@ -121,6 +143,50 @@ class UserAdmin(BaseUserAdmin):
readonly_fields = ["created_at", "updated_at", "last_login", "date_joined"]
actions = ["verify_users", "unverify_users", "activate_users", "deactivate_users"]
@_management_errors
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
return super().changeform_view(request, object_id, form_url, extra_context)
@_management_errors
def delete_view(self, request, object_id, extra_context=None):
return super().delete_view(request, object_id, extra_context)
@_management_errors
def user_change_password(self, request, id, form_url=""):
user = self.get_object(request, unquote(id))
if request.method == "POST" and user is not None:
with lock_managed_users(actor_id=request.user.pk, user_ids=[user.pk]):
return super().user_change_password(request, id, form_url)
return super().user_change_password(request, id, form_url)
@_management_errors
def changelist_view(self, request, extra_context=None):
# delete_selected writes LogEntry rows before delete_queryset. Roll those
# back too if the complete selection fails an account-management guard.
with transaction.atomic():
return super().changelist_view(request, extra_context)
def save_model(self, request, obj, form, change):
with lock_managed_users(
actor_id=request.user.pk, user_ids=[obj.pk] if change else []
) as users:
if change:
validate_access_change(
users[obj.pk],
actor_id=request.user.pk,
is_active=obj.is_active,
is_staff=obj.is_staff,
)
super().save_model(request, obj, form, change)
def delete_model(self, request, obj):
UserService.delete_user(obj.pk, actor_id=request.user.pk)
def delete_queryset(self, request, queryset):
UserService.delete_users(
queryset.values_list("pk", flat=True), actor_id=request.user.pk
)
def is_verified_badge(self, obj):
if obj.is_verified:
return format_html(
@@ -157,12 +223,20 @@ class UserAdmin(BaseUserAdmin):
@admin.action(description="Активировать пользователей")
def activate_users(self, request, queryset):
updated = queryset.update(is_active=True)
updated = UserService.set_users_active(
queryset.values_list("pk", flat=True),
actor_id=request.user.pk,
is_active=True,
)
self.message_user(request, f"Активировано {updated} пользователей")
@admin.action(description="Деактивировать пользователей")
def deactivate_users(self, request, queryset):
updated = queryset.update(is_active=False)
updated = UserService.set_users_active(
queryset.values_list("pk", flat=True),
actor_id=request.user.pk,
is_active=False,
)
self.message_user(request, f"Деактивировано {updated} пользователей")

133
src/apps/user/management.py Normal file
View File

@@ -0,0 +1,133 @@
"""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

View File

@@ -1,3 +1,4 @@
from collections.abc import Iterable
from typing import Any
from apps.core.exceptions import NotFoundError
@@ -7,6 +8,13 @@ from django.db import transaction
from django.db.models import F, Q
from rest_framework_simplejwt.tokens import RefreshToken
from .management import (
delete_accounts,
ensure_admins_remain,
lock_active_user,
lock_managed_users,
validate_access_change,
)
from .models import Profile
User = get_user_model()
@@ -177,19 +185,17 @@ class UserService:
Raises:
NotFoundError: Если пользователь не найден
"""
user = cls.get_user_by_id(user_id)
for field, value in fields.items():
setattr(user, field, value)
user.save()
return user
with lock_active_user(user_id) as user:
for field, value in fields.items():
setattr(user, field, value)
user.save()
return user
@classmethod
@transaction.atomic
def create_managed_user(
cls,
*,
actor_id: int,
email: str,
username: str,
password: str,
@@ -200,26 +206,40 @@ class UserService:
**extra_fields,
) -> User:
"""Создаёт пользователя администратором и назначает ему роль."""
user = User.objects.create_user(
email=email,
username=username,
password=password,
**extra_fields,
)
cls.assign_role(user, role)
cls._update_or_create_profile(
user=user,
first_name=first_name,
middle_name=middle_name or "",
last_name=last_name,
)
return cls.get_users_queryset().get(id=user.id)
with lock_managed_users(actor_id=actor_id, user_ids=[]):
user = User.objects.create_user(
email=email,
username=username,
password=password,
**extra_fields,
)
cls.assign_role(user, role)
cls._update_or_create_profile(
user=user,
first_name=first_name,
middle_name=middle_name or "",
last_name=last_name,
)
return cls.get_users_queryset().get(id=user.id)
@classmethod
@transaction.atomic
def update_managed_user(cls, user_id: int, **fields) -> User:
def update_managed_user(cls, user_id: int, *, actor_id: int, **fields) -> User:
"""Обновляет учётные данные, профиль, пароль и роль пользователя."""
user = cls.get_user_by_id(user_id)
with lock_managed_users(actor_id=actor_id, user_ids=[user_id]) as users:
user = users[user_id]
validate_access_change(
user,
actor_id=actor_id,
is_active=fields.get("is_active", user.is_active),
is_staff=(fields["role"] == cls.ROLE_ADMIN)
if "role" in fields
else user.is_staff,
)
return cls._update_managed_user(user, **fields)
@classmethod
def _update_managed_user(cls, user: User, **fields) -> User:
"""Apply validated fields while the caller holds management locks."""
role = fields.pop("role", None)
password = fields.pop("password", None)
profile_fields = {
@@ -243,34 +263,40 @@ class UserService:
return cls.get_users_queryset().get(id=user.id)
@classmethod
def deactivate_user(cls, user_id: int) -> User:
def deactivate_user(cls, user_id: int, *, actor_id: int) -> User:
"""Деактивирует пользователя без удаления записи."""
user = cls.get_user_by_id(user_id)
user.is_active = False
user.save(update_fields=["is_active"])
return user
cls.set_users_active([user_id], actor_id=actor_id, is_active=False)
return cls.get_user_by_id(user_id)
@classmethod
def activate_user(cls, user_id: int) -> User:
def activate_user(cls, user_id: int, *, actor_id: int) -> User:
"""Возвращает пользователя в активное состояние."""
user = cls.get_user_by_id(user_id)
user.is_active = True
user.save(update_fields=["is_active"])
return user
cls.set_users_active([user_id], actor_id=actor_id, is_active=True)
return cls.get_user_by_id(user_id)
@classmethod
def delete_user(cls, user_id: int) -> None:
"""
Удаляет пользователя
def set_users_active(
cls, user_ids: Iterable[int], *, actor_id: int, is_active: bool
) -> int:
"""Change activity atomically, including Django admin bulk actions."""
with lock_managed_users(actor_id=actor_id, user_ids=user_ids) as users:
for user in users.values():
validate_access_change(
user, actor_id=actor_id, is_active=is_active, is_staff=user.is_staff
)
if not is_active:
ensure_admins_remain(removed_ids=users)
return User.objects.filter(pk__in=users).update(is_active=is_active)
Args:
user_id: ID пользователя
@classmethod
def delete_user(cls, user_id: int, *, actor_id: int) -> None:
"""Delete an employee without deleting reports, imports or task history."""
cls.delete_users([user_id], actor_id=actor_id)
Raises:
NotFoundError: Если пользователь не найден
"""
user = cls.get_user_by_id(user_id)
user.delete()
@classmethod
def delete_users(cls, user_ids: Iterable[int], *, actor_id: int) -> None:
"""Share the same guarded deletion with Django admin bulk operations."""
delete_accounts(actor_id=actor_id, user_ids=user_ids)
@classmethod
def get_tokens_for_user(cls, user: User) -> dict[str, str]:

View File

@@ -17,8 +17,10 @@ from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.settings import api_settings as jwt_settings
from rest_framework_simplejwt.tokens import RefreshToken
from .management import lock_active_user
from .serializers import (
AdminUserCreateSerializer,
AdminUserListResponseSerializer,
@@ -278,21 +280,20 @@ class PasswordChangeView(APIView):
def post(self, request):
serializer = PasswordChangeSerializer(data=request.data)
if serializer.is_valid():
user = request.user
old_password = serializer.validated_data["old_password"]
if check_password(old_password, user.password):
new_password = serializer.validated_data["new_password"]
user.set_password(new_password)
user.save()
return Response(
{"message": "Пароль успешно изменен"}, status=status.HTTP_200_OK
)
else:
return Response(
{"error": "Неверный старый пароль"},
status=status.HTTP_400_BAD_REQUEST,
)
with lock_active_user(request.user.id) as user:
if check_password(old_password, user.password):
new_password = serializer.validated_data["new_password"]
user.set_password(new_password)
user.save()
return Response(
{"message": "Пароль успешно изменен"}, status=status.HTTP_200_OK
)
else:
return Response(
{"error": "Неверный старый пароль"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -392,7 +393,9 @@ class AdminUsersManagementView(APIView):
def post(self, request):
serializer = AdminUserCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = UserService.create_managed_user(**serializer.validated_data)
user = UserService.create_managed_user(
actor_id=request.user.id, **serializer.validated_data
)
return Response(
ManagedUserSerializer(user).data,
status=status.HTTP_201_CREATED,
@@ -400,7 +403,7 @@ class AdminUsersManagementView(APIView):
class AdminUserDetailView(APIView):
"""Просмотр и частичное обновление пользователя администратором."""
"""Просмотр, изменение и удаление пользователя администратором."""
permission_classes = [IsAdminUser]
@@ -443,10 +446,32 @@ class AdminUserDetailView(APIView):
updated_user = UserService.update_managed_user(
user_id=user.id,
actor_id=request.user.id,
**serializer.validated_data,
)
return Response(ManagedUserSerializer(updated_user).data)
@swagger_auto_schema(
tags=[USER_TAG],
operation_summary="Удалить сотрудника (admin)",
operation_description=(
"Полностью удаляет аккаунт и профиль. Отчёты, импорты и задачи "
"сохраняются без ссылки на сотрудника. Нельзя удалить себя или "
"последнего активного администратора."
),
responses={
204: "Сотрудник удалён",
400: "Нельзя удалить самого себя",
401: "Требуется аутентификация",
403: "Требуется активный администратор",
404: "Пользователь не найден",
409: "Удаление нарушает ограничения",
},
)
def delete(self, request, user_id: int):
UserService.delete_user(user_id, actor_id=request.user.id)
return Response(status=status.HTTP_204_NO_CONTENT)
class AdminUserDeactivateView(APIView):
"""Деактивация пользователя администратором."""
@@ -464,7 +489,7 @@ class AdminUserDeactivateView(APIView):
{"detail": "Нельзя деактивировать самого себя."},
status=status.HTTP_400_BAD_REQUEST,
)
user = UserService.deactivate_user(user_id)
user = UserService.deactivate_user(user_id, actor_id=request.user.id)
return Response(ManagedUserSerializer(user).data)
@@ -479,7 +504,7 @@ class AdminUserActivateView(APIView):
responses={200: ManagedUserSerializer},
)
def post(self, request, user_id: int):
user = UserService.activate_user(user_id)
user = UserService.activate_user(user_id, actor_id=request.user.id)
return Response(ManagedUserSerializer(user).data)
@@ -527,10 +552,15 @@ class TokenRefreshView(APIView):
try:
refresh = RefreshToken(refresh_token)
user_id = refresh.get(jwt_settings.USER_ID_CLAIM)
if not User.objects.filter(
**{jwt_settings.USER_ID_FIELD: user_id}, is_active=True
).exists():
raise TokenError("User is unavailable")
return Response(
{"access": str(refresh.access_token), "refresh": str(refresh)}
)
except TokenError:
except (TokenError, TypeError, ValueError, OverflowError):
return Response(
{"error": "Неверный refresh token"}, status=status.HTTP_401_UNAUTHORIZED
)