Fix admin API gaps for users, exchange checks, and parser logs
This commit is contained in:
@@ -16,7 +16,14 @@ class ProfileInline(admin.StackedInline):
|
||||
can_delete = False
|
||||
verbose_name_plural = "Профиль"
|
||||
fk_name = "user"
|
||||
fields = ["first_name", "last_name", "bio", "avatar", "date_of_birth"]
|
||||
fields = [
|
||||
"first_name",
|
||||
"middle_name",
|
||||
"last_name",
|
||||
"bio",
|
||||
"avatar",
|
||||
"date_of_birth",
|
||||
]
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
@@ -150,7 +157,13 @@ class ProfileAdmin(admin.ModelAdmin):
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["created_at"]
|
||||
search_fields = ["user__username", "user__email", "first_name", "last_name"]
|
||||
search_fields = [
|
||||
"user__username",
|
||||
"user__email",
|
||||
"first_name",
|
||||
"middle_name",
|
||||
"last_name",
|
||||
]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
ordering = ["-created_at"]
|
||||
list_per_page = 50
|
||||
@@ -160,7 +173,15 @@ class ProfileAdmin(admin.ModelAdmin):
|
||||
("Пользователь", {"fields": ("user",)}),
|
||||
(
|
||||
"Личная информация",
|
||||
{"fields": ("first_name", "last_name", "bio", "date_of_birth")},
|
||||
{
|
||||
"fields": (
|
||||
"first_name",
|
||||
"middle_name",
|
||||
"last_name",
|
||||
"bio",
|
||||
"date_of_birth",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Аватар", {"fields": ("avatar",)}),
|
||||
("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
||||
|
||||
20
src/apps/user/migrations/0007_profile_middle_name.py
Normal file
20
src/apps/user/migrations/0007_profile_middle_name.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("user", "0006_create_default_role_groups"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="middle_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
max_length=50,
|
||||
null=True,
|
||||
verbose_name="middle name",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -72,6 +72,13 @@ class Profile(models.Model):
|
||||
|
||||
first_name = models.CharField(_("first name"), max_length=50, blank=True, null=True)
|
||||
|
||||
middle_name = models.CharField(
|
||||
_("middle name"),
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
last_name = models.CharField(_("last name"), max_length=50, blank=True, null=True)
|
||||
|
||||
bio = models.TextField(
|
||||
@@ -104,10 +111,8 @@ class Profile(models.Model):
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Полное имя пользователя"""
|
||||
if self.first_name and self.last_name:
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
elif self.first_name:
|
||||
return self.first_name
|
||||
elif self.last_name:
|
||||
return self.last_name
|
||||
parts = [self.first_name, self.middle_name, self.last_name]
|
||||
full_name = " ".join(part for part in parts if part)
|
||||
if full_name:
|
||||
return full_name
|
||||
return self.user.username
|
||||
|
||||
@@ -54,6 +54,7 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
||||
fields = (
|
||||
"id",
|
||||
"first_name",
|
||||
"middle_name",
|
||||
"last_name",
|
||||
"full_name",
|
||||
"bio",
|
||||
@@ -127,10 +128,13 @@ class AdminUserCreateSerializer(serializers.ModelSerializer):
|
||||
default=UserService.ROLE_USER,
|
||||
help_text="Прикладная роль пользователя",
|
||||
)
|
||||
first_name = serializers.CharField(
|
||||
required=False, allow_blank=True, allow_null=True
|
||||
first_name = serializers.CharField(allow_blank=False)
|
||||
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(allow_blank=False)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@@ -143,6 +147,7 @@ class AdminUserCreateSerializer(serializers.ModelSerializer):
|
||||
"is_active",
|
||||
"is_verified",
|
||||
"first_name",
|
||||
"middle_name",
|
||||
"last_name",
|
||||
)
|
||||
extra_kwargs = {
|
||||
@@ -172,6 +177,11 @@ class AdminUserUpdateSerializer(serializers.ModelSerializer):
|
||||
first_name = serializers.CharField(
|
||||
required=False, allow_blank=True, allow_null=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)
|
||||
|
||||
class Meta:
|
||||
@@ -185,6 +195,7 @@ class AdminUserUpdateSerializer(serializers.ModelSerializer):
|
||||
"is_active",
|
||||
"is_verified",
|
||||
"first_name",
|
||||
"middle_name",
|
||||
"last_name",
|
||||
)
|
||||
|
||||
@@ -194,7 +205,14 @@ class ProfileUpdateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = ("first_name", "last_name", "bio", "avatar", "date_of_birth")
|
||||
fields = (
|
||||
"first_name",
|
||||
"middle_name",
|
||||
"last_name",
|
||||
"bio",
|
||||
"avatar",
|
||||
"date_of_birth",
|
||||
)
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
|
||||
@@ -4,6 +4,7 @@ from apps.core.exceptions import NotFoundError
|
||||
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.tokens import RefreshToken
|
||||
|
||||
from .models import Profile
|
||||
@@ -69,6 +70,65 @@ class UserService:
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_filtered_users_queryset(
|
||||
cls,
|
||||
*,
|
||||
search: str = "",
|
||||
ordering: str = "",
|
||||
):
|
||||
"""Queryset списка пользователей с поиском и сортировкой для админского UI."""
|
||||
queryset = cls.get_users_queryset()
|
||||
search_term = search.strip()
|
||||
|
||||
if search_term:
|
||||
queryset = queryset.filter(
|
||||
Q(username__icontains=search_term)
|
||||
| Q(email__icontains=search_term)
|
||||
| Q(phone__icontains=search_term)
|
||||
| Q(profile__first_name__icontains=search_term)
|
||||
| Q(profile__middle_name__icontains=search_term)
|
||||
| Q(profile__last_name__icontains=search_term)
|
||||
).distinct()
|
||||
|
||||
ordering_fields = []
|
||||
ordering_map = {
|
||||
"id": ("id", False),
|
||||
"email": ("email", False),
|
||||
"username": ("username", False),
|
||||
"phone": ("phone", False),
|
||||
"is_active": ("is_active", False),
|
||||
"is_verified": ("is_verified", False),
|
||||
"created_at": ("created_at", False),
|
||||
"updated_at": ("updated_at", False),
|
||||
"first_name": ("profile__first_name", True),
|
||||
"middle_name": ("profile__middle_name", True),
|
||||
"last_name": ("profile__last_name", True),
|
||||
"role": ("is_staff", False),
|
||||
}
|
||||
|
||||
for raw_field in (item.strip() for item in ordering.split(",") if item.strip()):
|
||||
is_desc = raw_field.startswith("-")
|
||||
field_name = raw_field[1:] if is_desc else raw_field
|
||||
mapped_config = ordering_map.get(field_name)
|
||||
if not mapped_config:
|
||||
continue
|
||||
mapped_field, nulls_last = mapped_config
|
||||
if nulls_last:
|
||||
ordering_fields.append(
|
||||
F(mapped_field).desc(nulls_last=True)
|
||||
if is_desc
|
||||
else F(mapped_field).asc(nulls_last=True)
|
||||
)
|
||||
continue
|
||||
|
||||
ordering_fields.append(f"-{mapped_field}" if is_desc else mapped_field)
|
||||
|
||||
if ordering_fields:
|
||||
queryset = queryset.order_by(*ordering_fields, "-created_at")
|
||||
|
||||
return queryset
|
||||
|
||||
@classmethod
|
||||
def get_user_by_email(cls, email: str) -> User:
|
||||
"""Получает пользователя по email
|
||||
@@ -147,8 +207,9 @@ class UserService:
|
||||
username: str,
|
||||
password: str,
|
||||
role: str,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
middle_name: str | None = None,
|
||||
**extra_fields,
|
||||
) -> User:
|
||||
"""Создаёт пользователя администратором и назначает роль."""
|
||||
@@ -162,6 +223,7 @@ class UserService:
|
||||
cls._update_or_create_profile(
|
||||
user=user,
|
||||
first_name=first_name,
|
||||
middle_name=middle_name,
|
||||
last_name=last_name,
|
||||
)
|
||||
return cls.get_users_queryset().get(id=user.id)
|
||||
@@ -174,7 +236,9 @@ class UserService:
|
||||
role = fields.pop("role", None)
|
||||
password = fields.pop("password", None)
|
||||
profile_fields = {
|
||||
key: fields.pop(key) for key in ("first_name", "last_name") if key in fields
|
||||
key: fields.pop(key)
|
||||
for key in ("first_name", "middle_name", "last_name")
|
||||
if key in fields
|
||||
}
|
||||
|
||||
for field, value in fields.items():
|
||||
@@ -201,6 +265,14 @@ class UserService:
|
||||
user.save(update_fields=["is_active"])
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def activate_user(cls, user_id: int) -> User:
|
||||
"""Активирует пользователя."""
|
||||
user = cls.get_user_by_id(user_id)
|
||||
user.is_active = True
|
||||
user.save(update_fields=["is_active"])
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def delete_user(cls, user_id: int) -> None:
|
||||
"""
|
||||
@@ -327,11 +399,14 @@ class UserService:
|
||||
*,
|
||||
user: User,
|
||||
first_name: str | None = None,
|
||||
middle_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
) -> Profile:
|
||||
profile, _ = Profile.objects.get_or_create(user=user)
|
||||
if first_name is not None:
|
||||
profile.first_name = first_name
|
||||
if middle_name is not None:
|
||||
profile.middle_name = middle_name
|
||||
if last_name is not None:
|
||||
profile.last_name = last_name
|
||||
profile.save()
|
||||
@@ -410,6 +485,7 @@ class ProfileService:
|
||||
"is_verified": user.is_verified,
|
||||
"phone": user.phone,
|
||||
"first_name": profile.first_name,
|
||||
"middle_name": profile.middle_name,
|
||||
"last_name": profile.last_name,
|
||||
"full_name": profile.full_name,
|
||||
"bio": profile.bio,
|
||||
|
||||
@@ -27,6 +27,11 @@ urlpatterns = [
|
||||
views.AdminUserDeactivateView.as_view(),
|
||||
name="admin-user-deactivate",
|
||||
),
|
||||
path(
|
||||
"admin/users/<int:user_id>/activate/",
|
||||
views.AdminUserActivateView.as_view(),
|
||||
name="admin-user-activate",
|
||||
),
|
||||
# Безопасность
|
||||
path(
|
||||
"password/change/", views.PasswordChangeView.as_view(), name="password_change"
|
||||
|
||||
@@ -163,14 +163,42 @@ class AdminUserListCreateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
tags=[USER_ADMIN_TAG],
|
||||
operation_summary="Список пользователей",
|
||||
operation_description="Возвращает список пользователей. Доступно только администраторам.",
|
||||
operation_description=(
|
||||
"Возвращает список пользователей. Доступно только администраторам.\n"
|
||||
"Поддерживает search по username, email, phone и ФИО.\n"
|
||||
"Поддерживает ordering по полям пользователя и profile-именам."
|
||||
),
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
name="search",
|
||||
in_=openapi.IN_QUERY,
|
||||
type=openapi.TYPE_STRING,
|
||||
required=False,
|
||||
description="Поиск по username, email, phone, first_name, middle_name, last_name",
|
||||
),
|
||||
openapi.Parameter(
|
||||
name="ordering",
|
||||
in_=openapi.IN_QUERY,
|
||||
type=openapi.TYPE_STRING,
|
||||
required=False,
|
||||
description=(
|
||||
"Сортировка. Поддерживаются: id, email, username, phone, "
|
||||
"is_active, is_verified, created_at, updated_at, first_name, "
|
||||
"middle_name, last_name, role. Для обратной сортировки используйте префикс -"
|
||||
),
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: UserSerializer(many=True),
|
||||
**ErrorResponses.ADMIN,
|
||||
},
|
||||
)
|
||||
def get(self, request):
|
||||
serializer = UserSerializer(UserService.get_users_queryset(), many=True)
|
||||
queryset = UserService.get_filtered_users_queryset(
|
||||
search=request.query_params.get("search", ""),
|
||||
ordering=request.query_params.get("ordering", ""),
|
||||
)
|
||||
serializer = UserSerializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
@@ -278,6 +306,25 @@ class AdminUserDeactivateView(APIView):
|
||||
return Response(UserSerializer(user).data)
|
||||
|
||||
|
||||
class AdminUserActivateView(APIView):
|
||||
"""Активация пользователя администратором."""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
@swagger_auto_schema(
|
||||
tags=[USER_ADMIN_TAG],
|
||||
operation_summary="Активировать пользователя",
|
||||
operation_description="Возвращает пользователя в активное состояние.",
|
||||
responses={
|
||||
200: UserSerializer,
|
||||
**ErrorResponses.ADMIN_NOT_FOUND,
|
||||
},
|
||||
)
|
||||
def post(self, request, user_id: int):
|
||||
user = UserService.activate_user(user_id)
|
||||
return Response(UserSerializer(user).data)
|
||||
|
||||
|
||||
class UserUpdateView(APIView):
|
||||
"""Обновление данных пользователя."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user