add product

This commit is contained in:
javad committed 2026-10-08 16:37:45 +03:30
1 parent 423d4cf40e
commit c8d45dd07c
58 files changed
+8309 -674

No files matched your search

+63 -71
View File
@@ -5,45 +5,34 @@ from apps.shop.models import Product as ProductModel, ProductCategory as Product
from apps.workspace.models import Workspace as WorkspaceModel, City as CityModel
from apps.customer.models import Customer as CustomerModel
from apps.customer.models import ResponsibleGroup as ResponsibleGroupModel
import os
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError, transaction
from rest_framework import exceptions
from rest_framework.permissions import IsAuthenticated
from utils import custom_permissions
from utils.get_user_workspace import get_user_workspace
from utils.validations.mobile import normalize_mobile
class ImportExcelView(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission)
def get(self, request):
df = pd.read_excel('pe.xlsx')
for index, row in df.iterrows():
cate_name = row['cate_name']
#order_cat = row['order_cat']
acc_code = row['acc_code']
prd_name = row['name']
order_prd = row['size']
vahed = row['vahed']
workspace = WorkspaceModel.objects.get(id=2)
try:
prd_ct = ProductCategoryModel.objects.get(name=cate_name, sys_ws=workspace)
except ProductCategoryModel.DoesNotExist as exp:
prd_ct = ProductCategoryModel.objects.create(name=cate_name, sys_ws=workspace)
if ProductModel.objects.filter(acc_code=acc_code).exists() == False:
new_product = ProductModel()
new_product.name = prd_name
new_product.price = 0
new_product.category = ProductCategoryModel.objects.get(id=prd_ct.id)
new_product.acc_code = acc_code
new_product.order_prd = order_prd
new_product.view_only = True
new_product.attr = {"acc_vahed": vahed}
new_product.sys_ws = workspace
new_product.save()
else:
up_product = ProductModel.objects.get(acc_code=acc_code)
up_product.name = prd_name
up_product.save()
return Response(data={"result": 'ok'})
# Keep the legacy URL and filename, but use the same validated importer as Admin.
from django.core.files import File
from apps.shop.product_import import parse_workbook, import_products
try:
with open('pe.xlsx', 'rb') as source:
rows = parse_workbook(File(source, name='pe.xlsx'))
for row in rows:
row['values'].setdefault('view_only', 'true')
result = import_products(get_user_workspace(request), rows,
{'mode': 'upsert', 'create_categories': True, 'errors': 'atomic', 'ordering': 'preserve'})
except (OSError, DjangoValidationError) as error:
raise exceptions.ValidationError(getattr(error, 'messages', ['فایل اکسل محصولات خوانده نشد.']))
if not result['committed']:
raise exceptions.ValidationError(result)
return Response(data={'result': 'ok', 'imported': result['created'] + result['updated']})
class ImportExcelCitysView(APIView):
@@ -72,43 +61,46 @@ class ImportExcelCitysView(APIView):
return Response(data={"result": 'ok'})
def customer_from_excel(row, workspace):
mobile = row['mobile']
if pd.isna(mobile) or pd.isna(row['name']):
raise exceptions.ValidationError('نام و شماره موبایل مشتری الزامی است.')
if isinstance(mobile, float) and mobile.is_integer():
mobile = int(mobile)
city = CityModel.objects.filter(name=row['shar'], ostan__name=row['ostan'], ostan__is_ostan=True).first()
city = city or CityModel.objects.filter(pk=1229).first()
if not city:
raise exceptions.ValidationError('شهر مشتری در فایل پیدا نشد.')
group = ResponsibleGroupModel.objects.filter(pk=row['id_mantaghe'], sys_ws=workspace).first()
if not group:
raise exceptions.ValidationError('گروه مسئول متعلق به این فضای کاری نیست.')
customer = CustomerModel(name_family=row['name'], mobile=normalize_mobile(mobile), city=city,
responsible_group=group, sys_ws=workspace,
phone=None if pd.isna(row['phone']) else row['phone'],
address=None if pd.isna(row['address']) else row['address'])
customer.full_clean()
customer.save()
class ImportExcelCustomersView(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission)
@transaction.atomic
def get(self, request):
df = pd.read_excel('customersData.xlsx')
for index, row in df.iterrows():
name = row['name']
mobile = row['mobile']
if name and mobile:
phone = row['phone']
address = row['address']
ostan = row['ostan']
shahr = row['shar']
mantaghe = row['id_mantaghe']
try:
ostan_obj = CityModel.objects.get(name=ostan, is_ostan=True)
shahr_obj = CityModel.objects.get(name=shahr, ostan=ostan_obj)
except Exception as exp:
shahr_obj = CityModel.objects.get(id=1229)
try:
cs = CustomerModel()
cs.name_family = name
cs.mobile = mobile
if phone:
cs.phone = phone
cs.city = shahr_obj
if address:
cs.address = address
cs.responsible_group = ResponsibleGroupModel.objects.get(id=mantaghe)
cs.sys_ws = WorkspaceModel.objects.get(id=2)
cs.save()
except Exception as exp:
print(name)
return Response(data={"result": 'ok'})
workspace = get_user_workspace(request)
try:
data = pd.read_excel('customersData.xlsx')
except (OSError, ValueError):
raise exceptions.ValidationError('فایل اکسل مشتریان خوانده نشد.')
for index, row in data.iterrows():
try:
customer_from_excel(row, workspace)
except DjangoValidationError as error:
raise exceptions.ValidationError({'row': int(index)+2, 'errors': error.message_dict})
except IntegrityError:
raise exceptions.ValidationError({'row': int(index)+2, 'mobile': 'شماره موبایل مشتری تکراری است.'})
except exceptions.ValidationError as error:
raise exceptions.ValidationError({'row': int(index)+2, 'errors': error.detail})
except (KeyError, TypeError, ValueError):
raise exceptions.ValidationError({'row': int(index)+2, 'error': 'اطلاعات این ردیف معتبر نیست.'})
return Response(data={'result': 'ok', 'imported': len(data)})
+293
View File
@@ -0,0 +1,293 @@
"""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()
+4 -2
View File
@@ -11,6 +11,7 @@ from rest_framework import generics
from apps.customer.models import Customer as CustomerModel
from rest_framework.permissions import IsAuthenticated
from utils import custom_permissions
import pandas as pd
from utils.tools import inline_serializer
from io import BytesIO
@@ -212,13 +213,14 @@ class OrderRetrieveOperatorApi(APIView):
class OrderRetrieveOperatorExcelApi(APIView):
permission_classes = (IsAuthenticated, )
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission)
def get(self, request, order_id):
order_df = retrieve_order_item_excel(request=request, order_id=order_id)
excel_file = BytesIO()
order_df.to_excel(excel_file, engine='xlsxwriter', index=False)
with pd.ExcelWriter(excel_file, engine='xlsxwriter', engine_kwargs={'options': {'strings_to_formulas': False, 'strings_to_urls': False}}) as writer:
order_df.to_excel(writer, index=False)
excel_file.seek(0)
response = HttpResponse(excel_file.read(), content_type='application/ms-excel')
+15 -9
View File
@@ -16,11 +16,14 @@ class ProductCreateApi(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission)
class InputSerializer(serializers.Serializer):
name = serializers.CharField(required=True, max_length=20)
price = serializers.DecimalField(required=True, max_digits=20, decimal_places=2)
name = serializers.CharField(required=True, max_length=200)
price = serializers.IntegerField(required=True, min_value=0, max_value=9223372036854775807)
category = serializers.IntegerField(required=True)
acc_code = serializers.CharField(required=False)
attr = serializers.CharField(required=False)
attr = serializers.JSONField(required=False, allow_null=True)
order_prd = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
view_only = serializers.BooleanField(required=False)
class OutputSerializer(serializers.ModelSerializer):
class Meta:
@@ -64,11 +67,14 @@ class ProductUpdateApi(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission)
class InputSerializer(serializers.Serializer):
name = serializers.CharField(required=True, max_length=20)
price = serializers.DecimalField(required=True, max_digits=20, decimal_places=2)
name = serializers.CharField(required=True, max_length=200)
price = serializers.IntegerField(required=True, min_value=0, max_value=9223372036854775807)
category = serializers.IntegerField(required=True)
acc_code = serializers.CharField(required=False)
attr = serializers.CharField(required=False)
attr = serializers.JSONField(required=False, allow_null=True)
order_prd = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
view_only = serializers.BooleanField(required=False)
class OutputSerializer(serializers.ModelSerializer):
class Meta:
@@ -77,7 +83,7 @@ class ProductUpdateApi(APIView):
def put(self, request, product_id: int):
serializer = self.InputSerializer(data=request.data)
serializer = self.InputSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
user_workspace_uuid = get_user_workspace_uuid(request)
@@ -116,7 +122,7 @@ class ProductListApi(generics.ListAPIView):
class OutputSerializer(serializers.ModelSerializer):
class Meta:
model = ProductCategoryModel
model = ProductModel
exclude = ('sys_ws', )
def get_queryset(self):
@@ -154,7 +160,7 @@ class ProductListCategoryApi(generics.ListAPIView):
)
data = {
"category_name": category.name,
"category_img": category.image.url,
"category_img": category.image.url if category.image else None,
"products": serializer.data,
}
+14 -6
View File
@@ -18,8 +18,10 @@ class ProductCategoryCreateApi(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission)
class InputSerializer(serializers.Serializer):
name = serializers.CharField(required=True, max_length=20)
parent = serializers.IntegerField(required=False)
name = serializers.CharField(required=True, max_length=60)
parent = serializers.IntegerField(required=False, allow_null=True)
order_cat = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
class OutputSerializer(serializers.ModelSerializer):
class Meta:
@@ -62,7 +64,10 @@ class ProductCategoryUpdateApi(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission)
class InputSerializer(serializers.Serializer):
name = serializers.CharField(required=True)
name = serializers.CharField(required=False, max_length=60)
parent = serializers.IntegerField(required=False, allow_null=True)
order_cat = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
class OutputSerializer(serializers.ModelSerializer):
class Meta:
@@ -74,11 +79,11 @@ class ProductCategoryUpdateApi(APIView):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
name = serializer.validated_data.get
name = serializer.validated_data.get("name")
user_workspace_uuid = get_user_workspace_uuid(request)
product_category_obj = update_product_category(name=name, category_id=category_id, workspace_uuid=user_workspace_uuid)
product_category_obj = update_product_category(category_id=category_id, workspace_uuid=user_workspace_uuid, **serializer.validated_data)
output = self.OutputSerializer(product_category_obj)
@@ -146,6 +151,7 @@ class ProductCategoryListApi(generics.ListAPIView):
class ProductsCategoryApi(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceCustomerPermission)
class OutputSerializer(serializers.ModelSerializer):
product_count = serializers.IntegerField()
@@ -156,7 +162,9 @@ class ProductsCategoryApi(APIView):
def get(self, request):
p_cate = ProductCategoryModel.objects.annotate(product_count=Count('product')).filter(product_count__gte=1).order_by('order_cat')
p_cate = ProductCategoryModel.objects.filter(
sys_ws__uuid=get_user_workspace_uuid(request),
).annotate(product_count=Count('product')).filter(product_count__gte=1)
output = self.OutputSerializer(p_cate, many=True)
return Response(data=output.data, status=status.HTTP_200_OK)