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

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

View File

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

View File

@@ -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 запуска копирования данных в целевую БД."""

View File

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

View File

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

View File

@@ -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 для просмотра списка прокси-серверов.

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