Fix admin API gaps for users, exchange checks, and parser logs

This commit is contained in:
2026-03-19 16:48:38 +01:00
parent 25176f31b4
commit 941c268d32
22 changed files with 817 additions and 28 deletions

View File

@@ -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",)}),

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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):
"""Обновление данных пользователя."""