import logging from datetime import timedelta from django.db.models import Count from django.db.models.deletion import ProtectedError from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response from commerce.integrations.moysklad import MoySkladError from commerce.integrations.wildberries import WildberriesError from commerce.models import ( Cost, IntegrationConnection, IntegrationStatus, IntegrationSyncRun, Product, ProductBinding, Sale, SaleLine, SourceSystem, StockSnapshot, ) from commerce.search import UnicodeCasefoldSearchFilter from commerce.services import ( get_product_metrics, merge_products, test_moysklad_connection, test_wildberries_connection, ) from commerce.services.credentials import CredentialError from commerce.tasks import enqueue_connection_sync from .permissions import CommerceAccessPermission from .serializers import ( CostSerializer, IntegrationConnectionSerializer, IntegrationSyncRunSerializer, ProductBindingSerializer, ProductMergeSerializer, ProductMetricsQuerySerializer, ProductSerializer, SaleLineSerializer, SaleSerializer, SourceSystemSerializer, StockSnapshotSerializer, ) logger = logging.getLogger(__name__) class IntegrationConnectionViewSet(viewsets.ModelViewSet): queryset = IntegrationConnection.objects.select_related("source") serializer_class = IntegrationConnectionSerializer permission_classes = [CommerceAccessPermission] http_method_names = ["get", "post", "patch", "delete", "head", "options"] @action(detail=True, methods=["post"]) def test(self, request, pk=None): connection = self.get_object() try: if connection.provider == "wildberries": context = test_wildberries_connection(connection) elif connection.provider == "moysklad": context = test_moysklad_connection(connection) else: return Response( {"detail": "Неподдерживаемый провайдер интеграции."}, status=status.HTTP_400_BAD_REQUEST, ) except (MoySkladError, WildberriesError, CredentialError) as exc: logger.warning( "Commerce connection test failed", extra={ "event": "commerce.connection.test_failed", "connection_id": str(connection.pk), "provider": connection.provider, "error_type": type(exc).__name__, "failure_reason": str(exc), }, ) connection.status = IntegrationStatus.ERROR connection.last_error = str(exc) connection.last_tested_at = timezone.now() connection.save( update_fields=[ "status", "last_error", "last_tested_at", "updated_at", ] ) return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) connection.status = IntegrationStatus.READY connection.account_name = context["account_name"] connection.last_error = "" connection.last_tested_at = timezone.now() connection.next_sync_at = ( timezone.now() + timedelta(minutes=connection.sync_interval_minutes) if connection.auto_sync else None ) connection.source.metadata = { **connection.source.metadata, "account_id": context["account_id"], } if context.get("tin"): connection.source.metadata["tin"] = context["tin"] connection.source.save(update_fields=["metadata", "updated_at"]) connection.save( update_fields=[ "status", "account_name", "last_error", "last_tested_at", "next_sync_at", "updated_at", ] ) logger.info( "Commerce connection test succeeded", extra={ "event": "commerce.connection.test_succeeded", "connection_id": str(connection.pk), "provider": connection.provider, }, ) return Response(self.get_serializer(connection).data) @action(detail=True, methods=["post"]) def sync(self, request, pk=None): connection = self.get_object() if connection.status in {IntegrationStatus.QUEUED, IntegrationStatus.SYNCING}: return Response( {"detail": "Синхронизация уже выполняется."}, status=status.HTTP_409_CONFLICT, ) run = enqueue_connection_sync(connection) if run is None: return Response( {"detail": "Синхронизация уже выполняется."}, status=status.HTTP_409_CONFLICT, ) return Response( IntegrationSyncRunSerializer(run).data, status=status.HTTP_202_ACCEPTED, ) class IntegrationSyncRunViewSet(viewsets.ReadOnlyModelViewSet): queryset = IntegrationSyncRun.objects.select_related("connection") serializer_class = IntegrationSyncRunSerializer permission_classes = [CommerceAccessPermission] filter_backends = [DjangoFilterBackend] filterset_fields = ["connection", "status"] class SourceSystemViewSet(viewsets.ModelViewSet): queryset = SourceSystem.objects.all() serializer_class = SourceSystemSerializer permission_classes = [CommerceAccessPermission] filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ["code", "name"] ordering_fields = ["code", "name", "created_at"] ordering = ["name"] def destroy(self, request, *args, **kwargs): source = self.get_object() try: self.perform_destroy(source) except ProtectedError as exc: protected_count = len(exc.protected_objects) logger.info( "Commerce source deletion rejected because it is in use", extra={ "event": "commerce.source.delete_conflict", "source_id": source.pk, "source_code": source.code, "protected_count": protected_count, }, ) return Response( { "code": "source_in_use", "detail": ( "Источник используется подключением или импортированными " "данными. Сначала удалите связанные записи." ), "protected_count": protected_count, }, status=status.HTTP_409_CONFLICT, ) return Response(status=status.HTTP_204_NO_CONTENT) class ProductViewSet(viewsets.ModelViewSet): queryset = Product.objects.all() serializer_class = ProductSerializer permission_classes = [CommerceAccessPermission] filter_backends = [ DjangoFilterBackend, UnicodeCasefoldSearchFilter, filters.OrderingFilter, ] filterset_fields = ["kind", "is_active", "parent"] search_fields = [ "sku__unicode_casefold__contains", "name__unicode_casefold__contains", "barcode__unicode_casefold__contains", "bindings__external_sku__unicode_casefold__contains", ] ordering_fields = ["sku", "name", "created_at", "updated_at"] ordering = ["name", "sku"] def get_queryset(self): return ( super() .get_queryset() .select_related("parent") .prefetch_related("bindings__source", "images") .annotate(variants_count=Count("variants", distinct=True)) .distinct() ) @action(detail=True, methods=["get"]) def analytics(self, request, pk=None): query = ProductMetricsQuerySerializer(data=request.query_params) query.is_valid(raise_exception=True) product = self.get_object() return Response(get_product_metrics(product, **query.validated_data)) @action(detail=True, methods=["post"]) def merge(self, request, pk=None): target = self.get_object() serializer = ProductMergeSerializer( data=request.data, context={"target": target}, ) serializer.is_valid(raise_exception=True) result = merge_products( target=target, source_products=serializer.validated_data["source_products"], ) refreshed = self.get_queryset().get(pk=result.target.pk) return Response( { "product": ProductSerializer(refreshed).data, "merged_products": result.merged_products, "moved_bindings": result.moved_bindings, } ) class ProductBindingViewSet(viewsets.ModelViewSet): queryset = ProductBinding.objects.select_related("product", "source") serializer_class = ProductBindingSerializer permission_classes = [CommerceAccessPermission] filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ["product", "source", "is_primary"] search_fields = ["external_id", "external_sku", "product__sku", "product__name"] class SaleViewSet(viewsets.ModelViewSet): queryset = Sale.objects.select_related("source", "source_record").prefetch_related( "lines__product" ) serializer_class = SaleSerializer permission_classes = [CommerceAccessPermission] filter_backends = [ DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter, ] filterset_fields = [ "source", "event_type", "channel", "status", "counts_as_sale", "currency", ] search_fields = ["external_id", "number", "lines__external_sku", "lines__name"] ordering_fields = ["occurred_at", "created_at", "updated_at"] ordering = ["-occurred_at"] class SaleLineViewSet(viewsets.ModelViewSet): queryset = SaleLine.objects.select_related("sale", "product", "sale__source") serializer_class = SaleLineSerializer permission_classes = [CommerceAccessPermission] filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ["sale", "product"] search_fields = ["external_id", "external_product_id", "external_sku", "name"] class CostViewSet(viewsets.ModelViewSet): queryset = Cost.objects.select_related("source", "product", "sale_line") serializer_class = CostSerializer permission_classes = [CommerceAccessPermission] filter_backends = [ DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter, ] filterset_fields = ["source", "product", "channel", "cost_type", "currency"] search_fields = ["external_id", "channel", "product__sku", "product__name"] ordering_fields = ["occurred_at", "amount", "created_at"] ordering = ["-occurred_at"] class StockSnapshotViewSet(viewsets.ModelViewSet): queryset = StockSnapshot.objects.select_related("source", "product") serializer_class = StockSnapshotSerializer permission_classes = [CommerceAccessPermission] filter_backends = [ DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter, ] filterset_fields = ["source", "product", "warehouse_external_id"] search_fields = [ "warehouse_external_id", "warehouse_name", "product__sku", "product__name", ] ordering_fields = ["captured_at", "quantity", "created_at"] ordering = ["-captured_at"]