Fix admin API gaps for users, exchange checks, and parser logs
This commit is contained in:
@@ -29,6 +29,27 @@ class ExchangeConnectionService:
|
||||
"parsers.FinancialReportLine",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def test_connection_payload(cls, **payload) -> dict[str, str]:
|
||||
"""Проверить подключение и структуру без сохранения в БД."""
|
||||
connection = ExchangeConnection(is_active=False, **payload)
|
||||
alias = cls.test_connection(connection)
|
||||
cls.validate_target_structure(
|
||||
connection=connection,
|
||||
alias=alias,
|
||||
schema_name=connection.schema_name,
|
||||
)
|
||||
|
||||
with suppress(Exception):
|
||||
connections[alias].close()
|
||||
with suppress(Exception):
|
||||
connections.databases.pop(alias, None)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Подключение и структура целевой БД валидны.",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def create_active_connection_and_prepare(cls, **payload) -> ExchangeConnection:
|
||||
@@ -456,4 +477,7 @@ class ExchangeConnectionService:
|
||||
) -> None:
|
||||
connection.last_checked_at = timezone.now()
|
||||
connection.last_error = error_message
|
||||
connection.save(update_fields=["last_checked_at", "last_error", "updated_at"])
|
||||
if connection.pk:
|
||||
connection.save(
|
||||
update_fields=["last_checked_at", "last_error", "updated_at"]
|
||||
)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""URL конфигурация приложения exchange."""
|
||||
|
||||
from apps.exchange.views import ExchangeConnectionListCreateView, ExchangeCopyDataView
|
||||
from apps.exchange.views import (
|
||||
ExchangeConnectionListCreateView,
|
||||
ExchangeConnectionTestView,
|
||||
ExchangeCopyDataView,
|
||||
)
|
||||
from django.urls import path
|
||||
|
||||
app_name = "exchange"
|
||||
@@ -9,6 +13,11 @@ exchange_urlpatterns = [
|
||||
path(
|
||||
"connections/", ExchangeConnectionListCreateView.as_view(), name="connections"
|
||||
),
|
||||
path(
|
||||
"connections/test/",
|
||||
ExchangeConnectionTestView.as_view(),
|
||||
name="connections-test",
|
||||
),
|
||||
path("copy/", ExchangeCopyDataView.as_view(), name="copy"),
|
||||
]
|
||||
|
||||
|
||||
@@ -78,6 +78,48 @@ class ExchangeConnectionListCreateView(APIView):
|
||||
return api_created_response(output.data)
|
||||
|
||||
|
||||
class ExchangeConnectionTestView(APIView):
|
||||
"""API проверки подключения к внешней БД без сохранения."""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
@swagger_auto_schema(
|
||||
tags=[EXCHANGE_TAG],
|
||||
operation_summary="Проверить подключение",
|
||||
operation_description=(
|
||||
"Проверяет подключение и структуру целевой БД без сохранения "
|
||||
"настроек подключения."
|
||||
),
|
||||
request_body=ExchangeConnectionCreateSerializer,
|
||||
responses={
|
||||
200: openapi.Response(
|
||||
description="Проверка успешна",
|
||||
schema=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"status": openapi.Schema(type=openapi.TYPE_STRING),
|
||||
"message": openapi.Schema(type=openapi.TYPE_STRING),
|
||||
},
|
||||
),
|
||||
),
|
||||
400: CommonResponses.BAD_REQUEST,
|
||||
**ErrorResponses.ADMIN,
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = ExchangeConnectionCreateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
result = ExchangeConnectionService.test_connection_payload(
|
||||
**serializer.validated_data
|
||||
)
|
||||
except ExchangeServiceError as exc:
|
||||
raise ValidationError({"connection": str(exc)}) from exc
|
||||
|
||||
return api_response(result, status_code=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ExchangeCopyDataView(APIView):
|
||||
"""API запуска копирования данных в целевую БД."""
|
||||
|
||||
|
||||
@@ -296,6 +296,7 @@ class ParserLoadLogSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
||||
source_display = serializers.CharField(source="get_source_display", read_only=True)
|
||||
organizations_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ParserLoadLog
|
||||
@@ -305,6 +306,7 @@ class ParserLoadLogSerializer(serializers.ModelSerializer):
|
||||
"source",
|
||||
"source_display",
|
||||
"records_count",
|
||||
"organizations_count",
|
||||
"status",
|
||||
"error_message",
|
||||
"created_at",
|
||||
@@ -312,6 +314,57 @@ class ParserLoadLogSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_organizations_count(self, obj) -> int:
|
||||
if obj.source == ParserLoadLog.Source.FNS_REPORTS:
|
||||
return (
|
||||
FinancialReport.objects.filter(load_batch=obj.batch_id)
|
||||
.exclude(ogrn="")
|
||||
.values("ogrn")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
if obj.source == ParserLoadLog.Source.INDUSTRIAL:
|
||||
return (
|
||||
IndustrialCertificateRecord.objects.filter(load_batch=obj.batch_id)
|
||||
.exclude(inn="")
|
||||
.values("inn")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
if obj.source == ParserLoadLog.Source.INDUSTRIAL_PRODUCTS:
|
||||
return (
|
||||
IndustrialProductRecord.objects.filter(load_batch=obj.batch_id)
|
||||
.exclude(inn="")
|
||||
.values("inn")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
if obj.source == ParserLoadLog.Source.MANUFACTURES:
|
||||
return (
|
||||
ManufacturerRecord.objects.filter(load_batch=obj.batch_id)
|
||||
.exclude(inn="")
|
||||
.values("inn")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
if obj.source == ParserLoadLog.Source.INSPECTIONS:
|
||||
return (
|
||||
InspectionRecord.objects.filter(load_batch=obj.batch_id)
|
||||
.exclude(inn="")
|
||||
.values("inn")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
if obj.source == ParserLoadLog.Source.PROCUREMENTS:
|
||||
return (
|
||||
ProcurementRecord.objects.filter(load_batch=obj.batch_id)
|
||||
.exclude(customer_inn="")
|
||||
.values("customer_inn")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
class ProxySerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
||||
@@ -11,6 +11,7 @@ from apps.parsers.views import (
|
||||
IndustrialProductViewSet,
|
||||
InspectionViewSet,
|
||||
ManufacturerViewSet,
|
||||
ParserLoadLogExportView,
|
||||
ParserLoadLogViewSet,
|
||||
ProcurementViewSet,
|
||||
ProxyViewSet,
|
||||
@@ -101,6 +102,7 @@ system_router.register(r"logs", ParserLoadLogViewSet, basename="parser-logs")
|
||||
system_router.register(r"proxies", ProxyViewSet, basename="proxies")
|
||||
|
||||
system_urlpatterns = [
|
||||
path("logs/export/", ParserLoadLogExportView.as_view(), name="parser-logs-export"),
|
||||
path("", include(system_router.urls)),
|
||||
]
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Views для приложения парсеров.
|
||||
Добавление и удаление данных - через парсеры и админку.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
@@ -43,7 +44,9 @@ from apps.parsers.serializers import (
|
||||
from apps.parsers.source_cards import SourceCardService
|
||||
from apps.parsers.tasks import process_fns_file
|
||||
from django.conf import settings
|
||||
from django.db.models import Count
|
||||
from django.db.models import CharField, Count, Q
|
||||
from django.db.models.functions import Cast
|
||||
from django.http import HttpResponse
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import status
|
||||
@@ -64,6 +67,46 @@ FNS_TAG = swagger_tag("ФНС - Бухгалтерская отчетность"
|
||||
SOURCES_TAG = swagger_tag("Источники для фронта", "frontend_sources")
|
||||
SYSTEM_TAG = swagger_tag("Системные", "system")
|
||||
|
||||
PARSER_LOG_ORDERING_FIELDS = {
|
||||
"id",
|
||||
"batch_id",
|
||||
"source",
|
||||
"status",
|
||||
"records_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
|
||||
def _get_parser_logs_queryset(*, search_query: str = ""):
|
||||
queryset = ParserLoadLog.objects.all().order_by("-created_at")
|
||||
search_term = search_query.strip()
|
||||
if not search_term:
|
||||
return queryset
|
||||
|
||||
return queryset.annotate(
|
||||
batch_id_text=Cast("batch_id", output_field=CharField())
|
||||
).filter(
|
||||
Q(source__icontains=search_term)
|
||||
| Q(status__icontains=search_term)
|
||||
| Q(error_message__icontains=search_term)
|
||||
| Q(batch_id_text__icontains=search_term)
|
||||
)
|
||||
|
||||
|
||||
def _apply_safe_ordering(queryset, ordering: str, allowed_fields: set[str]):
|
||||
order_by_fields = []
|
||||
for raw_field in (item.strip() for item in ordering.split(",") if item.strip()):
|
||||
field_name = raw_field[1:] if raw_field.startswith("-") else raw_field
|
||||
if field_name not in allowed_fields:
|
||||
continue
|
||||
order_by_fields.append(raw_field)
|
||||
|
||||
if not order_by_fields:
|
||||
return queryset
|
||||
|
||||
return queryset.order_by(*order_by_fields)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Минпромторг - Сертификаты промышленного производства
|
||||
@@ -703,10 +746,15 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet):
|
||||
Только для администраторов.
|
||||
"""
|
||||
|
||||
queryset = ParserLoadLog.objects.all().order_by("-created_at")
|
||||
serializer_class = ParserLoadLogSerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
filterset_fields = ["source", "status", "batch_id"]
|
||||
ordering_fields = list(PARSER_LOG_ORDERING_FIELDS)
|
||||
|
||||
def get_queryset(self):
|
||||
return _get_parser_logs_queryset(
|
||||
search_query=self.request.query_params.get("search", "")
|
||||
)
|
||||
|
||||
@swagger_auto_schema(
|
||||
tags=[SYSTEM_TAG],
|
||||
@@ -714,8 +762,30 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet):
|
||||
operation_description=(
|
||||
"Возвращает историю загрузок данных парсерами.\n"
|
||||
"Доступно только администраторам.\n"
|
||||
"Поддерживает фильтрацию по: source, status, batch_id."
|
||||
"Поддерживает фильтрацию по: source, status, batch_id.\n"
|
||||
"Поддерживает search по source, status, batch_id и error_message.\n"
|
||||
"Поддерживает ordering по: id, batch_id, source, status, records_count, "
|
||||
"created_at, updated_at."
|
||||
),
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
name="search",
|
||||
in_=openapi.IN_QUERY,
|
||||
type=openapi.TYPE_STRING,
|
||||
required=False,
|
||||
description="Поиск по source, status, batch_id и error_message",
|
||||
),
|
||||
openapi.Parameter(
|
||||
name="ordering",
|
||||
in_=openapi.IN_QUERY,
|
||||
type=openapi.TYPE_STRING,
|
||||
required=False,
|
||||
description=(
|
||||
"Сортировка по id, batch_id, source, status, records_count, "
|
||||
"created_at, updated_at. Для обратной сортировки используйте префикс -"
|
||||
),
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: ParserLoadLogSerializer(many=True),
|
||||
**ErrorResponses.ADMIN,
|
||||
@@ -737,6 +807,121 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
|
||||
|
||||
class ParserLoadLogExportView(APIView):
|
||||
"""Экспорт истории загрузок парсеров в CSV."""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
@swagger_auto_schema(
|
||||
tags=[SYSTEM_TAG],
|
||||
operation_summary="Экспорт истории загрузок",
|
||||
operation_description=(
|
||||
"Выгружает историю загрузок парсеров в CSV. "
|
||||
"Поддерживает те же фильтры, search и ordering, что и список логов."
|
||||
),
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
name="source",
|
||||
in_=openapi.IN_QUERY,
|
||||
type=openapi.TYPE_STRING,
|
||||
required=False,
|
||||
description="Фильтр по коду источника",
|
||||
),
|
||||
openapi.Parameter(
|
||||
name="status",
|
||||
in_=openapi.IN_QUERY,
|
||||
type=openapi.TYPE_STRING,
|
||||
required=False,
|
||||
description="Фильтр по статусу",
|
||||
),
|
||||
openapi.Parameter(
|
||||
name="batch_id",
|
||||
in_=openapi.IN_QUERY,
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=False,
|
||||
description="Фильтр по ID пакета",
|
||||
),
|
||||
openapi.Parameter(
|
||||
name="search",
|
||||
in_=openapi.IN_QUERY,
|
||||
type=openapi.TYPE_STRING,
|
||||
required=False,
|
||||
description="Поиск по source, status, batch_id и error_message",
|
||||
),
|
||||
openapi.Parameter(
|
||||
name="ordering",
|
||||
in_=openapi.IN_QUERY,
|
||||
type=openapi.TYPE_STRING,
|
||||
required=False,
|
||||
description="Сортировка по тем же полям, что и в списке логов",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: "CSV файл",
|
||||
**ErrorResponses.ADMIN,
|
||||
},
|
||||
)
|
||||
def get(self, request):
|
||||
queryset = _get_parser_logs_queryset(
|
||||
search_query=request.query_params.get("search", "")
|
||||
)
|
||||
|
||||
source = request.query_params.get("source")
|
||||
status_value = request.query_params.get("status")
|
||||
batch_id = request.query_params.get("batch_id")
|
||||
|
||||
if source:
|
||||
queryset = queryset.filter(source=source)
|
||||
if status_value:
|
||||
queryset = queryset.filter(status=status_value)
|
||||
if batch_id:
|
||||
queryset = queryset.filter(batch_id=batch_id)
|
||||
|
||||
queryset = _apply_safe_ordering(
|
||||
queryset,
|
||||
request.query_params.get("ordering", ""),
|
||||
PARSER_LOG_ORDERING_FIELDS,
|
||||
)
|
||||
|
||||
serializer = ParserLoadLogSerializer(queryset, many=True)
|
||||
response = HttpResponse(content_type="text/csv; charset=utf-8")
|
||||
response["Content-Disposition"] = 'attachment; filename="parser-load-logs.csv"'
|
||||
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(
|
||||
[
|
||||
"id",
|
||||
"batch_id",
|
||||
"source",
|
||||
"source_display",
|
||||
"records_count",
|
||||
"organizations_count",
|
||||
"status",
|
||||
"error_message",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
for row in serializer.data:
|
||||
writer.writerow(
|
||||
[
|
||||
row["id"],
|
||||
row["batch_id"],
|
||||
row["source"],
|
||||
row["source_display"],
|
||||
row["records_count"],
|
||||
row["organizations_count"],
|
||||
row["status"],
|
||||
row["error_message"],
|
||||
row["created_at"],
|
||||
row["updated_at"],
|
||||
]
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class ProxyViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
API для просмотра списка прокси-серверов.
|
||||
|
||||
@@ -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