"""JWT-protected workspace administration for the CRM product pages.""" from django.core.exceptions import ValidationError as ModelValidationError from django.db import DataError, IntegrityError, transaction from django.db.models import Count, Q from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import serializers from rest_framework.exceptions import APIException, ValidationError from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from apps.shop.import_batches import preview_batch, confirm_batch, batch_report, import_template from apps.shop.import_models import ProductImportBatch from apps.shop.management_serializers import ProductManagementSerializer, CategoryManagementSerializer, ImportOptionsSerializer from apps.shop.models import Product, ProductCategory, Cart, OrderItem from apps.shop.ordering import product_ordering from apps.workspace.models import Workspace from utils.custom_permissions import WorkspaceAdminPermission from utils.get_user_workspace import get_user_workspace class CatalogConflict(APIException): status_code = 409 default_detail = 'این عملیات با داده‌های فعلی سازگار نیست.' def ensure_product_deletable(product): if OrderItem.objects.filter(product=product).exists() or Cart.objects.filter(product=product).exists(): raise CatalogConflict('محصول در سفارش یا سبد استفاده شده است؛ برای حفظ سوابق امکان حذف ندارد.') def ensure_category_deletable(category): if Product.objects.filter(category=category).exists() or ProductCategory.objects.filter(parent=category).exists(): raise CatalogConflict('دسته دارای محصول یا زیردسته است؛ ابتدا محصولات و زیردسته‌ها را منتقل کنید.') class ManagementPagination(PageNumberPagination): page_size = 25 page_size_query_param = 'page_size' max_page_size = 100 class ManagementApi(APIView): permission_classes = (IsAuthenticated, WorkspaceAdminPermission) def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) self.workspace = get_user_workspace(request) def handle_exception(self, exc): if isinstance(exc, ModelValidationError): exc = ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else {'detail': exc.messages}) elif isinstance(exc, (DataError, IntegrityError)): exc = CatalogConflict('داده‌ها با محدودیت‌های دیتابیس سازگار نیستند یا هم‌زمان تغییر کرده‌اند؛ اطلاعات را تازه کنید.') return super().handle_exception(exc) def serializer_context(self): return {'request': self.request, 'workspace': self.workspace} def lock_workspace(self): Workspace.objects.select_for_update().get(pk=self.workspace.pk) class ProductFilters(serializers.Serializer): search = serializers.CharField(required=False, allow_blank=True, max_length=200) category = serializers.IntegerField(required=False, min_value=1) view_only = serializers.BooleanField(required=False) price_min = serializers.IntegerField(required=False, min_value=0) price_max = serializers.IntegerField(required=False, min_value=0) ordering = serializers.ChoiceField(choices=('display', 'excel', 'name', '-name', 'price', '-price', '-created_at'), default='display') def validate(self, attrs): if attrs.get('price_min', 0) > attrs.get('price_max', 9223372036854775807): raise ValidationError({'price_max': 'حداکثر قیمت باید از حداقل کمتر نباشد.'}) return attrs def filtered_products(workspace, parameters): serializer = ProductFilters(data=parameters) serializer.is_valid(raise_exception=True) data = serializer.validated_data queryset = Product.objects.filter(sys_ws=workspace).select_related('category') if data.get('search'): queryset = queryset.filter(Q(name__icontains=data['search']) | Q(acc_code__icontains=data['search']) | Q(category__name__icontains=data['search'])) if 'category' in data: queryset = queryset.filter(category_id=data['category']) if 'view_only' in data: queryset = queryset.filter(view_only=data['view_only']) for key, lookup in (('price_min', 'price__gte'), ('price_max', 'price__lte')): if key in data: queryset = queryset.filter(**{lookup: data[key]}) ordering = data['ordering'] return queryset.order_by(*product_ordering(excel=ordering == 'excel')) if ordering in ('display', 'excel') else queryset.order_by(ordering, 'pk') class ProductManagementListApi(ManagementApi): def get(self, request): pagination = ManagementPagination() rows = pagination.paginate_queryset(filtered_products(self.workspace, request.query_params), request) return pagination.get_paginated_response(ProductManagementSerializer(rows, many=True).data) @transaction.atomic def post(self, request): self.lock_workspace() serializer = ProductManagementSerializer(data=request.data, context=self.serializer_context()) serializer.is_valid(raise_exception=True) product = Product(sys_ws=self.workspace, **serializer.validated_data) product.full_clean() product.save() return Response(ProductManagementSerializer(product).data, status=201) class ProductManagementDetailApi(ManagementApi): def get(self, request, pk): product = get_object_or_404(Product, pk=pk, sys_ws=self.workspace) return Response(ProductManagementSerializer(product).data) @transaction.atomic def patch(self, request, pk): self.lock_workspace() product = get_object_or_404(Product.objects.select_for_update(), pk=pk, sys_ws=self.workspace) serializer = ProductManagementSerializer(product, data=request.data, partial=True, context=self.serializer_context()) serializer.is_valid(raise_exception=True) for name, value in serializer.validated_data.items(): setattr(product, name, value) product.full_clean() product.save() return Response(ProductManagementSerializer(product).data) put = patch @transaction.atomic def delete(self, request, pk): self.lock_workspace() product = get_object_or_404(Product.objects.select_for_update(), pk=pk, sys_ws=self.workspace) ensure_product_deletable(product) product.delete() return Response(status=204) class CategoryManagementListApi(ManagementApi): def get(self, request): queryset = ProductCategory.objects.filter(sys_ws=self.workspace).annotate(product_count=Count('product', distinct=True), child_count=Count('productcategory', distinct=True)) return Response(CategoryManagementSerializer(queryset, many=True, context=self.serializer_context()).data) @transaction.atomic def post(self, request): self.lock_workspace() serializer = CategoryManagementSerializer(data=request.data, context=self.serializer_context()) serializer.is_valid(raise_exception=True) category = ProductCategory(sys_ws=self.workspace, **serializer.validated_data) category.full_clean() category.save() return Response(CategoryManagementSerializer(category, context=self.serializer_context()).data, status=201) class CategoryManagementDetailApi(ManagementApi): def get(self, request, pk): category = get_object_or_404(ProductCategory, pk=pk, sys_ws=self.workspace) return Response(CategoryManagementSerializer(category, context=self.serializer_context()).data) @transaction.atomic def patch(self, request, pk): self.lock_workspace() category = get_object_or_404(ProductCategory.objects.select_for_update(), pk=pk, sys_ws=self.workspace) serializer = CategoryManagementSerializer(category, data=request.data, partial=True, context=self.serializer_context()) serializer.is_valid(raise_exception=True) for name, value in serializer.validated_data.items(): setattr(category, name, value) category.full_clean() category.save() return Response(CategoryManagementSerializer(category, context=self.serializer_context()).data) put = patch @transaction.atomic def delete(self, request, pk): self.lock_workspace() category = get_object_or_404(ProductCategory.objects.select_for_update(), pk=pk, sys_ws=self.workspace) ensure_category_deletable(category) category.delete() return Response(status=204) class ProductBulkSerializer(serializers.Serializer): ids = serializers.ListField(child=serializers.IntegerField(min_value=1), min_length=1, max_length=100) action = serializers.ChoiceField(choices=('delete', 'move', 'view_only', 'display_order', 'excel_order')) category = serializers.IntegerField(min_value=1, required=False) view_only = serializers.BooleanField(required=False) def validate(self, attrs): if len(set(attrs['ids'])) != len(attrs['ids']): raise ValidationError({'ids': 'شناسه‌های تکراری مجاز نیست.'}) for action, key in (('move', 'category'), ('view_only', 'view_only')): if attrs['action'] == action and key not in attrs: raise ValidationError({key: 'این مقدار برای عملیات انتخاب‌شده لازم است.'}) return attrs class ProductBulkApi(ManagementApi): @transaction.atomic def post(self, request): self.lock_workspace() input_serializer = ProductBulkSerializer(data=request.data) input_serializer.is_valid(raise_exception=True) data = input_serializer.validated_data products = {item.pk: item for item in Product.objects.select_for_update().filter(sys_ws=self.workspace, pk__in=data['ids'])} if len(products) != len(data['ids']): raise ValidationError({'ids': 'یک یا چند محصول در فضای کاری شما پیدا نشد.'}) for position, pk in enumerate(data['ids'], 1): product = products[pk] if data['action'] == 'delete': ensure_product_deletable(product) product.delete() else: values = bulk_values(data, position) serializer = ProductManagementSerializer(product, data=values, partial=True, context=self.serializer_context()) serializer.is_valid(raise_exception=True) for name, value in serializer.validated_data.items(): setattr(product, name, value) product.full_clean() product.save() return Response({'updated': len(products), 'action': data['action']}) def bulk_values(data, position): if data['action'] == 'move': return {'category': data['category']} if data['action'] == 'view_only': return {'view_only': data['view_only']} return {'order_prd' if data['action'] == 'display_order' else 'excel_order': position} class ProductExportApi(ManagementApi): def get(self, request): from apps.shop.admin import export_products return export_products(filtered_products(self.workspace, request.query_params)) def batch_data(batch, page=1): result = batch.result rows = result.get('rows', []) return {'id': batch.pk, 'filename': batch.filename, 'created_at': batch.created_at, 'expires_at': batch.expires_at, 'confirmed_at': batch.confirmed_at, 'options': batch.payload['options'], 'created': result.get('created', 0), 'updated': result.get('updated', 0), 'errors': result.get('errors', 0), 'committed': bool(batch.confirmed_at), 'can_confirm': not batch.confirmed_at and batch.expires_at > timezone.now() and (batch.payload['options']['errors'] == 'skip' or not result.get('errors')), 'row_count': len(rows), 'row_page': page, 'rows': rows[(page-1)*100:page*100]} class ProductImportPreviewApi(ManagementApi): def post(self, request): serializer = ImportOptionsSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data options = {key: data[key] for key in ('mode', 'create_categories', 'errors', 'ordering')} batch = preview_batch(request.user, self.workspace, data['file'], options, data['sheet'], data['header_row'], data['mapping']) return Response(batch_data(batch), status=201) class ProductImportListApi(ManagementApi): def get(self, request): queryset = ProductImportBatch.objects.filter(sys_ws=self.workspace, created_by=request.user) pagination = ManagementPagination() rows = pagination.paginate_queryset(queryset, request) results = [{key: value for key, value in batch_data(batch).items() if key != 'rows'} for batch in rows] return pagination.get_paginated_response(results) class ProductImportDetailApi(ManagementApi): def get(self, request, pk): batch = get_object_or_404(ProductImportBatch, pk=pk, sys_ws=self.workspace, created_by=request.user) page_serializer = serializers.IntegerField(min_value=1, max_value=50) page = page_serializer.run_validation(request.query_params.get('row_page', 1)) return Response(batch_data(batch, page)) class ProductImportConfirmApi(ManagementApi): def post(self, request, pk): return Response(batch_data(confirm_batch(pk, request.user, self.workspace))) class ProductImportReportApi(ManagementApi): def get(self, request, pk): batch = get_object_or_404(ProductImportBatch, pk=pk, sys_ws=self.workspace, created_by=request.user) return batch_report(batch) class ProductImportTemplateApi(ManagementApi): def get(self, request): return import_template()