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
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:
@@ -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
133
src/apps/user/management.py
Normal 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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user