Align frontend API contracts

This commit is contained in:
2026-03-22 13:21:02 +01:00
parent 0da5b4abe2
commit e639b3c792
35 changed files with 1362 additions and 205 deletions

View 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"),
),
]

View File

@@ -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

View File

@@ -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):
"""Сериализатор для входа"""

View File

@@ -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-групп."""

View File

@@ -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)

View File

@@ -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(