Align frontend API contracts
This commit is contained in:
55
src/apps/user/migrations/0010_profile_names_required.py
Normal file
55
src/apps/user/migrations/0010_profile_names_required.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_profile_names(apps, schema_editor):
|
||||
Profile = apps.get_model("user", "Profile")
|
||||
|
||||
for profile in Profile.objects.select_related("user").all().iterator():
|
||||
username = profile.user.username
|
||||
first_name = (profile.first_name or "").strip() or username
|
||||
middle_name = (profile.middle_name or "").strip()
|
||||
last_name = (profile.last_name or "").strip() or username
|
||||
|
||||
updates = []
|
||||
if profile.first_name != first_name:
|
||||
profile.first_name = first_name
|
||||
updates.append("first_name")
|
||||
if (profile.middle_name or "") != middle_name:
|
||||
profile.middle_name = middle_name
|
||||
updates.append("middle_name")
|
||||
if profile.last_name != last_name:
|
||||
profile.last_name = last_name
|
||||
updates.append("last_name")
|
||||
|
||||
if updates:
|
||||
profile.save(update_fields=updates)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("user", "0009_alter_user_groups"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(backfill_profile_names, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name="profile",
|
||||
name="first_name",
|
||||
field=models.CharField(default="", max_length=50, verbose_name="first name"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="profile",
|
||||
name="middle_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
max_length=50,
|
||||
verbose_name="middle name",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="profile",
|
||||
name="last_name",
|
||||
field=models.CharField(default="", max_length=50, verbose_name="last name"),
|
||||
),
|
||||
]
|
||||
@@ -69,16 +69,16 @@ class Profile(models.Model):
|
||||
User, on_delete=models.CASCADE, related_name="profile", verbose_name=_("user")
|
||||
)
|
||||
|
||||
first_name = models.CharField(_("first name"), max_length=50, blank=True, null=True)
|
||||
first_name = models.CharField(_("first name"), max_length=50, default="")
|
||||
|
||||
middle_name = models.CharField(
|
||||
_("middle name"),
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
last_name = models.CharField(_("last name"), max_length=50, blank=True, null=True)
|
||||
last_name = models.CharField(_("last name"), max_length=50, default="")
|
||||
|
||||
bio = models.TextField(
|
||||
_("bio"), blank=True, null=True, help_text=_("Short biography or description")
|
||||
@@ -110,7 +110,7 @@ class Profile(models.Model):
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Полное имя пользователя"""
|
||||
parts = [self.first_name, self.middle_name, self.last_name]
|
||||
parts = [self.last_name, self.first_name, self.middle_name]
|
||||
full_name = " ".join(part for part in parts if part)
|
||||
if full_name:
|
||||
return full_name
|
||||
|
||||
@@ -21,10 +21,26 @@ class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True, min_length=8, help_text="Подтверждение пароля"
|
||||
)
|
||||
first_name = serializers.CharField(help_text="Имя пользователя")
|
||||
middle_name = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Отчество пользователя",
|
||||
)
|
||||
last_name = serializers.CharField(help_text="Фамилия пользователя")
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("email", "username", "password", "password_confirm", "phone")
|
||||
fields = (
|
||||
"email",
|
||||
"username",
|
||||
"password",
|
||||
"password_confirm",
|
||||
"phone",
|
||||
"first_name",
|
||||
"middle_name",
|
||||
"last_name",
|
||||
)
|
||||
extra_kwargs = {
|
||||
"username": {
|
||||
"validators": [UniqueValidator(queryset=User.objects.all())],
|
||||
@@ -64,6 +80,21 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ("id",)
|
||||
|
||||
|
||||
class FrontendUserProfileSerializer(serializers.ModelSerializer):
|
||||
"""Профиль пользователя в shape, который ожидает frontend."""
|
||||
|
||||
full_name = serializers.ReadOnlyField(help_text="Полное имя")
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = (
|
||||
"first_name",
|
||||
"middle_name",
|
||||
"last_name",
|
||||
"full_name",
|
||||
)
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор для пользователя"""
|
||||
|
||||
@@ -109,6 +140,60 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
return UserService.get_user_capabilities(obj)
|
||||
|
||||
|
||||
class FrontendUserWithProfileSerializer(serializers.ModelSerializer):
|
||||
"""Короткий сериализатор пользователя с профилем для frontend."""
|
||||
|
||||
profile = FrontendUserProfileSerializer(read_only=True)
|
||||
role = serializers.SerializerMethodField()
|
||||
role_label = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"phone",
|
||||
"is_active",
|
||||
"role",
|
||||
"role_label",
|
||||
"profile",
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
def get_role(self, obj) -> str:
|
||||
return UserService.get_user_role(obj)
|
||||
|
||||
def get_role_label(self, obj) -> str:
|
||||
return UserService.get_role_label(self.get_role(obj))
|
||||
|
||||
|
||||
class FrontendManagedUserSerializer(serializers.ModelSerializer):
|
||||
"""Короткий сериализатор пользователя без вложенного профиля."""
|
||||
|
||||
role = serializers.SerializerMethodField()
|
||||
role_label = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"phone",
|
||||
"role",
|
||||
"role_label",
|
||||
"is_active",
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
def get_role(self, obj) -> str:
|
||||
return UserService.get_user_role(obj)
|
||||
|
||||
def get_role_label(self, obj) -> str:
|
||||
return UserService.get_role_label(self.get_role(obj))
|
||||
|
||||
|
||||
class UserUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор для обновления данных пользователя"""
|
||||
|
||||
@@ -132,7 +217,6 @@ class AdminUserCreateSerializer(serializers.ModelSerializer):
|
||||
middle_name = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
)
|
||||
last_name = serializers.CharField(allow_blank=False)
|
||||
|
||||
@@ -175,14 +259,13 @@ class AdminUserUpdateSerializer(serializers.ModelSerializer):
|
||||
help_text="Прикладная роль пользователя",
|
||||
)
|
||||
first_name = serializers.CharField(
|
||||
required=False, allow_blank=True, allow_null=True
|
||||
required=False, allow_blank=True
|
||||
)
|
||||
middle_name = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
)
|
||||
last_name = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||||
last_name = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@@ -199,6 +282,32 @@ class AdminUserUpdateSerializer(serializers.ModelSerializer):
|
||||
"last_name",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
profile_fields = {"first_name", "middle_name", "last_name"}
|
||||
if not any(field in attrs for field in profile_fields):
|
||||
return attrs
|
||||
|
||||
profile = getattr(self.instance, "profile", None) if self.instance else None
|
||||
first_name = attrs.get(
|
||||
"first_name",
|
||||
profile.first_name if profile is not None else None,
|
||||
)
|
||||
last_name = attrs.get(
|
||||
"last_name",
|
||||
profile.last_name if profile is not None else None,
|
||||
)
|
||||
|
||||
errors = {}
|
||||
if not str(first_name or "").strip():
|
||||
errors["first_name"] = "Обязательное поле."
|
||||
if not str(last_name or "").strip():
|
||||
errors["last_name"] = "Обязательное поле."
|
||||
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ProfileUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор для обновления профиля"""
|
||||
@@ -214,6 +323,27 @@ class ProfileUpdateSerializer(serializers.ModelSerializer):
|
||||
"date_of_birth",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
first_name = attrs.get(
|
||||
"first_name",
|
||||
self.instance.first_name if self.instance is not None else None,
|
||||
)
|
||||
last_name = attrs.get(
|
||||
"last_name",
|
||||
self.instance.last_name if self.instance is not None else None,
|
||||
)
|
||||
|
||||
errors = {}
|
||||
if not str(first_name or "").strip():
|
||||
errors["first_name"] = "Обязательное поле."
|
||||
if not str(last_name or "").strip():
|
||||
errors["last_name"] = "Обязательное поле."
|
||||
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
"""Сериализатор для входа"""
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, OutstandingToken
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from .models import Profile
|
||||
@@ -53,12 +54,21 @@ class UserService:
|
||||
ValidationError: При некорректных данных
|
||||
"""
|
||||
role = role or cls.ROLE_USER
|
||||
first_name = extra_fields.pop("first_name", None)
|
||||
middle_name = extra_fields.pop("middle_name", None)
|
||||
last_name = extra_fields.pop("last_name", None)
|
||||
with transaction.atomic():
|
||||
user = User.objects.create_user(
|
||||
email=email, username=username, password=password, **extra_fields
|
||||
)
|
||||
cls.assign_role(user, role)
|
||||
return user
|
||||
cls._update_or_create_profile(
|
||||
user=user,
|
||||
first_name=first_name or user.username,
|
||||
middle_name=middle_name or "",
|
||||
last_name=last_name or user.username,
|
||||
)
|
||||
return cls.get_users_queryset().get(id=user.id)
|
||||
|
||||
@classmethod
|
||||
def get_users_queryset(cls):
|
||||
@@ -218,13 +228,10 @@ class UserService:
|
||||
username=username,
|
||||
password=password,
|
||||
role=role,
|
||||
**extra_fields,
|
||||
)
|
||||
cls._update_or_create_profile(
|
||||
user=user,
|
||||
first_name=first_name,
|
||||
middle_name=middle_name,
|
||||
last_name=last_name,
|
||||
**extra_fields,
|
||||
)
|
||||
return cls.get_users_queryset().get(id=user.id)
|
||||
|
||||
@@ -304,6 +311,16 @@ class UserService:
|
||||
"access": str(refresh.access_token),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def logout_user(cls, user: User) -> int:
|
||||
"""Отзывает все активные refresh-токены пользователя."""
|
||||
outstanding_tokens = OutstandingToken.objects.filter(user=user)
|
||||
revoked_count = 0
|
||||
for token in outstanding_tokens:
|
||||
_, created = BlacklistedToken.objects.get_or_create(token=token)
|
||||
revoked_count += int(created)
|
||||
return revoked_count
|
||||
|
||||
@classmethod
|
||||
def ensure_role_groups(cls) -> dict[str, Group]:
|
||||
"""Гарантирует существование системных role-групп."""
|
||||
|
||||
@@ -13,7 +13,12 @@ def create_user_profile(sender, instance, created, **kwargs):
|
||||
Автоматически создает профиль при создании пользователя
|
||||
"""
|
||||
if created:
|
||||
Profile.objects.create(user=instance)
|
||||
Profile.objects.create(
|
||||
user=instance,
|
||||
first_name=instance.username,
|
||||
middle_name="",
|
||||
last_name=instance.username,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.paginator import Paginator
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
@@ -15,6 +16,8 @@ from rest_framework_simplejwt.views import TokenVerifyView as SimpleJWTTokenVeri
|
||||
from .serializers import (
|
||||
AdminUserCreateSerializer,
|
||||
AdminUserUpdateSerializer,
|
||||
FrontendManagedUserSerializer,
|
||||
FrontendUserWithProfileSerializer,
|
||||
LoginSerializer,
|
||||
PasswordChangeSerializer,
|
||||
ProfileUpdateSerializer,
|
||||
@@ -131,9 +134,8 @@ class LogoutView(APIView):
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
# Для JWT логаут означает удаление токенов на клиенте.
|
||||
# Сервер не хранит сессию и ничего не инвалидирует.
|
||||
return Response({"message": "Успешный выход"}, status=status.HTTP_200_OK)
|
||||
UserService.logout_user(request.user)
|
||||
return Response({}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class CurrentUserView(APIView):
|
||||
@@ -146,12 +148,12 @@ class CurrentUserView(APIView):
|
||||
operation_summary="Текущий пользователь",
|
||||
operation_description="Возвращает данные авторизованного пользователя.",
|
||||
responses={
|
||||
200: UserSerializer,
|
||||
200: FrontendUserWithProfileSerializer,
|
||||
**ErrorResponses.AUTHENTICATED,
|
||||
},
|
||||
)
|
||||
def get(self, request):
|
||||
serializer = UserSerializer(request.user)
|
||||
serializer = FrontendUserWithProfileSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@@ -160,6 +162,24 @@ class AdminUserListCreateView(APIView):
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
@staticmethod
|
||||
def _get_positive_int(value, *, default: int, minimum: int = 1) -> int:
|
||||
if value in (None, ""):
|
||||
return default
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("must be integer") from None
|
||||
if parsed < minimum:
|
||||
raise ValueError("must be positive")
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
def _build_page_url(request, page_number: int) -> str:
|
||||
query_params = request.query_params.copy()
|
||||
query_params["page"] = page_number
|
||||
return request.build_absolute_uri(f"{request.path}?{query_params.urlencode()}")
|
||||
|
||||
@swagger_auto_schema(
|
||||
tags=[USER_ADMIN_TAG],
|
||||
operation_summary="Список пользователей",
|
||||
@@ -189,7 +209,7 @@ class AdminUserListCreateView(APIView):
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: UserSerializer(many=True),
|
||||
200: FrontendUserWithProfileSerializer(many=True),
|
||||
**ErrorResponses.ADMIN,
|
||||
},
|
||||
)
|
||||
@@ -198,8 +218,47 @@ class AdminUserListCreateView(APIView):
|
||||
search=request.query_params.get("search", ""),
|
||||
ordering=request.query_params.get("ordering", ""),
|
||||
)
|
||||
serializer = UserSerializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
try:
|
||||
page_number = self._get_positive_int(
|
||||
request.query_params.get("page"),
|
||||
default=1,
|
||||
)
|
||||
page_size = self._get_positive_int(
|
||||
request.query_params.get("page_size"),
|
||||
default=20,
|
||||
)
|
||||
except ValueError:
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"Параметры page и page_size должны быть положительными "
|
||||
"целыми числами."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
page_size = min(page_size, 100)
|
||||
paginator = Paginator(queryset, page_size)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
serializer = FrontendUserWithProfileSerializer(page_obj.object_list, many=True)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"count": paginator.count,
|
||||
"next": (
|
||||
self._build_page_url(request, page_obj.next_page_number())
|
||||
if page_obj.has_next()
|
||||
else None
|
||||
),
|
||||
"previous": (
|
||||
self._build_page_url(request, page_obj.previous_page_number())
|
||||
if page_obj.has_previous()
|
||||
else None
|
||||
),
|
||||
"results": serializer.data,
|
||||
}
|
||||
)
|
||||
|
||||
@swagger_auto_schema(
|
||||
tags=[USER_ADMIN_TAG],
|
||||
@@ -209,7 +268,7 @@ class AdminUserListCreateView(APIView):
|
||||
),
|
||||
request_body=AdminUserCreateSerializer,
|
||||
responses={
|
||||
201: UserSerializer,
|
||||
201: FrontendManagedUserSerializer,
|
||||
400: CommonResponses.BAD_REQUEST,
|
||||
**ErrorResponses.ADMIN,
|
||||
},
|
||||
@@ -218,7 +277,10 @@ class AdminUserListCreateView(APIView):
|
||||
serializer = AdminUserCreateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = UserService.create_managed_user(**serializer.validated_data)
|
||||
return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED)
|
||||
return Response(
|
||||
FrontendManagedUserSerializer(user).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class AdminUserDetailView(APIView):
|
||||
@@ -234,13 +296,13 @@ class AdminUserDetailView(APIView):
|
||||
operation_summary="Детали пользователя",
|
||||
operation_description="Возвращает данные конкретного пользователя.",
|
||||
responses={
|
||||
200: UserSerializer,
|
||||
200: FrontendUserWithProfileSerializer,
|
||||
**ErrorResponses.ADMIN_NOT_FOUND,
|
||||
},
|
||||
)
|
||||
def get(self, request, user_id: int):
|
||||
user = self._get_user(user_id)
|
||||
return Response(UserSerializer(user).data)
|
||||
return Response(FrontendUserWithProfileSerializer(user).data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
tags=[USER_ADMIN_TAG],
|
||||
@@ -251,7 +313,7 @@ class AdminUserDetailView(APIView):
|
||||
),
|
||||
request_body=AdminUserUpdateSerializer,
|
||||
responses={
|
||||
200: UserSerializer,
|
||||
200: FrontendManagedUserSerializer,
|
||||
400: CommonResponses.BAD_REQUEST,
|
||||
**ErrorResponses.ADMIN_NOT_FOUND,
|
||||
},
|
||||
@@ -277,7 +339,7 @@ class AdminUserDetailView(APIView):
|
||||
user_id=user.id,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
return Response(UserSerializer(updated_user).data)
|
||||
return Response(FrontendManagedUserSerializer(updated_user).data)
|
||||
|
||||
|
||||
class AdminUserDeactivateView(APIView):
|
||||
@@ -302,8 +364,8 @@ class AdminUserDeactivateView(APIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user = UserService.deactivate_user(user_id)
|
||||
return Response(UserSerializer(user).data)
|
||||
UserService.deactivate_user(user_id)
|
||||
return Response({"success": True})
|
||||
|
||||
|
||||
class AdminUserActivateView(APIView):
|
||||
@@ -316,13 +378,13 @@ class AdminUserActivateView(APIView):
|
||||
operation_summary="Активировать пользователя",
|
||||
operation_description="Возвращает пользователя в активное состояние.",
|
||||
responses={
|
||||
200: UserSerializer,
|
||||
200: FrontendManagedUserSerializer,
|
||||
**ErrorResponses.ADMIN_NOT_FOUND,
|
||||
},
|
||||
)
|
||||
def post(self, request, user_id: int):
|
||||
user = UserService.activate_user(user_id)
|
||||
return Response(UserSerializer(user).data)
|
||||
return Response(FrontendManagedUserSerializer(user).data)
|
||||
|
||||
|
||||
class UserUpdateView(APIView):
|
||||
@@ -362,7 +424,12 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView):
|
||||
# Если профиль не существует, создаем его
|
||||
from .models import Profile
|
||||
|
||||
profile = Profile.objects.create(user=self.request.user)
|
||||
profile = Profile.objects.create(
|
||||
user=self.request.user,
|
||||
first_name=self.request.user.username,
|
||||
middle_name="",
|
||||
last_name=self.request.user.username,
|
||||
)
|
||||
return profile
|
||||
|
||||
@swagger_auto_schema(
|
||||
|
||||
Reference in New Issue
Block a user