add product
This commit is contained in:
1 parent
423d4cf40e
commit
c8d45dd07c
58 files changed
+8309
-674
No files matched your search
+41
-8
@@ -1,4 +1,7 @@
|
||||
from utils.admin_base import CRMModelAdmin
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from apps.cuser.forms import CUserCreationForm, CUserChangeForm
|
||||
from apps.cuser.models import (
|
||||
CUser as CUserModel,
|
||||
CuserRole as CuserRoleModel,
|
||||
@@ -8,23 +11,53 @@ from apps.cuser.models import (
|
||||
# Register your models here.
|
||||
|
||||
|
||||
class CUserAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
class CUserAdmin(UserAdmin):
|
||||
list_per_page = 50
|
||||
save_on_top = True
|
||||
date_hierarchy = 'created_at'
|
||||
form = CUserChangeForm
|
||||
add_form = CUserCreationForm
|
||||
list_display = ('username', 'name_family', 'mobile', 'is_active', 'is_staff')
|
||||
search_fields = ('username', 'name_family', 'mobile')
|
||||
ordering = ('username',)
|
||||
readonly_fields = ('last_login', 'created_at', 'updated_at')
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password')}),
|
||||
('اطلاعات کاربر', {'fields': ('name_family', 'mobile')}),
|
||||
('دسترسی', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
||||
('تاریخها', {'fields': ('last_login', 'created_at', 'updated_at')}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(None, {'classes': ('wide',), 'fields': ('username', 'name_family', 'mobile', 'password1', 'password2', 'is_active', 'is_staff')}),
|
||||
)
|
||||
|
||||
|
||||
class CUserRoleAdmin(admin.ModelAdmin):
|
||||
class CUserRoleAdmin(CRMModelAdmin):
|
||||
list_display = ('id', 'cuser', 'name_family', 'mobile', 'role_type', 'sys_ws', 'is_active', 'customer_panel', 'is_admin')
|
||||
search_fields = ('name_family', 'mobile', 'cuser__username')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
|
||||
|
||||
class RoleTypeAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
class RoleTypeAdmin(CRMModelAdmin):
|
||||
list_display = ('id', 'role_name', 'role_name_en', 'created_at')
|
||||
|
||||
|
||||
class SmsOptAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
class SmsOptAdmin(CRMModelAdmin):
|
||||
list_display = ("id", "mobile", "expire", "created_at")
|
||||
exclude = ("opt",)
|
||||
sensitive_fields = ("opt",)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return ("mobile", "expire", "created_at", "updated_at")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
admin.site.register(CUserModel, CUserAdmin)
|
||||
admin.site.register(CuserRoleModel, CUserRoleAdmin)
|
||||
admin.site.register(RoleTypeModel, RoleTypeAdmin)
|
||||
admin.site.register(SmsOptModel, SmsOptAdmin)
|
||||
admin.site.register(SmsOptModel, SmsOptAdmin)
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
|
||||
from utils.base_model import BaseModel
|
||||
from django.core.exceptions import ValidationError
|
||||
from utils.validations.mobile import normalize_mobile
|
||||
import uuid
|
||||
|
||||
|
||||
@@ -8,9 +10,11 @@ class CUserManager(BaseUserManager):
|
||||
def create_user(self, username, password, **extra_fields):
|
||||
if not username:
|
||||
raise ValueError("Please Enter username")
|
||||
extra_fields['mobile'] = normalize_mobile(extra_fields.get('mobile'))
|
||||
user = self.model(username=username, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
user.full_clean()
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, username, password, **extra_fields):
|
||||
@@ -31,6 +35,7 @@ class CUser(BaseModel, AbstractBaseUser, PermissionsMixin):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
|
||||
USERNAME_FIELD = 'username'
|
||||
REQUIRED_FIELDS = ['mobile']
|
||||
|
||||
objects = CUserManager()
|
||||
|
||||
@@ -44,3 +49,9 @@ class CUser(BaseModel, AbstractBaseUser, PermissionsMixin):
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name_family} - {self.username}'
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
self.mobile = normalize_mobile(self.mobile)
|
||||
if type(self).objects.filter(mobile=self.mobile).exclude(pk=self.pk).exists():
|
||||
raise ValidationError({"mobile": "کاربری با این شماره موبایل قبلاً ثبت شده است."})
|
||||
@@ -0,0 +1,30 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.cuser.cuser_model import CUser
|
||||
from utils.validations.mobile import normalize_mobile
|
||||
|
||||
|
||||
class MobileFormMixin:
|
||||
def clean_mobile(self):
|
||||
try:
|
||||
return normalize_mobile(self.cleaned_data['mobile'])
|
||||
except ValidationError as error:
|
||||
raise forms.ValidationError(error.messages)
|
||||
|
||||
|
||||
class CUserCreationForm(MobileFormMixin, UserCreationForm):
|
||||
mobile = forms.CharField(label='شماره موبایل')
|
||||
|
||||
class Meta(UserCreationForm.Meta):
|
||||
model = CUser
|
||||
fields = ('username', 'name_family', 'mobile', 'is_active', 'is_staff')
|
||||
|
||||
|
||||
class CUserChangeForm(MobileFormMixin, UserChangeForm):
|
||||
mobile = forms.CharField(label='شماره موبایل')
|
||||
|
||||
class Meta(UserChangeForm.Meta):
|
||||
model = CUser
|
||||
fields = '__all__'
|
||||
+8
-2
@@ -3,15 +3,21 @@ from apps.cuser.models import CuserRole as CuserRoleModel
|
||||
from utils.get_cuser_role import get_cuser_role_uuid
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
# Create your views here.
|
||||
|
||||
|
||||
class CUserGetInfo(views.APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
def get(self, request):
|
||||
|
||||
|
||||
cuser_role_uuid = get_cuser_role_uuid(request)
|
||||
cuser_obj = CuserRoleModel.objects.get(uuid=cuser_role_uuid)
|
||||
|
||||
return Response(data={"name_family": cuser_obj.name_family}, status=status.HTTP_200_OK)
|
||||
return Response(data={"name_family": cuser_obj.name_family,
|
||||
"role": cuser_obj.role_type.role_name_en,
|
||||
"workspace": str(cuser_obj.sys_ws.uuid),
|
||||
"permissions": {"manage_products": cuser_obj.role_type.role_name_en in ('workspace_admin', 'workspace_owner')}},
|
||||
status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
+20
-7
@@ -1,3 +1,4 @@
|
||||
from utils.admin_base import CRMModelAdmin
|
||||
from django.contrib import admin
|
||||
from apps.customer.models import (
|
||||
Customer as CustomerModel,
|
||||
@@ -11,36 +12,48 @@ from apps.customer.models import (
|
||||
# Register your models here.
|
||||
|
||||
|
||||
class CustomerNoteInline(admin.TabularInline):
|
||||
model = CustomerNoteModel
|
||||
extra = 0
|
||||
fields = ["desc","cuser_role","created_at"]
|
||||
readonly_fields = fields
|
||||
can_delete = False
|
||||
show_change_link = True
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(CustomerModel)
|
||||
class CustomerAdmin(admin.ModelAdmin):
|
||||
class CustomerAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(CustomerNoteModel)
|
||||
class CustomerNoteAdmin(admin.ModelAdmin):
|
||||
class CustomerNoteAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(ShiveAshenaeiModel)
|
||||
class ShiveAshenaeiAdmin(admin.ModelAdmin):
|
||||
class ShiveAshenaeiAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(ResponsibleGroupModel)
|
||||
class ResponsibleGroupAdmin(admin.ModelAdmin):
|
||||
class ResponsibleGroupAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(CustomerResponsibleModel)
|
||||
class CustomerResponsibleAdmin(admin.ModelAdmin):
|
||||
class CustomerResponsibleAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(CustomerGroupTypeModel)
|
||||
class CustomerGroupTypeAdmin(admin.ModelAdmin):
|
||||
class CustomerGroupTypeAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(CustomerGroupModel)
|
||||
class CustomerGroupAdmin(admin.ModelAdmin):
|
||||
class CustomerGroupAdmin(CRMModelAdmin):
|
||||
pass
|
||||
@@ -0,0 +1,60 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def canonical_mobile(value):
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if len(text) == 12 and text.startswith('98'):
|
||||
return int(text[2:])
|
||||
return value
|
||||
|
||||
|
||||
def normalized_rows(model, alias, per_workspace=False):
|
||||
seen = {}
|
||||
updates = []
|
||||
fields = ('pk', 'mobile', 'sys_ws_id') if per_workspace else ('pk', 'mobile')
|
||||
for row in model.objects.using(alias).only(*fields).iterator():
|
||||
mobile = canonical_mobile(row.mobile)
|
||||
key = (row.sys_ws_id, mobile) if per_workspace else mobile
|
||||
if mobile is not None and key in seen:
|
||||
raise RuntimeError(
|
||||
f'Duplicate mobiles in {model._meta.label}: record IDs {seen[key]} and {row.pk}. '
|
||||
'Resolve these records before migration. No records were deleted.'
|
||||
)
|
||||
if mobile is not None:
|
||||
seen[key] = row.pk
|
||||
if mobile != row.mobile:
|
||||
row.mobile = mobile
|
||||
updates.append(row)
|
||||
return updates
|
||||
|
||||
|
||||
def check_and_normalize_mobiles(apps, schema_editor):
|
||||
alias = schema_editor.connection.alias
|
||||
customer = apps.get_model('customer', 'Customer')
|
||||
user = apps.get_model('cuser', 'CUser')
|
||||
role = apps.get_model('cuser', 'CuserRole')
|
||||
# Audit both tables before writing. A collision never silently merges people.
|
||||
customer_updates = normalized_rows(customer, alias, per_workspace=True)
|
||||
user_updates = normalized_rows(user, alias)
|
||||
role_updates = []
|
||||
for row in role.objects.using(alias).only('pk', 'mobile').iterator():
|
||||
normalized = canonical_mobile(row.mobile)
|
||||
if normalized != row.mobile:
|
||||
row.mobile = normalized
|
||||
role_updates.append(row)
|
||||
customer.objects.using(alias).bulk_update(customer_updates, ['mobile'], batch_size=500)
|
||||
user.objects.using(alias).bulk_update(user_updates, ['mobile'], batch_size=500)
|
||||
role.objects.using(alias).bulk_update(role_updates, ['mobile'], batch_size=500)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('customer', '0007_alter_customer_name_family'), ('cuser', '0013_alter_cuser_name_family')]
|
||||
operations = [
|
||||
migrations.RunPython(check_and_normalize_mobiles, migrations.RunPython.noop),
|
||||
migrations.AddConstraint(
|
||||
model_name='customer',
|
||||
constraint=models.UniqueConstraint(fields=('sys_ws', 'mobile'), name='customer_workspace_mobile_unique'),
|
||||
),
|
||||
]
|
||||
@@ -3,6 +3,7 @@ from utils.base_model import BaseModel
|
||||
from apps.workspace.models import City as CityModel, Workspace as WorkspaceModel
|
||||
from apps.cuser.models import CuserRole as CuserRoleModel
|
||||
from django.core.exceptions import ValidationError
|
||||
from utils.validations.mobile import normalize_mobile
|
||||
# Create your models here.
|
||||
|
||||
|
||||
@@ -61,13 +62,24 @@ class Customer(BaseModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['sys_ws', 'mobile'], name='customer_workspace_mobile_unique'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name_family)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.mobile is not None:
|
||||
self.mobile = normalize_mobile(self.mobile)
|
||||
if type(self).objects.filter(sys_ws_id=self.sys_ws_id, mobile=self.mobile).exclude(pk=self.pk).exists():
|
||||
raise ValidationError({"mobile": "مشتری با این شماره موبایل در این فضای کاری قبلاً ثبت شده است."})
|
||||
if self.cuser_role and not self.cuser_role.role_type.role_name_en == 'workspace_customer':
|
||||
raise ValidationError("cuser_role type must be customer")
|
||||
if self.cuser_role and (self.cuser_role.sys_ws_id != self.sys_ws_id or not self.cuser_role.cuser_id
|
||||
or self.cuser_role.cuser.mobile != self.mobile):
|
||||
raise ValidationError({'cuser_role': 'حساب پنل باید متعلق به همین شماره موبایل و فضای کاری باشد.'})
|
||||
|
||||
|
||||
class CustomerNote(BaseModel):
|
||||
|
||||
+78
-52
@@ -10,7 +10,9 @@ from apps.cuser.models import CuserRole as CuserRoleModel
|
||||
from rest_framework import exceptions
|
||||
from utils.get_user_workspace import get_user_workspace, get_user_workspace_uuid
|
||||
from utils.get_cuser_role import get_cuser_role
|
||||
from django.db import transaction
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from utils.validations.mobile import normalize_mobile
|
||||
from django.contrib.auth import get_user_model
|
||||
import uuid
|
||||
import random
|
||||
@@ -20,11 +22,20 @@ CUser = get_user_model()
|
||||
|
||||
@transaction.atomic
|
||||
def customer_panel_access(request, mobile: int, customer_id: int, status: bool) -> CuserRoleModel:
|
||||
|
||||
try:
|
||||
workspace = get_user_workspace(request)
|
||||
customer = CustomerModel.objects.get(id=customer_id, sys_ws=workspace)
|
||||
|
||||
mobile = normalize_mobile(mobile)
|
||||
if customer.mobile != mobile:
|
||||
raise exceptions.ValidationError({'mobile': 'شماره موبایل باید با شماره مشتری یکسان باشد.'})
|
||||
# Disabling access must not create an unrelated account or a new role.
|
||||
if not status:
|
||||
if customer.cuser_role_id:
|
||||
if customer.cuser_role.sys_ws_id != workspace.pk:
|
||||
raise exceptions.ValidationError('اتصال پنل متعلق به این فضای کاری نیست؛ ابتدا اتصال را اصلاح کنید.')
|
||||
customer.cuser_role.customer_panel = False
|
||||
customer.cuser_role.save(update_fields=['customer_panel'])
|
||||
return customer.cuser_role
|
||||
try:
|
||||
cuser_obj = CUser.objects.get(mobile=mobile)
|
||||
except CUser.DoesNotExist:
|
||||
@@ -36,29 +47,29 @@ def customer_panel_access(request, mobile: int, customer_id: int, status: bool)
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
try:
|
||||
cuser_role_obj = CuserRoleModel.objects.get(cuser=cuser_obj, role_type_id=3)
|
||||
cuser_role_obj.customer_panel = status
|
||||
cuser_role_obj.save()
|
||||
except CuserRoleModel.DoesNotExist:
|
||||
cuser_role_obj = CuserRoleModel.objects.create(
|
||||
cuser=cuser_obj,
|
||||
role_type_id=3,
|
||||
name_family=customer.name_family,
|
||||
mobile=mobile,
|
||||
mobile_verify=True,
|
||||
is_active=True,
|
||||
customer_panel=status,
|
||||
sys_ws=workspace
|
||||
)
|
||||
|
||||
if cuser_role_obj:
|
||||
customer.cuser_role = cuser_role_obj
|
||||
customer.save()
|
||||
return cuser_role_obj
|
||||
else:
|
||||
raise Exception
|
||||
|
||||
from apps.cuser.models import RoleType
|
||||
if not cuser_obj.is_active:
|
||||
raise exceptions.PermissionDenied('حساب کاربری مربوط به این شماره غیرفعال است.')
|
||||
role_type = RoleType.objects.get(role_name_en='workspace_customer')
|
||||
cuser_role_obj, _ = CuserRoleModel.objects.get_or_create(
|
||||
cuser=cuser_obj, role_type=role_type, sys_ws=workspace,
|
||||
defaults={'name_family': customer.name_family, 'mobile': mobile,
|
||||
'mobile_verify': True, 'is_active': True, 'customer_panel': True},
|
||||
)
|
||||
if CustomerModel.objects.filter(cuser_role=cuser_role_obj, sys_ws=workspace).exclude(pk=customer.pk).exists():
|
||||
raise exceptions.ValidationError({'mobile': 'این شماره قبلاً به پنل مشتری دیگری متصل شده است.'})
|
||||
cuser_role_obj.customer_panel = True
|
||||
cuser_role_obj.save(update_fields=['customer_panel'])
|
||||
customer.cuser_role = cuser_role_obj
|
||||
customer.full_clean()
|
||||
customer.save()
|
||||
return cuser_role_obj
|
||||
except exceptions.APIException:
|
||||
raise
|
||||
except DjangoValidationError as exp:
|
||||
raise exceptions.ValidationError(exp.message_dict)
|
||||
except IntegrityError:
|
||||
raise exceptions.ValidationError({'mobile': 'این شماره موبایل قبلاً ثبت شده است.'})
|
||||
except Exception as exp:
|
||||
message = f'Error Create or Update Customer panel accsess: {exp}'
|
||||
raise exceptions.ValidationError({'error': message})
|
||||
@@ -104,6 +115,7 @@ def delete_shive_ashenaei(shive_id: int, workspace_uuid: str) -> ShiveAshenaeiMo
|
||||
raise exceptions.ValidationError({'error': message})
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_customer(name_family: str , mobile: int, phone: int, address: str, city: int, shive_ashenaei: int,
|
||||
responsible_group: int, cuser_role: int = None, workspace: WorkspaceModel = None) -> CustomerModel:
|
||||
try:
|
||||
@@ -122,39 +134,48 @@ def create_customer(name_family: str , mobile: int, phone: int, address: str, ci
|
||||
customer_obj.full_clean()
|
||||
customer_obj.save()
|
||||
return customer_obj
|
||||
except DjangoValidationError as exp:
|
||||
raise exceptions.ValidationError(exp.message_dict)
|
||||
except IntegrityError:
|
||||
raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'})
|
||||
except Exception as exp:
|
||||
message = f'Error Creating Customer: {exp}'
|
||||
raise exceptions.ValidationError({'error': message})
|
||||
|
||||
|
||||
def update_customer(customer_id: int, name_family: str = None , mobile: int = None, phone: int = None, address: str = None,
|
||||
def set_customer_mobile(customer, mobile):
|
||||
if mobile is None:
|
||||
return
|
||||
normalized = normalize_mobile(mobile)
|
||||
if normalized != customer.mobile and customer.cuser_role_id:
|
||||
role = customer.cuser_role
|
||||
if role.sys_ws_id == customer.sys_ws_id:
|
||||
role.customer_panel = False
|
||||
role.save(update_fields=['customer_panel'])
|
||||
customer.cuser_role = None
|
||||
customer.mobile = normalized
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_customer(customer_id: int, name_family: str = None, mobile: int = None, phone: int = None, address: str = None,
|
||||
city: int = None, shive_ashenaei: int = None, responsible_group: int = None, cuser_role: int = None, workspace_uuid: str = None) -> CustomerModel:
|
||||
try:
|
||||
customer_obj = CustomerModel.objects.get(id=customer_id, sys_ws__uuid=workspace_uuid)
|
||||
|
||||
if name_family is not None:
|
||||
customer_obj.name_family = name_family
|
||||
if mobile is not None:
|
||||
customer_obj.mobile = mobile
|
||||
if phone is not None:
|
||||
customer_obj.phone = phone
|
||||
if address is not None:
|
||||
customer_obj.address = address
|
||||
if city is not None:
|
||||
customer_obj.city = city
|
||||
if shive_ashenaei is not None:
|
||||
customer_obj.shive_ashenaei = shive_ashenaei
|
||||
if responsible_group is not None:
|
||||
customer_obj.responsible_group = responsible_group
|
||||
if cuser_role is not None:
|
||||
customer_obj.cuser_role = cuser_role
|
||||
|
||||
customer_obj.full_clean()
|
||||
customer_obj.save()
|
||||
return customer_obj
|
||||
except Exception as exp:
|
||||
message = f'Error Creating Customer: {exp}'
|
||||
raise exceptions.ValidationError({'error': message})
|
||||
customer = CustomerModel.objects.select_for_update().get(id=customer_id, sys_ws__uuid=workspace_uuid)
|
||||
set_customer_mobile(customer, mobile)
|
||||
fields = {'name_family': name_family, 'phone': phone, 'address': address, 'city': city,
|
||||
'shive_ashenaei': shive_ashenaei, 'responsible_group': responsible_group, 'cuser_role': cuser_role}
|
||||
for field, value in fields.items():
|
||||
if value is not None:
|
||||
setattr(customer, field, value)
|
||||
customer.full_clean()
|
||||
customer.save()
|
||||
return customer
|
||||
except DjangoValidationError as error:
|
||||
raise exceptions.ValidationError(error.message_dict)
|
||||
except IntegrityError:
|
||||
raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'})
|
||||
except CustomerModel.DoesNotExist:
|
||||
raise exceptions.NotFound('مشتری در این فضای کاری پیدا نشد.')
|
||||
|
||||
|
||||
def delete_customer(customer_id: int, workspace_uuid: str = None) -> CustomerModel:
|
||||
@@ -165,6 +186,7 @@ def delete_customer(customer_id: int, workspace_uuid: str = None) -> CustomerMod
|
||||
raise exceptions.ValidationError({'error': message})
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_customer_v2(request, name_family: str , mobile: int, phone: int, address: str, city: int, shive_ashenaei: int,
|
||||
responsible_group: int, cuser_role: int = None) -> CustomerModel:
|
||||
try:
|
||||
@@ -186,6 +208,10 @@ def create_customer_v2(request, name_family: str , mobile: int, phone: int, addr
|
||||
customer_obj.full_clean()
|
||||
customer_obj.save()
|
||||
return customer_obj
|
||||
except DjangoValidationError as exp:
|
||||
raise exceptions.ValidationError(exp.message_dict)
|
||||
except IntegrityError:
|
||||
raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'})
|
||||
except Exception as exp:
|
||||
message = f'Error Creating Customer: {exp}'
|
||||
raise exceptions.ValidationError({'error': message})
|
||||
|
||||
@@ -13,6 +13,17 @@ from apps.workspace.models import City as CityModel
|
||||
from utils.tools import inline_model_serializer, inline_serializer
|
||||
from utils.pagination import CustomPagination
|
||||
from django_filters import rest_framework as filters
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from utils.validations.mobile import normalize_mobile
|
||||
|
||||
|
||||
class MobileField(serializers.CharField):
|
||||
def to_internal_value(self, data):
|
||||
value = super().to_internal_value(data)
|
||||
try:
|
||||
return normalize_mobile(value)
|
||||
except DjangoValidationError:
|
||||
raise serializers.ValidationError('شماره موبایل معتبر وارد کنید.')
|
||||
|
||||
|
||||
|
||||
@@ -87,6 +98,7 @@ class CustomerCreateApi(APIView):
|
||||
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission)
|
||||
|
||||
class InputSerializer(serializers.ModelSerializer):
|
||||
mobile = MobileField(required=False, allow_null=True)
|
||||
class Meta:
|
||||
model = CustomerModel
|
||||
fields = '__all__'
|
||||
@@ -114,6 +126,7 @@ class CustomerUpdateApi(APIView):
|
||||
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission)
|
||||
|
||||
class InputSerializer(serializers.ModelSerializer):
|
||||
mobile = MobileField(required=False, allow_null=True)
|
||||
class Meta:
|
||||
model = CustomerModel
|
||||
fields = '__all__'
|
||||
@@ -125,7 +138,7 @@ class CustomerUpdateApi(APIView):
|
||||
exclude = ('sys_ws', )
|
||||
|
||||
def patch(self, request, customer_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=request)
|
||||
@@ -215,7 +228,7 @@ class CustomerCreateApiV2(APIView):
|
||||
|
||||
class InputSerializer(serializers.Serializer):
|
||||
name_family = serializers.CharField(max_length=25, required=True)
|
||||
mobile = serializers.IntegerField(required=True)
|
||||
mobile = MobileField(required=True)
|
||||
phone = serializers.IntegerField(required=True)
|
||||
address = serializers.CharField(max_length=300, required=True)
|
||||
city = serializers.IntegerField(required=True)
|
||||
|
||||
@@ -22,6 +22,6 @@ class CustomerPanelApi(APIView):
|
||||
|
||||
cuser_role = customer_panel_access(request=request, **serializer.validated_data)
|
||||
|
||||
return Response(data={'cuser_role_id': cuser_role.id}, status=status.HTTP_200_OK)
|
||||
return Response(data={'cuser_role_id': cuser_role.id if cuser_role else None}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from utils.admin_base import CRMModelAdmin
|
||||
from django.contrib import admin
|
||||
from apps.customform.models import (
|
||||
CustomForm as CustomFormModel,
|
||||
@@ -7,16 +8,28 @@ from apps.customform.models import (
|
||||
# Register your models here.
|
||||
|
||||
|
||||
class CustomFormFieldInline(admin.TabularInline):
|
||||
model = CustomFormFieldModel
|
||||
extra = 0
|
||||
fields = ["field_name_fa","field_name_en","validator"]
|
||||
readonly_fields = fields
|
||||
can_delete = False
|
||||
show_change_link = True
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(CustomFormModel)
|
||||
class CustomFormAdmin(admin.ModelAdmin):
|
||||
class CustomFormAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(CustomFormDataModel)
|
||||
class CustomFormDataAdmin(admin.ModelAdmin):
|
||||
class CustomFormDataAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(CustomFormFieldModel)
|
||||
class CustomFormFieldAdmin(admin.ModelAdmin):
|
||||
class CustomFormFieldAdmin(CRMModelAdmin):
|
||||
pass
|
||||
+34
-12
@@ -1,17 +1,39 @@
|
||||
from django.contrib import admin
|
||||
from apps.jwt_auth.models import (
|
||||
JwtToken as JwtTokenModel,
|
||||
CUserRoleLoginOpt as CUserRoleLoginOptModel,
|
||||
)
|
||||
|
||||
# Register your models here.
|
||||
from apps.jwt_auth.models import JwtToken, CUserRoleLoginOpt
|
||||
from utils.admin_base import CRMModelAdmin
|
||||
|
||||
|
||||
@admin.register(JwtTokenModel)
|
||||
class JwtTokenAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
@admin.register(JwtToken)
|
||||
class JwtTokenAdmin(CRMModelAdmin):
|
||||
list_display = ('id', 'cuser', 'cuser_role', 'expire', 'is_blocked')
|
||||
list_filter = ('is_blocked', 'cuser_role__sys_ws')
|
||||
search_fields = ('cuser__username', 'cuser__mobile')
|
||||
exclude = ('refresh', 'access')
|
||||
sensitive_fields = ('refresh', 'access')
|
||||
readonly_fields = ('cuser', 'cuser_role', 'expire', 'is_blocked')
|
||||
actions = ('revoke_sessions', 'export_selected')
|
||||
|
||||
@admin.action(description='ابطال نشستهای انتخابشده', permissions=['change'])
|
||||
def revoke_sessions(self, request, queryset):
|
||||
count = queryset.update(is_blocked=True)
|
||||
for obj in queryset:
|
||||
self.log_change(request, obj, 'نشست از پنل مدیریت ابطال شد.')
|
||||
self.message_user(request, f'{count} نشست ابطال شد.')
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(CUserRoleLoginOptModel)
|
||||
class CUserRoleLoginOptAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
@admin.register(CUserRoleLoginOpt)
|
||||
class CUserRoleLoginOptAdmin(CRMModelAdmin):
|
||||
list_display = ('id', 'cuser', 'cuser_role', 'expire')
|
||||
search_fields = ('cuser__username', 'cuser__mobile')
|
||||
exclude = ('uuid',)
|
||||
sensitive_fields = ('uuid',)
|
||||
readonly_fields = ('cuser', 'cuser_role', 'expire')
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
@@ -0,0 +1,19 @@
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
from apps.jwt_auth.models import JwtToken
|
||||
from apps.jwt_auth.session import validate_session_claims
|
||||
|
||||
|
||||
class SessionJWTAuthentication(JWTAuthentication):
|
||||
"""A signed JWT is usable only while its recorded session remains active."""
|
||||
|
||||
def get_user(self, validated_token):
|
||||
user = super().get_user(validated_token)
|
||||
session = JwtToken.objects.select_related('cuser_role__role_type', 'cuser_role__sys_ws', 'cuser_role__cuser').filter(
|
||||
access=str(validated_token), cuser=user, is_blocked=False,
|
||||
).first()
|
||||
if not session:
|
||||
raise AuthenticationFailed('نشست شما معتبر نیست. لطفاً دوباره وارد شوید.', code='session_invalid')
|
||||
validate_session_claims(session, validated_token)
|
||||
return user
|
||||
@@ -0,0 +1,12 @@
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('jwt_auth', '0092_alter_cuserroleloginopt_uuid')]
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='cuserroleloginopt', name='uuid',
|
||||
field=models.UUIDField(default=uuid.uuid4, unique=True),
|
||||
),
|
||||
]
|
||||
@@ -22,4 +22,4 @@ class CUserRoleLoginOpt(models.Model):
|
||||
cuser_role = models.ForeignKey(CuserRoleModel, on_delete=models.CASCADE)
|
||||
cuser = models.ForeignKey(CUser, null=True, blank=True, default=None, on_delete=models.CASCADE)
|
||||
expire = models.DateTimeField(null=False, blank=False)
|
||||
uuid = models.UUIDField(default=uuid.uuid4(), unique=True, null=False, blank=False)
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, null=False, blank=False)
|
||||
+87
-98
@@ -1,110 +1,99 @@
|
||||
import uuid
|
||||
import random
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.jwt_auth.models import CUserRoleLoginOpt
|
||||
from apps.cuser.models import CuserRole as CuserRoleModel, SmsOpt as SmsOptModel
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework import exceptions
|
||||
import requests
|
||||
from datetime import timedelta
|
||||
|
||||
from apps.cuser.models import CuserRole, SmsOpt
|
||||
from apps.jwt_auth.models import CUserRoleLoginOpt
|
||||
from utils.services.sms import DefaultSmsProvider
|
||||
from utils.validations.mobile import normalize_mobile
|
||||
|
||||
CUser = get_user_model()
|
||||
|
||||
def login_by_username(username: str, password: str) -> CUserRoleLoginOpt:
|
||||
|
||||
class SmsDeliveryUnavailable(exceptions.APIException):
|
||||
status_code = 503
|
||||
default_detail = 'ارسال پیامک انجام نشد. لطفاً کمی بعد دوباره تلاش کنید.'
|
||||
default_code = 'sms_failed'
|
||||
|
||||
|
||||
def eligible_roles(user, customer_only=False):
|
||||
roles = CuserRole.objects.select_related('role_type', 'sys_ws', 'cuser').filter(
|
||||
cuser=user, is_active=True, mobile_verify=True, sys_ws__is_active=True,
|
||||
)
|
||||
if customer_only:
|
||||
roles = roles.filter(role_type__role_name_en='workspace_customer', customer_panel=True)
|
||||
return [role for role in roles if
|
||||
(not role.sys_ws.expire or role.sys_ws.expire > timezone.now()) and
|
||||
(role.role_type.role_name_en != 'workspace_customer' or role.customer_panel is True)]
|
||||
|
||||
|
||||
def create_login_options(user, roles):
|
||||
CUserRoleLoginOpt.objects.filter(cuser=user).delete()
|
||||
options = []
|
||||
for role in roles:
|
||||
options.append(CUserRoleLoginOpt.objects.create(
|
||||
cuser=user, cuser_role=role, expire=timezone.now() + timedelta(minutes=5),
|
||||
))
|
||||
return options
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def login_by_username(username: str, password: str):
|
||||
user = CUser.objects.select_for_update().filter(username=username).first()
|
||||
if not user or not user.check_password(password):
|
||||
raise exceptions.ValidationError({'error': 'نام کاربری یا رمز عبور اشتباه است.'})
|
||||
if not user.is_active:
|
||||
raise exceptions.PermissionDenied('حساب کاربری شما غیرفعال است.')
|
||||
roles = eligible_roles(user)
|
||||
if not roles:
|
||||
raise exceptions.PermissionDenied('نقش یا دسترسی فعال برای این حساب وجود ندارد.')
|
||||
return create_login_options(user, roles)
|
||||
|
||||
|
||||
def mobile_user(mobile):
|
||||
try:
|
||||
print(username)
|
||||
user = CUser.objects.get(username=username)
|
||||
|
||||
if user and user.check_password(password):
|
||||
|
||||
cuser_roles = CuserRoleModel.objects.filter(cuser=user)
|
||||
CUserRoleLoginOpt.objects.filter(cuser_role__cuser=user).delete()
|
||||
|
||||
if cuser_roles:
|
||||
for user_role_obj in cuser_roles:
|
||||
|
||||
if not (user_role_obj.is_active and user_role_obj.mobile_verify):
|
||||
raise exceptions.ValidationError({"error": 'Not a valid user'})
|
||||
|
||||
expire_datetime = timezone.datetime.now() + timezone.timedelta(minutes=5)
|
||||
|
||||
user_role_opt = CUserRoleLoginOpt()
|
||||
user_role_opt.cuser_role = user_role_obj
|
||||
user_role_opt.cuser = user_role_obj.cuser
|
||||
user_role_opt.expire = expire_datetime
|
||||
user_role_opt.uuid = uuid.uuid4()
|
||||
user_role_opt.save()
|
||||
|
||||
login_opts = CUserRoleLoginOpt.objects.filter(cuser_role__cuser=user)
|
||||
return login_opts
|
||||
|
||||
else:
|
||||
raise exceptions.ValidationError({'error': "User not have role"})
|
||||
else:
|
||||
raise exceptions.ValidationError({'error': "Username or password is not correct"})
|
||||
|
||||
except CUser.DoesNotExist:
|
||||
raise exceptions.ValidationError({'error': "Username or password is not correct"})
|
||||
mobile = normalize_mobile(mobile)
|
||||
except DjangoValidationError as error:
|
||||
raise exceptions.ValidationError(error.message_dict)
|
||||
user = CUser.objects.filter(mobile=mobile).first()
|
||||
if not user:
|
||||
raise exceptions.ValidationError({'mobile': 'کاربری با این شماره موبایل ثبت نشده است.'})
|
||||
if not user.is_active:
|
||||
raise exceptions.PermissionDenied('حساب کاربری شما غیرفعال است.')
|
||||
roles = eligible_roles(user, customer_only=True)
|
||||
if not roles:
|
||||
raise exceptions.PermissionDenied('دسترسی شما به پنل مشتریان فعال نیست.')
|
||||
return user, roles
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def login_by_mobile(mobile: int):
|
||||
try:
|
||||
|
||||
exists_cuser = CUser.objects.get(mobile=mobile)
|
||||
|
||||
random_number = random.randint(100000, 999999)
|
||||
user, _ = mobile_user(mobile)
|
||||
# Serialize code requests for this account, retaining the old valid code if
|
||||
# the provider refuses the new message.
|
||||
CUser.objects.select_for_update().get(pk=user.pk)
|
||||
code = secrets.randbelow(900000) + 100000
|
||||
if not DefaultSmsProvider().send_sms(str(user.mobile), f'کد ورود شما: {code}'):
|
||||
raise SmsDeliveryUnavailable()
|
||||
SmsOpt.objects.filter(mobile=user.mobile).delete()
|
||||
SmsOpt.objects.create(mobile=user.mobile, opt=code, expire=timezone.now() + timedelta(minutes=3))
|
||||
return user.mobile
|
||||
|
||||
|
||||
try:
|
||||
SmsOptModel.objects.filter(mobile=mobile).delete()
|
||||
except Exception as exp:
|
||||
pass
|
||||
|
||||
expire_time = timezone.now() + timedelta(minutes=3)
|
||||
opt_code = SmsOptModel(mobile=mobile, opt=random_number, expire=expire_time)
|
||||
opt_code.save()
|
||||
|
||||
sms_text = f' کد ورود شما: {random_number}'
|
||||
|
||||
payload = {
|
||||
'USERNAME': 'vsms7937',
|
||||
'PASSWORD': '335577775533',
|
||||
'FROM': '30005367',
|
||||
'TO': int(mobile),
|
||||
'TEXT': f'{sms_text}'
|
||||
}
|
||||
r = requests.get('http://www.0098sms.com/sendsmslink.aspx', params=payload)
|
||||
|
||||
except Exception as exp:
|
||||
raise exceptions.ValidationError({'error': "mobile not found"})
|
||||
|
||||
|
||||
def verify_mobile_and_get_token(mobile: int, opt: int) -> CUserRoleLoginOpt:
|
||||
try:
|
||||
time_now = timezone.now()
|
||||
opt_obj = SmsOptModel.objects.filter(mobile=mobile, opt=opt, expire__gte=time_now)[0]
|
||||
|
||||
if opt_obj:
|
||||
cuser_roles = CuserRoleModel.objects.filter(cuser__mobile=mobile, is_active=True)
|
||||
CUserRoleLoginOpt.objects.filter(cuser_role__in=cuser_roles).delete()
|
||||
|
||||
if cuser_roles:
|
||||
for user_role_obj in cuser_roles:
|
||||
expire_datetime = timezone.datetime.now() + timezone.timedelta(minutes=5)
|
||||
|
||||
user_role_opt = CUserRoleLoginOpt()
|
||||
user_role_opt.cuser_role = user_role_obj
|
||||
user_role_opt.cuser = user_role_obj.cuser
|
||||
user_role_opt.expire = expire_datetime
|
||||
user_role_opt.uuid = uuid.uuid4()
|
||||
user_role_opt.save()
|
||||
|
||||
login_opts = CUserRoleLoginOpt.objects.filter(cuser_role__in=cuser_roles)
|
||||
SmsOptModel.objects.filter(mobile=mobile).delete()
|
||||
return login_opts
|
||||
except Exception as exp:
|
||||
try:
|
||||
SmsOptModel.objects.filter(mobile=mobile).delete()
|
||||
except Exception as exp:
|
||||
pass
|
||||
raise exceptions.ValidationError({'error': "cannot verify this mobile and opt code:"+str(exp)})
|
||||
@transaction.atomic
|
||||
def verify_mobile_and_get_token(mobile: int, opt: int):
|
||||
user, roles = mobile_user(mobile)
|
||||
CUser.objects.select_for_update().get(pk=user.pk)
|
||||
code = SmsOpt.objects.select_for_update().filter(
|
||||
mobile=user.mobile, opt=opt, expire__gt=timezone.now(),
|
||||
).first()
|
||||
if not code:
|
||||
raise exceptions.ValidationError({'opt': 'کد تأیید اشتباه است یا اعتبار آن تمام شده است.'})
|
||||
options = create_login_options(user, roles)
|
||||
SmsOpt.objects.filter(mobile=user.mobile).delete()
|
||||
return options
|
||||
@@ -0,0 +1,49 @@
|
||||
from datetime import datetime, timezone as datetime_timezone
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from apps.jwt_auth.models import JwtToken
|
||||
|
||||
|
||||
def validate_role_access(role):
|
||||
if not (role.cuser and role.cuser.is_active and role.is_active and role.mobile_verify):
|
||||
raise AuthenticationFailed('حساب کاربری یا نقش شما غیرفعال است. دوباره وارد شوید.', code='session_invalid')
|
||||
workspace = role.sys_ws
|
||||
if not workspace or not workspace.is_active or (workspace.expire and workspace.expire <= timezone.now()):
|
||||
raise AuthenticationFailed('دسترسی فضای کاری غیرفعال یا منقضی شده است.', code='session_invalid')
|
||||
if role.role_type.role_name_en == 'workspace_customer' and role.customer_panel is not True:
|
||||
raise AuthenticationFailed('دسترسی شما به پنل مشتریان فعال نیست.', code='session_invalid')
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def issue_tokens(role, session=None):
|
||||
validate_role_access(role)
|
||||
if session is None:
|
||||
session = JwtToken.objects.create(cuser=role.cuser, cuser_role=role, refresh='')
|
||||
refresh = RefreshToken.for_user(role.cuser)
|
||||
refresh['workspace_uuid'] = str(role.sys_ws.uuid)
|
||||
refresh['cuser_role_uuid'] = str(role.uuid)
|
||||
refresh['user_uuid'] = str(role.cuser.uuid)
|
||||
refresh['session_id'] = session.pk
|
||||
data = {'refresh': str(refresh), 'access': str(refresh.access_token)}
|
||||
session.refresh = data['refresh']
|
||||
session.access = data['access']
|
||||
session.expire = datetime.fromtimestamp(refresh['exp'], tz=datetime_timezone.utc)
|
||||
session.save()
|
||||
return data
|
||||
|
||||
|
||||
def validate_session_claims(session, token):
|
||||
if session.expire and session.expire <= timezone.now():
|
||||
raise AuthenticationFailed('نشست شما منقضی شده است. دوباره وارد شوید.', code='session_invalid')
|
||||
role = session.cuser_role
|
||||
validate_role_access(role)
|
||||
expected = {'user_id': session.cuser_id, 'user_uuid': str(role.cuser.uuid),
|
||||
'cuser_role_uuid': str(role.uuid), 'workspace_uuid': str(role.sys_ws.uuid)}
|
||||
if token.get('session_id') is not None:
|
||||
expected['session_id'] = session.pk
|
||||
if role.cuser_id != session.cuser_id or any(token.get(key) != value for key, value in expected.items()):
|
||||
raise AuthenticationFailed('نشست شما معتبر نیست. دوباره وارد شوید.', code='session_invalid')
|
||||
@@ -0,0 +1,453 @@
|
||||
"""Authentication, duplicate mobiles and ordering integration regressions."""
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.contrib import admin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
from openpyxl import load_workbook
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from apps.cuser.cuser_model import CUser
|
||||
from apps.cuser.models import CuserRole, RoleType, SmsOpt
|
||||
from apps.customer.models import Customer, ResponsibleGroup, ShiveAshenaei
|
||||
from apps.jwt_auth.models import CUserRoleLoginOpt, JwtToken
|
||||
from apps.jwt_auth.session import issue_tokens
|
||||
from apps.shop.admin import ProductAdmin, ProductCategoryAdmin, excel_response
|
||||
from apps.shop.models import Product, ProductCategory, Order, OrderItem
|
||||
from apps.workspace.models import City, Workspace
|
||||
from utils.services.sms import DosefrNavadHashtSMS
|
||||
from utils.validations.mobile import normalize_mobile
|
||||
|
||||
|
||||
class RegressionFixtures(APITestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.workspace = Workspace.objects.create(name='Test', username='test')
|
||||
cls.other_workspace = Workspace.objects.create(name='Other', username='other')
|
||||
cls.user = CUser.objects.create_user('client', 'password', mobile=9123456789, name_family='Client', is_active=True)
|
||||
cls.customer_type = RoleType.objects.create(role_name='Customer', role_name_en='workspace_customer')
|
||||
cls.operator_type = RoleType.objects.create(role_name='Operator', role_name_en='workspace_operator')
|
||||
cls.admin_type = RoleType.objects.create(role_name='Admin', role_name_en='workspace_admin')
|
||||
cls.role = CuserRole.objects.create(cuser=cls.user, role_type=cls.customer_type, sys_ws=cls.workspace,
|
||||
name_family='Client', mobile=cls.user.mobile, is_active=True,
|
||||
mobile_verify=True, customer_panel=True)
|
||||
cls.operator = CuserRole.objects.create(cuser=cls.user, role_type=cls.operator_type, sys_ws=cls.workspace,
|
||||
name_family='Operator', mobile=cls.user.mobile, is_active=True, mobile_verify=True)
|
||||
cls.customer = Customer.objects.create(name_family='Client', mobile=cls.user.mobile, cuser_role=cls.role, sys_ws=cls.workspace)
|
||||
cls.city = City.objects.create(name='City')
|
||||
cls.group = ResponsibleGroup.objects.create(name='Group', sys_ws=cls.workspace)
|
||||
cls.shive = ShiveAshenaei.objects.create(name='Web', sys_ws=cls.workspace)
|
||||
|
||||
def use_role(self, role=None):
|
||||
tokens = issue_tokens(role or self.role)
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + tokens['access'])
|
||||
return tokens
|
||||
|
||||
def post(self, path, data):
|
||||
return self.client.post('/auth/' + path, data, format='json')
|
||||
|
||||
def otp(self, expired=False):
|
||||
return SmsOpt.objects.create(mobile=self.user.mobile, opt=123456,
|
||||
expire=timezone.now() + timedelta(minutes=-1 if expired else 3))
|
||||
|
||||
|
||||
class AuthRegressionTests(RegressionFixtures):
|
||||
def test_unknown_mobile_has_useful_400(self):
|
||||
response = self.post('login/mobile/', {'mobile': '09999999999'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('ثبت نشده', str(response.data))
|
||||
|
||||
def test_invalid_mobile_has_400(self):
|
||||
self.assertEqual(self.post('login/mobile/', {'mobile': '123'}).status_code, 400)
|
||||
|
||||
def test_disabled_customer_panel_has_403_and_sends_no_sms(self):
|
||||
self.role.customer_panel = False
|
||||
self.role.save()
|
||||
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms') as send:
|
||||
response = self.post('login/mobile/', {'mobile': self.user.mobile})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn('پنل مشتریان', str(response.data))
|
||||
send.assert_not_called()
|
||||
|
||||
def test_inactive_user_has_403(self):
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 403)
|
||||
|
||||
def test_sms_failure_has_503_and_preserves_old_otp(self):
|
||||
old = self.otp()
|
||||
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=False):
|
||||
response = self.post('login/mobile/', {'mobile': self.user.mobile})
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertIn('ارسال پیامک', str(response.data))
|
||||
self.assertTrue(SmsOpt.objects.filter(pk=old.pk).exists())
|
||||
|
||||
def test_successful_sms_saves_only_one_code(self):
|
||||
self.otp()
|
||||
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True):
|
||||
response = self.post('login/mobile/', {'mobile': '+98 912-345-6789'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['result']['mobile'], self.user.mobile)
|
||||
self.assertEqual(SmsOpt.objects.count(), 1)
|
||||
self.assertGreaterEqual(SmsOpt.objects.get().opt, 100000)
|
||||
|
||||
def test_wrong_otp_preserves_valid_code(self):
|
||||
old = self.otp()
|
||||
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 654321}).status_code, 400)
|
||||
self.assertTrue(SmsOpt.objects.filter(pk=old.pk).exists())
|
||||
|
||||
def test_expired_otp_rejected(self):
|
||||
self.otp(expired=True)
|
||||
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 123456}).status_code, 400)
|
||||
|
||||
def test_otp_is_single_use_and_roles_have_distinct_options(self):
|
||||
CuserRole.objects.create(cuser=self.user, role_type=self.customer_type, sys_ws=self.other_workspace,
|
||||
name_family='Other', mobile=self.user.mobile, is_active=True, mobile_verify=True, customer_panel=True)
|
||||
self.otp()
|
||||
response = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 123456})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertNotEqual(response.data[0]['uuid'], response.data[1]['uuid'])
|
||||
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 123456}).status_code, 400)
|
||||
|
||||
def test_bad_password_has_400(self):
|
||||
self.assertEqual(self.post('login/username/', {'username': 'client', 'password': 'wrong'}).status_code, 400)
|
||||
|
||||
def test_public_login_ignores_stale_authorization(self):
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Bearer invalid')
|
||||
response = self.post('login/username/', {'username': 'client', 'password': 'password'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
def test_option_expiry_and_replay_have_401(self):
|
||||
option = CUserRoleLoginOpt.objects.create(cuser=self.user, cuser_role=self.role, expire=timezone.now()-timedelta(seconds=1))
|
||||
self.assertEqual(self.post('token/', {'uuid_opt': str(option.uuid)}).status_code, 401)
|
||||
option.expire = timezone.now()+timedelta(minutes=5)
|
||||
option.save()
|
||||
result = self.post('token/', {'uuid_opt': str(option.uuid)})
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(self.post('token/', {'uuid_opt': str(option.uuid)}).status_code, 401)
|
||||
self.assertEqual(JwtToken.objects.count(), 1)
|
||||
|
||||
def test_anonymous_info_has_401(self):
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
||||
|
||||
def test_signed_but_unrecorded_access_is_rejected(self):
|
||||
refresh = RefreshToken.for_user(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + str(refresh.access_token))
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
||||
|
||||
def test_blocked_access_and_refresh_are_rejected(self):
|
||||
tokens = self.use_role()
|
||||
JwtToken.objects.update(is_blocked=True)
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
||||
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
|
||||
|
||||
def test_missing_session_rejects_access_and_refresh(self):
|
||||
tokens = self.use_role()
|
||||
JwtToken.objects.all().delete()
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
||||
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
|
||||
|
||||
def test_refresh_rotates_and_retains_workspace(self):
|
||||
tokens = self.use_role(self.operator)
|
||||
response = self.post('token/refresh/', {'refresh': tokens['refresh']})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotEqual(response.data['refresh'], tokens['refresh'])
|
||||
self.assertEqual(RefreshToken(response.data['refresh'])['workspace_uuid'], str(self.workspace.uuid))
|
||||
self.assertEqual(JwtToken.objects.count(), 1)
|
||||
self.assertGreater(JwtToken.objects.get().expire, timezone.now())
|
||||
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + response.data['access'])
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 200)
|
||||
|
||||
def test_disabling_panel_revokes_both_tokens(self):
|
||||
tokens = self.use_role()
|
||||
self.role.customer_panel = False
|
||||
self.role.save()
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
||||
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
|
||||
|
||||
def test_expired_workspace_revokes_both_tokens(self):
|
||||
tokens = self.use_role()
|
||||
self.workspace.expire = timezone.now()-timedelta(seconds=1)
|
||||
self.workspace.save()
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
||||
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
|
||||
|
||||
def test_logout_revokes_session_without_valid_access(self):
|
||||
tokens = self.use_role()
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Bearer expired')
|
||||
self.assertEqual(self.post('logout/', {'refresh': tokens['refresh']}).status_code, 204)
|
||||
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + tokens['access'])
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
||||
self.assertEqual(self.post('logout/', {'refresh': tokens['refresh']}).status_code, 204)
|
||||
|
||||
def test_user_duplicate_formats_are_rejected(self):
|
||||
for mobile in ['09123456789', '+98 912-345-6789', '۰۹۱۲۳۴۵۶۷۸۹', '00989123456789']:
|
||||
with self.subTest(mobile=mobile), self.assertRaises(ValidationError):
|
||||
CUser.objects.create_user('duplicate', 'password', mobile=mobile)
|
||||
self.assertEqual(CUser.objects.count(), 1)
|
||||
|
||||
def test_customer_duplicate_has_database_constraint(self):
|
||||
with self.assertRaises(IntegrityError), transaction.atomic():
|
||||
Customer.objects.create(name_family='Duplicate', mobile=self.user.mobile, sys_ws=self.workspace)
|
||||
Customer.objects.create(name_family='Other', mobile=self.user.mobile, sys_ws=self.other_workspace)
|
||||
Customer.objects.create(name_family='Null1', sys_ws=self.workspace)
|
||||
Customer.objects.create(name_family='Null2', sys_ws=self.workspace)
|
||||
|
||||
def test_create_customer_v2_duplicate_has_mobile_error(self):
|
||||
self.use_role(self.operator)
|
||||
response = self.client.post('/customer/v2/create/', {
|
||||
'name_family': 'Duplicate', 'mobile': '+98 912-345-6789', 'phone': 2133333333,
|
||||
'address': 'Address', 'city': self.city.pk, 'shive_ashenaei': self.shive.pk, 'responsible_group': self.group.pk,
|
||||
}, format='json')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('mobile', response.data)
|
||||
self.assertEqual(Customer.objects.count(), 1)
|
||||
|
||||
def test_partial_customer_update_rejects_duplicate_and_allows_same_mobile(self):
|
||||
other = Customer.objects.create(name_family='Other', mobile=9333333333, sys_ws=self.workspace)
|
||||
self.use_role(self.operator)
|
||||
response = self.client.patch(f'/customer/update/{other.pk}/', {'mobile': '09123456789'}, format='json')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('mobile', response.data)
|
||||
response = self.client.patch(f'/customer/update/{self.customer.pk}/', {'mobile': self.user.mobile}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_panel_link_is_scoped_to_workspace(self):
|
||||
other_operator = CuserRole.objects.create(cuser=self.user, role_type=self.operator_type, sys_ws=self.other_workspace,
|
||||
name_family='Operator2', mobile=self.user.mobile, is_active=True, mobile_verify=True)
|
||||
other_customer = Customer.objects.create(name_family='Other', mobile=self.user.mobile, sys_ws=self.other_workspace)
|
||||
self.use_role(other_operator)
|
||||
response = self.client.post('/customer/customer-panel/', {'customer_id': other_customer.pk, 'mobile': self.user.mobile, 'status': True}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
other_customer.refresh_from_db()
|
||||
self.assertNotEqual(other_customer.cuser_role_id, self.role.pk)
|
||||
self.assertEqual(other_customer.cuser_role.sys_ws_id, self.other_workspace.pk)
|
||||
|
||||
def test_disable_panel_without_link_does_not_create_user(self):
|
||||
other = Customer.objects.create(name_family='Other', mobile=9333333333, sys_ws=self.workspace)
|
||||
self.use_role(self.operator)
|
||||
response = self.client.post('/customer/customer-panel/', {'customer_id': other.pk, 'mobile': other.mobile, 'status': False}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.data['cuser_role_id'])
|
||||
self.assertEqual(CUser.objects.count(), 1)
|
||||
|
||||
def test_provider_checks_body_and_handles_timeout(self):
|
||||
import requests
|
||||
with patch('utils.services.sms.requests.get', return_value=Mock(text='0')) as send:
|
||||
self.assertTrue(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test'))
|
||||
self.assertEqual(send.call_args.kwargs['timeout'], 10)
|
||||
with patch('utils.services.sms.requests.get', return_value=Mock(text='error')):
|
||||
self.assertFalse(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test'))
|
||||
with patch('utils.services.sms.requests.get', side_effect=requests.Timeout):
|
||||
self.assertFalse(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test'))
|
||||
|
||||
def test_iranian_mobile_prefix_is_not_accidentally_removed(self):
|
||||
self.assertEqual(normalize_mobile('9891234567'), 9891234567)
|
||||
|
||||
def test_logout_during_refresh_rotation_revokes_new_tokens(self):
|
||||
tokens = self.use_role()
|
||||
rotated = self.post('token/refresh/', {'refresh': tokens['refresh']})
|
||||
self.assertEqual(rotated.status_code, 200)
|
||||
self.assertEqual(self.post('logout/', {'refresh': tokens['refresh']}).status_code, 204)
|
||||
self.assertEqual(self.post('token/refresh/', {'refresh': rotated.data['refresh']}).status_code, 401)
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + rotated.data['access'])
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
||||
|
||||
def test_mobile_change_disconnects_old_panel_and_revokes_old_session(self):
|
||||
tokens = issue_tokens(self.role)
|
||||
self.use_role(self.operator)
|
||||
response = self.client.patch(f'/customer/update/{self.customer.pk}/', {'mobile': '09333333333'}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.customer.refresh_from_db()
|
||||
self.role.refresh_from_db()
|
||||
self.assertIsNone(self.customer.cuser_role_id)
|
||||
self.assertFalse(self.role.customer_panel)
|
||||
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
|
||||
|
||||
def test_duplicate_migration_audits_before_changing_any_data(self):
|
||||
import importlib
|
||||
from django.apps import apps
|
||||
from django.db import connection
|
||||
duplicate = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
|
||||
migration = importlib.import_module('apps.customer.migrations.0008_customer_mobile_unique')
|
||||
with self.assertRaises(RuntimeError):
|
||||
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
|
||||
duplicate.refresh_from_db()
|
||||
self.assertEqual(duplicate.mobile, 989123456789)
|
||||
|
||||
def test_migration_normalizes_legacy_country_prefix(self):
|
||||
import importlib
|
||||
from django.apps import apps
|
||||
from django.db import connection
|
||||
legacy = Customer.objects.create(name_family='Legacy', mobile=989333333333, sys_ws=self.workspace)
|
||||
migration = importlib.import_module('apps.customer.migrations.0008_customer_mobile_unique')
|
||||
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
|
||||
legacy.refresh_from_db()
|
||||
self.assertEqual(legacy.mobile, 9333333333)
|
||||
|
||||
def test_customer_excel_import_reports_duplicate_and_rolls_back_all_rows(self):
|
||||
import pandas as pd
|
||||
self.city.ostan = City.objects.create(name='Province', is_ostan=True)
|
||||
self.city.save()
|
||||
role = CuserRole.objects.create(cuser=self.user, role_type=self.admin_type, sys_ws=self.workspace,
|
||||
name_family='Admin', mobile=self.user.mobile, is_active=True, mobile_verify=True)
|
||||
self.use_role(role)
|
||||
rows = [{'name': 'New', 'mobile': 9333333333, 'phone': None, 'address': None,
|
||||
'ostan': 'Province', 'shar': 'City', 'id_mantaghe': self.group.pk},
|
||||
{'name': 'Duplicate', 'mobile': self.user.mobile, 'phone': None, 'address': None,
|
||||
'ostan': 'Province', 'shar': 'City', 'id_mantaghe': self.group.pk}]
|
||||
with patch('apps.shop.views.excel_import.pd.read_excel', return_value=pd.DataFrame(rows)):
|
||||
response = self.client.get('/shop/import/customer/')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('mobile', response.data['errors'])
|
||||
self.assertEqual(Customer.objects.count(), 1)
|
||||
|
||||
def test_anonymous_import_requests_are_rejected(self):
|
||||
self.assertEqual(self.client.get('/shop/import/customer/').status_code, 401)
|
||||
self.assertEqual(self.client.get('/shop/import/').status_code, 401)
|
||||
|
||||
def test_recorded_session_expiry_is_enforced(self):
|
||||
tokens = self.use_role()
|
||||
JwtToken.objects.update(expire=timezone.now()-timedelta(seconds=1))
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
||||
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
|
||||
|
||||
def test_inactive_role_and_workspace_reject_access(self):
|
||||
self.use_role()
|
||||
self.role.is_active = False
|
||||
self.role.save()
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
||||
self.role.is_active = True
|
||||
self.role.save()
|
||||
self.workspace.is_active = False
|
||||
self.workspace.save()
|
||||
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
|
||||
|
||||
|
||||
def test_admin_user_creation_hashes_password_and_normalizes_mobile(self):
|
||||
self.user.is_staff = True
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post('/admin/cuser/cuser/add/', {
|
||||
'username': 'new-user', 'name_family': 'New User', 'mobile': '+98 933-333-3333',
|
||||
'password1': 'Valid-Password-9320!', 'password2': 'Valid-Password-9320!',
|
||||
'is_active': 'on', 'is_staff': 'on', '_save': 'Save',
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
user = CUser.objects.get(username='new-user')
|
||||
self.assertEqual(user.mobile, 9333333333)
|
||||
self.assertTrue(user.check_password('Valid-Password-9320!'))
|
||||
self.assertNotEqual(user.password, 'Valid-Password-9320!')
|
||||
|
||||
def test_admin_duplicate_user_mobile_is_a_form_error(self):
|
||||
self.user.is_staff = True
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post('/admin/cuser/cuser/add/', {
|
||||
'username': 'duplicate', 'mobile': '+98 912-345-6789',
|
||||
'password1': 'Valid-Password-9320!', 'password2': 'Valid-Password-9320!', '_save': 'Save',
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('mobile', response.context['adminform'].form.errors)
|
||||
self.assertEqual(CUser.objects.count(), 1)
|
||||
|
||||
|
||||
class OrderingRegressionTests(RegressionFixtures):
|
||||
def setUp(self):
|
||||
self.first = ProductCategory.objects.create(name='First', sys_ws=self.workspace, order_cat=1, excel_order=20)
|
||||
self.second = ProductCategory.objects.create(name='Second', sys_ws=self.workspace, order_cat=2, excel_order=10)
|
||||
self.unordered = ProductCategory.objects.create(name='Unordered', sys_ws=self.workspace)
|
||||
self.a = Product.objects.create(name='A', category=self.first, sys_ws=self.workspace, price=10, order_prd=2, excel_order=1, attr={'acc_vahed': 'Unit'})
|
||||
self.b = Product.objects.create(name='B', category=self.first, sys_ws=self.workspace, price=20, order_prd=1, excel_order=2)
|
||||
self.c = Product.objects.create(name='C', category=self.second, sys_ws=self.workspace, price=30, order_prd=1)
|
||||
|
||||
def test_display_order_groups_categories_and_products(self):
|
||||
self.assertEqual(list(ProductCategory.objects.values_list('name', flat=True)), ['First', 'Second', 'Unordered'])
|
||||
self.assertEqual(list(Product.objects.values_list('name', flat=True)), ['B', 'A', 'C'])
|
||||
|
||||
def test_admin_excel_independent_order_and_current_column_order(self):
|
||||
model_admin = ProductAdmin(Product, admin.site)
|
||||
sheet = load_workbook(BytesIO(model_admin.export_excel_order(None, Product.objects.all()).content)).active
|
||||
self.assertEqual([row[2] for row in list(sheet.values)[1:]], ['C', 'A', 'B'])
|
||||
sheet = load_workbook(BytesIO(model_admin.export_display_order(None, Product.objects.order_by('-name')).content)).active
|
||||
self.assertEqual([row[2] for row in list(sheet.values)[1:]], ['C', 'B', 'A'])
|
||||
category_admin = ProductCategoryAdmin(ProductCategory, admin.site)
|
||||
sheet = load_workbook(BytesIO(category_admin.export_excel_order(None, ProductCategory.objects.all()).content)).active
|
||||
self.assertEqual([row[1] for row in list(sheet.values)[1:]], ['Second', 'First', 'Unordered'])
|
||||
|
||||
def test_empty_excel_order_falls_back_to_display(self):
|
||||
ProductCategory.objects.update(excel_order=None)
|
||||
Product.objects.update(excel_order=None)
|
||||
response = ProductAdmin(Product, admin.site).export_excel_order(None, Product.objects.all())
|
||||
sheet = load_workbook(BytesIO(response.content)).active
|
||||
self.assertEqual([row[2] for row in list(sheet.values)[1:]], ['B', 'A', 'C'])
|
||||
|
||||
def test_excel_keeps_formula_like_names_as_text(self):
|
||||
response = excel_response(('Name',), [('=1+1',)], 'test.xlsx')
|
||||
cell = load_workbook(BytesIO(response.content)).active['A2']
|
||||
self.assertEqual(cell.data_type, 's')
|
||||
self.assertEqual(cell.value, '=1+1')
|
||||
|
||||
def test_category_api_is_ordered_and_does_not_leak_other_workspace(self):
|
||||
foreign = ProductCategory.objects.create(name='Foreign', sys_ws=self.other_workspace, order_cat=0)
|
||||
Product.objects.create(name='Foreign product', sys_ws=self.other_workspace, category=foreign, price=1)
|
||||
self.use_role()
|
||||
response = self.client.get('/shop/v2/product-category/list/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual([row['name'] for row in response.data], ['First', 'Second'])
|
||||
response = self.client.get(f'/shop/product/list/{self.first.pk}/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.data['category_img'])
|
||||
self.assertEqual([row['name'] for row in response.data['products']], ['B', 'A'])
|
||||
|
||||
def test_order_excel_matches_independent_order_and_handles_empty_order(self):
|
||||
order = Order.objects.create(customer=self.customer, total_price=60, factor_type=1, sys_ws=self.workspace)
|
||||
for product in [self.b, self.a, self.c]:
|
||||
OrderItem.objects.create(order=order, product=product, product_name=product.name, price=product.price, amount=1)
|
||||
self.use_role(self.operator)
|
||||
response = self.client.get(f'/shop/order/{order.pk}/operator/excel/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
sheet = load_workbook(BytesIO(response.content)).active
|
||||
self.assertEqual([row[0] for row in list(sheet.values)[1:]], ['SecondC', 'FirstA', 'FirstB'])
|
||||
OrderItem.objects.all().delete()
|
||||
response = self.client.get(f'/shop/order/{order.pk}/operator/excel/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(load_workbook(BytesIO(response.content)).active.max_row, 1)
|
||||
|
||||
def test_order_excel_requires_operator_and_matching_workspace(self):
|
||||
order = Order.objects.create(customer=self.customer, total_price=0, factor_type=1, sys_ws=self.other_workspace)
|
||||
self.use_role()
|
||||
self.assertEqual(self.client.get(f'/shop/order/{order.pk}/operator/excel/').status_code, 403)
|
||||
self.use_role(self.operator)
|
||||
self.assertEqual(self.client.get(f'/shop/order/{order.pk}/operator/excel/').status_code, 400)
|
||||
|
||||
def test_admin_bulk_edit_category_and_product_orders(self):
|
||||
self.user.is_staff = True
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
self.client.force_login(self.user)
|
||||
for model, field, objects in [('productcategory', 'order_cat', [self.first, self.second, self.unordered]),
|
||||
('product', 'order_prd', [self.a, self.b, self.c])]:
|
||||
path = f'/admin/shop/{model}/'
|
||||
response = self.client.get(path)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = {'_save': 'Save', 'form-TOTAL_FORMS': len(objects), 'form-INITIAL_FORMS': len(objects),
|
||||
'form-MIN_NUM_FORMS': 0, 'form-MAX_NUM_FORMS': 1000}
|
||||
for index, obj in enumerate(objects):
|
||||
data.update({f'form-{index}-id': obj.pk, f'form-{index}-{field}': 10+index,
|
||||
f'form-{index}-excel_order': 20-index})
|
||||
self.assertEqual(self.client.post(path, data).status_code, 302)
|
||||
for index, obj in enumerate(objects):
|
||||
obj.refresh_from_db()
|
||||
self.assertEqual(getattr(obj, field), 10+index)
|
||||
self.assertEqual(obj.excel_order, 20-index)
|
||||
@@ -4,13 +4,15 @@ from apps.jwt_auth.views import (
|
||||
CustomTokenObtainPairView,
|
||||
LoginByUserView,
|
||||
LoginByMobileView,
|
||||
VerifyMobileAndGetTokenView
|
||||
VerifyMobileAndGetTokenView,
|
||||
LogoutView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('logout/', LogoutView.as_view(), name='logout'),
|
||||
path('login/username/', LoginByUserView.as_view(), name='login_by_username'),
|
||||
path('login/mobile/', LoginByMobileView.as_view(), name='login_by_mobile'),
|
||||
path('login/mobile/verify/', VerifyMobileAndGetTokenView.as_view(), name='verify_mobile'),
|
||||
path('token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('token/refresh/', CustomTokenRefreshView.as_view(), name='token_refresh'),
|
||||
]
|
||||
]
|
||||
+107
-255
@@ -1,297 +1,149 @@
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import serializers
|
||||
from rest_framework.response import Response
|
||||
from django.db import transaction
|
||||
from apps.jwt_auth.models import JwtToken as JwtTokenModel, CUserRoleLoginOpt
|
||||
from apps.cuser.models import CuserRole as CuserRoleModel, RoleType as RoleTypeModel
|
||||
from apps.customer.models import Customer as CustomerModel, CustomerResponsible as CustomerResponsibleModel
|
||||
from rest_framework_simplejwt.tokens import RefreshToken, AccessToken, Token
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from apps.workspace.models import Workspace as WorkspaceModel
|
||||
from django.utils import timezone
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from apps.cuser.models import CUser, CuserRole, RoleType
|
||||
from apps.jwt_auth.models import JwtToken, CUserRoleLoginOpt
|
||||
from apps.jwt_auth.services import login_by_username, login_by_mobile, verify_mobile_and_get_token
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import status
|
||||
CUser = get_user_model()
|
||||
|
||||
# Create your views here.
|
||||
from apps.jwt_auth.session import issue_tokens, validate_session_claims
|
||||
from apps.workspace.models import Workspace
|
||||
|
||||
|
||||
class VerifyMobileAndGetTokenView(APIView):
|
||||
|
||||
class InputSerializer(serializers.Serializer):
|
||||
mobile = serializers.IntegerField(required=True)
|
||||
opt = serializers.IntegerField(required=True)
|
||||
class RoleTypeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = RoleType
|
||||
fields = ('role_name', 'role_name_en')
|
||||
|
||||
|
||||
class OutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
class CUserRoleSerializer(serializers.ModelSerializer):
|
||||
|
||||
class RoleTypeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = RoleTypeModel
|
||||
fields = ('role_name', )
|
||||
|
||||
role_type = RoleTypeSerializer()
|
||||
|
||||
class Meta:
|
||||
model = CuserRoleModel
|
||||
fields = ('name_family', 'title', 'role_type')
|
||||
|
||||
cuser_role = CUserRoleSerializer()
|
||||
|
||||
class RoleSerializer(serializers.ModelSerializer):
|
||||
class WorkspaceSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = CUserRoleLoginOpt
|
||||
fields = ('cuser_role', 'expire', 'uuid')
|
||||
model = Workspace
|
||||
fields = ('id', 'name')
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.InputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
role_type = RoleTypeSerializer()
|
||||
sys_ws = WorkspaceSerializer()
|
||||
|
||||
login_opts = verify_mobile_and_get_token(**serializer.validated_data)
|
||||
|
||||
response_data = self.OutputSerializer(login_opts, many=True).data
|
||||
|
||||
return Response(response_data)
|
||||
class Meta:
|
||||
model = CuserRole
|
||||
fields = ('name_family', 'title', 'role_type', 'sys_ws')
|
||||
|
||||
|
||||
class LoginByMobileView(APIView):
|
||||
class LoginOptionSerializer(serializers.ModelSerializer):
|
||||
cuser_role = RoleSerializer()
|
||||
|
||||
class Meta:
|
||||
model = CUserRoleLoginOpt
|
||||
fields = ('cuser_role', 'expire', 'uuid')
|
||||
|
||||
|
||||
class PublicAuthView(APIView):
|
||||
authentication_classes = ()
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def get_authenticate_header(self, request):
|
||||
return 'Bearer realm="api"'
|
||||
|
||||
|
||||
class VerifyMobileAndGetTokenView(PublicAuthView):
|
||||
class InputSerializer(serializers.Serializer):
|
||||
mobile = serializers.IntegerField(required=True)
|
||||
mobile = serializers.CharField(required=True)
|
||||
opt = serializers.IntegerField(required=True, min_value=100000, max_value=999999)
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.InputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
login_by_mobile(**serializer.validated_data)
|
||||
|
||||
output = {
|
||||
"result": {
|
||||
"mobile": serializer.validated_data.get('mobile'),
|
||||
"message": "opt code send"
|
||||
}
|
||||
}
|
||||
|
||||
return Response(output)
|
||||
options = verify_mobile_and_get_token(**serializer.validated_data)
|
||||
return Response(LoginOptionSerializer(options, many=True).data)
|
||||
|
||||
|
||||
class LoginByUserView(APIView):
|
||||
class LoginByMobileView(PublicAuthView):
|
||||
class InputSerializer(serializers.Serializer):
|
||||
mobile = serializers.CharField(required=True)
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.InputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
mobile = login_by_mobile(**serializer.validated_data)
|
||||
return Response({'result': {'mobile': mobile, 'message': 'opt code send'}})
|
||||
|
||||
|
||||
class LoginByUserView(PublicAuthView):
|
||||
class InputSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(required=True)
|
||||
password = serializers.CharField(required=True)
|
||||
|
||||
class OutputSerializer(serializers.ModelSerializer):
|
||||
|
||||
class CUserRoleSerializer(serializers.ModelSerializer):
|
||||
|
||||
class RoleTypeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = RoleTypeModel
|
||||
fields = ('role_name', )
|
||||
|
||||
role_type = RoleTypeSerializer()
|
||||
|
||||
class Meta:
|
||||
model = CuserRoleModel
|
||||
fields = ('name_family', 'title', 'role_type')
|
||||
|
||||
cuser_role = CUserRoleSerializer()
|
||||
|
||||
class Meta:
|
||||
model = CUserRoleLoginOpt
|
||||
fields = ('cuser_role', 'expire', 'uuid')
|
||||
password = serializers.CharField(required=True, trim_whitespace=False, write_only=True)
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.InputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
username = serializer.validated_data.get('username')
|
||||
password = serializer.validated_data.get('password')
|
||||
|
||||
login_opts = login_by_username(username=username, password=password)
|
||||
|
||||
response_data = self.OutputSerializer(login_opts, many=True).data
|
||||
|
||||
return Response(response_data)
|
||||
options = login_by_username(**serializer.validated_data)
|
||||
return Response(LoginOptionSerializer(options, many=True).data)
|
||||
|
||||
|
||||
class CustomTokenObtainPairView(APIView):
|
||||
|
||||
class CustomTokenObtainPairView(PublicAuthView):
|
||||
class InputSerializer(serializers.Serializer):
|
||||
uuid_opt = serializers.CharField(required=True)
|
||||
|
||||
class OutputSerializer(serializers.Serializer):
|
||||
access = serializers.CharField(required=True)
|
||||
refresh = serializers.CharField(required=True)
|
||||
uuid_opt = serializers.UUIDField(required=True)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
serializer = self.InputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
uuid_opt = serializer.validated_data.get('uuid_opt')
|
||||
|
||||
try:
|
||||
user_login_opt = CUserRoleLoginOpt.objects.get(uuid=uuid_opt)
|
||||
user = user_login_opt.cuser
|
||||
cuser_role = user_login_opt.cuser_role
|
||||
user_id = CUserRoleLoginOpt.objects.values_list('cuser_id', flat=True).get(
|
||||
uuid=serializer.validated_data['uuid_opt'], expire__gt=timezone.now())
|
||||
CUser.objects.select_for_update().get(pk=user_id)
|
||||
option = CUserRoleLoginOpt.objects.select_related(
|
||||
'cuser_role__cuser', 'cuser_role__role_type', 'cuser_role__sys_ws',
|
||||
).get(uuid=serializer.validated_data['uuid_opt'], expire__gt=timezone.now())
|
||||
except (CUserRoleLoginOpt.DoesNotExist, CUser.DoesNotExist):
|
||||
raise InvalidToken('اعتبار درخواست ورود تمام شده است. دوباره وارد شوید.')
|
||||
if option.cuser_id != option.cuser_role.cuser_id:
|
||||
raise InvalidToken('درخواست ورود معتبر نیست.')
|
||||
data = issue_tokens(option.cuser_role)
|
||||
CUserRoleLoginOpt.objects.filter(cuser_id=option.cuser_id).delete()
|
||||
return Response(data)
|
||||
|
||||
|
||||
workspace = cuser_role.sys_ws
|
||||
|
||||
if user_login_opt:
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
#if 'user_id' in refresh:
|
||||
# del refresh['user_id']
|
||||
#print(cuser_role.uuid)
|
||||
|
||||
|
||||
'''try:
|
||||
#customerObj = CustomerModel.objects.get(cuser_role__uuid=str(cuser_role.uuid))
|
||||
# workspace = customerObj.sys_ws
|
||||
|
||||
workspace = cuser_role.sys_ws
|
||||
|
||||
except:
|
||||
operator = CustomerResponsibleModel.objects.get(cuser_role=cuser_role)
|
||||
workspace = operator.sys_ws'''
|
||||
|
||||
refresh['workspace_uuid'] = str(workspace.uuid)
|
||||
|
||||
refresh['cuser_role_uuid'] = str(cuser_role.uuid)
|
||||
|
||||
refresh['user_uuid'] = str(user.uuid)
|
||||
|
||||
#print(str(refresh))
|
||||
|
||||
try:
|
||||
jwt_db = JwtTokenModel(refresh=str(refresh), access=str(refresh.access_token),
|
||||
cuser=user, cuser_role=cuser_role)
|
||||
jwt_db.save()
|
||||
|
||||
try:
|
||||
CUserRoleLoginOpt.objects.filter(cuser=user).delete()
|
||||
except Exception as exp:
|
||||
pass
|
||||
|
||||
except Exception as exp:
|
||||
return Response({'error': str(exp)})
|
||||
|
||||
new_token_data = {
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
}
|
||||
|
||||
out_data = self.OutputSerializer(data=new_token_data)
|
||||
out_data.is_valid(raise_exception=True)
|
||||
|
||||
return Response(out_data.data)
|
||||
else:
|
||||
return Response({"error": 'userpass is not True'})
|
||||
except CUserRoleLoginOpt.DoesNotExist:
|
||||
return Response({"error": 'Token not valid'})
|
||||
except Exception as exp:
|
||||
return Response({"error": 'user or pass is not true 2'})
|
||||
|
||||
|
||||
class CustomTokenRefreshView(APIView):
|
||||
|
||||
class CustomTokenRefreshView(PublicAuthView):
|
||||
class InputSerializer(serializers.Serializer):
|
||||
refresh = serializers.CharField(required=True)
|
||||
|
||||
class OutputSerializer(serializers.Serializer):
|
||||
access = serializers.CharField(required=True)
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
serializer = self.InputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
refresh_value = serializer.validated_data['refresh']
|
||||
try:
|
||||
refresh = RefreshToken(refresh_value)
|
||||
session = JwtToken.objects.select_for_update().select_related(
|
||||
'cuser_role__cuser', 'cuser_role__role_type', 'cuser_role__sys_ws',
|
||||
).get(refresh=refresh_value, is_blocked=False)
|
||||
except (TokenError, JwtToken.DoesNotExist):
|
||||
raise InvalidToken('نشست شما منقضی یا باطل شده است. دوباره وارد شوید.')
|
||||
validate_session_claims(session, refresh)
|
||||
return Response(issue_tokens(session.cuser_role, session=session))
|
||||
|
||||
|
||||
class LogoutView(PublicAuthView):
|
||||
class InputSerializer(serializers.Serializer):
|
||||
refresh = serializers.CharField(required=True)
|
||||
|
||||
def post(self, request):
|
||||
with transaction.atomic():
|
||||
try:
|
||||
|
||||
serializer = self.InputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
refresh_token = serializer.validated_data.get('refresh')
|
||||
|
||||
print(refresh_token)
|
||||
|
||||
# check: token is valid?
|
||||
ref_token = RefreshToken(token=refresh_token)
|
||||
|
||||
print("ref")
|
||||
|
||||
# first token must exists in whitelist and not blocked
|
||||
token_exist = JwtTokenModel.objects.get(refresh=str(refresh_token))
|
||||
|
||||
print("exsist")
|
||||
|
||||
token_is_blocked = JwtTokenModel.objects.get(refresh=str(refresh_token)).is_blocked
|
||||
|
||||
print("yess")
|
||||
|
||||
if token_exist and token_is_blocked is False:
|
||||
JwtTokenModel.objects.get(refresh=str(refresh_token)).delete()
|
||||
else:
|
||||
raise ValueError('refresh token is not in whitelist or blocked!')
|
||||
|
||||
user_uuid = ref_token.payload.get('user_uuid')
|
||||
user_id = ref_token.payload.get('user_id')
|
||||
cuser_role_uuid = ref_token.payload.get('cuser_role_uuid')
|
||||
|
||||
token_exp_timestamp = ref_token.payload.get('exp')
|
||||
token_exp = timezone.make_aware(timezone.datetime.fromtimestamp(token_exp_timestamp), timezone.utc)
|
||||
user = CUser.objects.get(id=user_id)
|
||||
cuser_role = CuserRoleModel.objects.get(uuid=cuser_role_uuid)
|
||||
|
||||
|
||||
print("OKH###")
|
||||
|
||||
if ref_token:
|
||||
new_token = RefreshToken.for_user(user)
|
||||
|
||||
#if 'user_id' in new_token:
|
||||
#del new_token['user_id']
|
||||
|
||||
new_token['user_uuid'] = str(user.uuid)
|
||||
new_token['cuser_role_uuid'] = str(cuser_role.uuid)
|
||||
|
||||
print("OKOKKO 555")
|
||||
|
||||
#workspace = WorkspaceModel.objects.get(cuser_role_owner__uuid=str(cuser_role.uuid))
|
||||
|
||||
try:
|
||||
customerObj = CustomerModel.objects.get(cuser_role__uuid=str(cuser_role.uuid))
|
||||
new_token['workspace_uuid'] = str(customerObj.sys_ws.uuid)
|
||||
except:
|
||||
operator = CustomerResponsibleModel.objects.get(cuser_role__uuid=str(cuser_role.uuid))
|
||||
new_token['workspace_uuid'] = str(operator.sys_ws.uuid)
|
||||
|
||||
|
||||
new_access_token = new_token.access_token
|
||||
|
||||
new_token_data = {
|
||||
'refresh': str(new_token),
|
||||
'access': str(new_access_token),
|
||||
}
|
||||
|
||||
print("OKOKKO")
|
||||
|
||||
new_token_obj = JwtTokenModel(refresh=str(new_token),
|
||||
access=str(new_token_data.get('access')),
|
||||
expire=token_exp,
|
||||
cuser=user,
|
||||
cuser_role=cuser_role
|
||||
)
|
||||
|
||||
print("OKOKKO 222")
|
||||
new_token_obj.save()
|
||||
out_data = self.OutputSerializer(data=new_token_data)
|
||||
out_data.is_valid(raise_exception=True)
|
||||
return Response(out_data.data)
|
||||
|
||||
except Exception as e:
|
||||
transaction.set_rollback(True)
|
||||
return Response({"error": str(e)})
|
||||
serializer = self.InputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
value = serializer.validated_data['refresh']
|
||||
try:
|
||||
token = RefreshToken(value)
|
||||
except TokenError:
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
# Possession of the signed refresh token permits logout even after access expires.
|
||||
if type(token.get('session_id')) is int and type(token.get('user_id')) is int:
|
||||
JwtToken.objects.filter(pk=token['session_id'], cuser_id=token['user_id']).update(is_blocked=True)
|
||||
else:
|
||||
JwtToken.objects.filter(refresh=value).update(is_blocked=True)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
+20
-7
@@ -1,40 +1,53 @@
|
||||
from utils.admin_base import CRMModelAdmin
|
||||
from django.contrib import admin
|
||||
from apps.lead.models import *
|
||||
# Register your models here.
|
||||
|
||||
|
||||
class LeadCommentInline(admin.TabularInline):
|
||||
model = LeadComment
|
||||
extra = 0
|
||||
fields = ["desc","cuser_role","created_at"]
|
||||
readonly_fields = fields
|
||||
can_delete = False
|
||||
show_change_link = True
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(Lead)
|
||||
class LeadAdmin(admin.ModelAdmin):
|
||||
class LeadAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(LeadComment)
|
||||
class LeadCommentAdmin(admin.ModelAdmin):
|
||||
class LeadCommentAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(LeadStep)
|
||||
class LeadStepAdmin(admin.ModelAdmin):
|
||||
class LeadStepAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(LeadType)
|
||||
class LeadTypeAdmin(admin.ModelAdmin):
|
||||
class LeadTypeAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(LeadSource)
|
||||
class LeadSourceAdmin(admin.ModelAdmin):
|
||||
class LeadSourceAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(LeadReason)
|
||||
class LeadReasonAdmin(admin.ModelAdmin):
|
||||
class LeadReasonAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(LeadEvent)
|
||||
class LeadEventAdmin(admin.ModelAdmin):
|
||||
class LeadEventAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from utils.admin_base import CRMModelAdmin
|
||||
from django.contrib import admin
|
||||
from apps.reminder.models import *
|
||||
|
||||
@@ -7,5 +8,5 @@ from apps.reminder.models import *
|
||||
|
||||
|
||||
@admin.register(Reminder)
|
||||
class ReminderAdmin(admin.ModelAdmin):
|
||||
class ReminderAdmin(CRMModelAdmin):
|
||||
pass
|
||||
+119
-26
@@ -1,40 +1,133 @@
|
||||
from utils.admin_base import CRMModelAdmin
|
||||
from utils.admin_excel import excel_response
|
||||
from apps.shop.admin_import import ProductImportAdminMixin
|
||||
from apps.shop.import_models import ProductImportBatch
|
||||
|
||||
from django.contrib import admin
|
||||
from apps.shop.models import (
|
||||
ProductCategory as ProductCategoryModel,
|
||||
Product as ProductModel,
|
||||
Order as OrderModel,
|
||||
OrderItem as OrderItemModel,
|
||||
Cart as CartModel,
|
||||
ProductType as ProductTypeModel
|
||||
)
|
||||
# Register your models here.
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
|
||||
from apps.shop.models import ProductCategory, Product, Order, OrderItem, Cart, ProductType
|
||||
from apps.shop.ordering import category_ordering, product_ordering
|
||||
|
||||
|
||||
@admin.register(ProductTypeModel)
|
||||
class ProductTypeAdmin(admin.ModelAdmin):
|
||||
def export_products(queryset):
|
||||
headers = ('فضای کاری', 'دسته', 'محصول', 'کد محصول', 'قیمت', 'ترتیب نمایش دسته', 'ترتیب نمایش محصول', 'ترتیب اکسل دسته', 'ترتیب اکسل محصول', 'فقط نمایش', 'ویژگیها')
|
||||
categories = {category.pk: (category.name, category.parent_id) for category in
|
||||
ProductCategory.objects.filter(sys_ws_id__in=queryset.values('sys_ws_id'))}
|
||||
def path(category_id):
|
||||
names, visited = [], set()
|
||||
while category_id in categories and category_id not in visited:
|
||||
visited.add(category_id)
|
||||
name, category_id = categories[category_id]
|
||||
names.append(name)
|
||||
return '/'.join(reversed(names))
|
||||
rows = ((item.sys_ws.name, path(item.category_id), item.name, item.acc_code, item.price,
|
||||
item.category.order_cat, item.order_prd, item.category.excel_order, item.excel_order,
|
||||
item.view_only, item.attr) for item in queryset.select_related('sys_ws', 'category').iterator())
|
||||
return excel_response(headers, rows, 'products.xlsx')
|
||||
|
||||
|
||||
class ProductInline(admin.TabularInline):
|
||||
model = Product
|
||||
fields = ('name', 'price', 'order_prd', 'excel_order')
|
||||
extra = 0
|
||||
show_change_link = True
|
||||
readonly_fields = ('name', 'price')
|
||||
can_delete = False
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(ProductType)
|
||||
class ProductTypeAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(ProductCategoryModel)
|
||||
class ProductCategoryAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
@admin.register(ProductCategory)
|
||||
class ProductCategoryAdmin(CRMModelAdmin):
|
||||
list_display = ('name', 'sys_ws', 'parent', 'order_cat', 'excel_order')
|
||||
list_editable = ('order_cat', 'excel_order')
|
||||
list_filter = ('sys_ws',)
|
||||
search_fields = ('name',)
|
||||
inlines = (ProductInline,)
|
||||
actions = ('export_display_order', 'export_excel_order')
|
||||
|
||||
@admin.action(description='خروجی اکسل دستهها با ترتیب نمایش فعلی')
|
||||
def export_display_order(self, request, queryset):
|
||||
return self.export_categories(queryset)
|
||||
|
||||
@admin.action(description='خروجی اکسل دستهها با ترتیب مستقل اکسل')
|
||||
def export_excel_order(self, request, queryset):
|
||||
return self.export_categories(queryset.order_by(*category_ordering(excel=True)))
|
||||
|
||||
def export_categories(self, queryset):
|
||||
return excel_response(('فضای کاری', 'دسته', 'ترتیب نمایش', 'ترتیب اکسل'),
|
||||
queryset.values_list('sys_ws__name', 'name', 'order_cat', 'excel_order'), 'categories.xlsx')
|
||||
|
||||
|
||||
@admin.register(ProductModel)
|
||||
class ProductAdmin(admin.ModelAdmin):
|
||||
search_fields = ("name", "category__name", "acc_code")
|
||||
@admin.register(Product)
|
||||
class ProductAdmin(ProductImportAdminMixin, CRMModelAdmin):
|
||||
list_display = ('name', 'category', 'sys_ws', 'price', 'order_prd', 'excel_order', 'view_only')
|
||||
list_editable = ('order_prd', 'excel_order')
|
||||
list_filter = ('sys_ws', 'category', 'view_only')
|
||||
search_fields = ('name', 'category__name', 'acc_code')
|
||||
list_select_related = ('category', 'sys_ws')
|
||||
actions = ('export_display_order', 'export_excel_order')
|
||||
|
||||
@admin.action(description='خروجی اکسل محصولات با ترتیب نمایش فعلی')
|
||||
def export_display_order(self, request, queryset):
|
||||
return export_products(queryset)
|
||||
|
||||
@admin.action(description='خروجی اکسل محصولات با ترتیب مستقل اکسل')
|
||||
def export_excel_order(self, request, queryset):
|
||||
return export_products(queryset.order_by(*product_ordering(excel=True)))
|
||||
|
||||
|
||||
@admin.register(OrderModel)
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
@admin.register(Order)
|
||||
class OrderAdmin(CRMModelAdmin):
|
||||
list_display = ('id', 'customer', 'sys_ws', 'total_price', 'factor_type', 'factor_status', 'view_at', 'created_at')
|
||||
|
||||
|
||||
@admin.register(OrderItemModel)
|
||||
class OrderItemAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
@admin.register(OrderItem)
|
||||
class OrderItemAdmin(CRMModelAdmin):
|
||||
list_display = ('id', 'order', 'product', 'product_name', 'price', 'amount')
|
||||
search_fields = ('product_name', 'product__name', 'order__customer__name_family')
|
||||
|
||||
|
||||
@admin.register(CartModel)
|
||||
class CartAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
@admin.register(Cart)
|
||||
class CartAdmin(CRMModelAdmin):
|
||||
list_display = ('id', 'cuser_role', 'product', 'quantity', 'sys_ws', 'updated_at')
|
||||
|
||||
|
||||
@admin.register(ProductImportBatch)
|
||||
class ProductImportBatchAdmin(CRMModelAdmin):
|
||||
list_display = ("id", "filename", "sys_ws", "created_by", "summary", "created_at", "confirmed_at")
|
||||
search_fields = ("filename", "created_by__username")
|
||||
exclude = ("payload", "fingerprint", "result")
|
||||
sensitive_fields = ("payload", "fingerprint", "result")
|
||||
actions = None
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
fields = tuple(field.name for field in self.model._meta.fields if field.name not in self.exclude)
|
||||
return (*fields, 'summary', 'report_link') if obj and obj.created_by_id == request.user.pk else (*fields, 'summary')
|
||||
|
||||
@admin.display(description='خلاصه نتیجه')
|
||||
def summary(self, obj):
|
||||
result = obj.result
|
||||
status = 'ثبت نهایی' if obj.confirmed_at else 'پیشنمایش'
|
||||
return f"{status} | ایجاد: {result.get('created', 0)} | بهروزرسانی: {result.get('updated', 0)} | خطا: {result.get('errors', 0)}"
|
||||
|
||||
@admin.display(description='گزارش ردیفها')
|
||||
def report_link(self, obj):
|
||||
return format_html('<a href="{}">دریافت اکسل گزارش</a>', reverse('admin:shop_product_import_report', args=[obj.pk]))
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import path
|
||||
|
||||
from apps.shop.import_models import ProductImportBatch
|
||||
from apps.shop.product_import import HEADERS, parse_workbook
|
||||
from apps.shop.import_batches import preview_batch, confirm_batch
|
||||
from apps.workspace.models import Workspace
|
||||
from utils.admin_excel import excel_response
|
||||
|
||||
|
||||
class ProductImportForm(forms.Form):
|
||||
workspace = forms.ModelChoiceField(label='فضای کاری مقصد', queryset=Workspace.objects.all())
|
||||
file = forms.FileField(label='فایل xlsx')
|
||||
sheet = forms.CharField(label='نام برگه (خالی = اولین برگه)', required=False)
|
||||
header_row = forms.IntegerField(label='شماره ردیف سرستون', min_value=1, max_value=100, initial=1)
|
||||
mode = forms.ChoiceField(label='نوع ورود', choices=(('create', 'فقط ایجاد'), ('upsert', 'ایجاد و بهروزرسانی'), ('update', 'فقط بهروزرسانی')))
|
||||
create_categories = forms.BooleanField(label='ساخت دستههای ناموجود', required=False, initial=True)
|
||||
errors = forms.ChoiceField(label='مدیریت خطا', choices=(('atomic', 'در صورت هر خطا، هیچ ردیفی ثبت نشود'), ('skip', 'فقط ردیفهای معتبر ثبت شوند')))
|
||||
ordering = forms.ChoiceField(label='ترتیب نمایش محصولات', choices=(('preserve', 'مقادیر فایل؛ خالی = حفظ مقدار فعلی'), ('rows', 'ترتیب ردیف فایل برای مقادیر خالی'), ('append', 'محصول جدید در انتهای دسته')))
|
||||
mapping = forms.CharField(label='نگاشت اختیاری ستونها (JSON)', required=False, widget=forms.Textarea(attrs={'rows': 3}),
|
||||
help_text='مثال: {"name": "عنوان کالا", "category": "گروه", "acc_code": "شناسه"}')
|
||||
|
||||
def clean_mapping(self):
|
||||
try:
|
||||
value = json.loads(self.cleaned_data['mapping'] or '{}')
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) and isinstance(item, str) for key, item in value.items()):
|
||||
raise ValueError
|
||||
return value
|
||||
except ValueError:
|
||||
raise forms.ValidationError('یک شیء JSON با کلید و مقدار متنی وارد کنید.')
|
||||
|
||||
|
||||
class ProductImportAdminMixin:
|
||||
change_list_template = 'admin/shop/product/change_list.html'
|
||||
|
||||
def import_allowed(self, request):
|
||||
return (self.has_add_permission(request) and self.has_change_permission(request)
|
||||
and request.user.has_perm('shop.view_productcategory'))
|
||||
|
||||
def get_urls(self):
|
||||
return [path('import-excel/', self.admin_site.admin_view(self.import_view), name='shop_product_import'),
|
||||
path('import-template/', self.admin_site.admin_view(self.import_template), name='shop_product_import_template'),
|
||||
path('import-report/<int:batch_id>/', self.admin_site.admin_view(self.import_report), name='shop_product_import_report')] + super().get_urls()
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
return super().changelist_view(request, dict(extra_context or {}, can_import=self.import_allowed(request)))
|
||||
|
||||
def import_template(self, request):
|
||||
if not self.import_allowed(request):
|
||||
raise PermissionDenied
|
||||
return excel_response(HEADERS.keys(), [('محصول نمونه', 'دسته اصلی/زیردسته', 'CODE-001', 1000, 1, 1, 1, 1, False, '{}', 'عدد')], 'product-import-template.xlsx')
|
||||
|
||||
def import_report(self, request, batch_id):
|
||||
if not self.import_allowed(request):
|
||||
raise PermissionDenied
|
||||
batch = get_object_or_404(ProductImportBatch, pk=batch_id, created_by=request.user)
|
||||
rows = ((row['row'], *(row['values'].get(key) for key in HEADERS), row['status'], row['errors'])
|
||||
for row in batch.result.get('rows', []))
|
||||
return excel_response(('ردیف', *HEADERS, 'وضعیت', 'خطاها'), rows, f'import-report-{batch.pk}.xlsx')
|
||||
|
||||
def import_view(self, request):
|
||||
if not self.import_allowed(request):
|
||||
raise PermissionDenied
|
||||
form = ProductImportForm(request.POST or None, request.FILES or None)
|
||||
batch, problem = None, None
|
||||
if request.method == 'POST':
|
||||
if request.POST.get('confirm'):
|
||||
try:
|
||||
batch = self.confirm_import(request)
|
||||
except ValidationError as error:
|
||||
problem = '؛ '.join(error.messages)
|
||||
elif form.is_valid():
|
||||
try:
|
||||
batch = self.preview_import(request, form.cleaned_data)
|
||||
except ValidationError as error:
|
||||
form.add_error(None, error)
|
||||
context = dict(self.admin_site.each_context(request), opts=self.model._meta, title='ورود محصولات از اکسل',
|
||||
form=form, batch=batch, problem=problem,
|
||||
result=batch.result if batch else None, can_confirm=batch and not batch.confirmed_at
|
||||
and (batch.payload['options']['errors'] == 'skip' or not batch.result['errors']))
|
||||
return TemplateResponse(request, 'admin/shop/product/import.html', context)
|
||||
|
||||
def preview_import(self, request, data):
|
||||
if data['create_categories'] and not request.user.has_perm('shop.add_productcategory'):
|
||||
raise PermissionDenied
|
||||
# Category-order permission is checked against the parsed file before preview writes.
|
||||
rows = parse_workbook(data['file'], data['sheet'], data['header_row'], data['mapping'])
|
||||
if any(row['values'].get(key) for row in rows for key in ('order_cat', 'category_excel_order')):
|
||||
if not request.user.has_perm('shop.change_productcategory'):
|
||||
raise PermissionDenied
|
||||
data['file'].seek(0)
|
||||
options = {key: data[key] for key in ('mode', 'create_categories', 'errors', 'ordering')}
|
||||
return preview_batch(request.user, data['workspace'], data['file'], options,
|
||||
data['sheet'], data['header_row'], data['mapping'])
|
||||
|
||||
def confirm_import(self, request):
|
||||
try:
|
||||
batch_id = int(request.POST['batch_id'])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
raise ValidationError('شناسه پیشنمایش معتبر نیست.')
|
||||
batch = get_object_or_404(ProductImportBatch, pk=batch_id, created_by=request.user)
|
||||
options = batch.payload['options']
|
||||
if options['create_categories'] and not request.user.has_perm('shop.add_productcategory'):
|
||||
raise PermissionDenied
|
||||
if any(row['values'].get(key) for row in batch.payload['rows'] for key in ('order_cat', 'category_excel_order')):
|
||||
if not request.user.has_perm('shop.change_productcategory'):
|
||||
raise PermissionDenied
|
||||
return confirm_batch(batch_id, request.user)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Shared import lifecycle; callers enforce their own admin permission boundary."""
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from django.contrib.admin.models import LogEntry, ADDITION
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.shop.import_models import ProductImportBatch
|
||||
from apps.shop.models import Product
|
||||
from apps.shop.product_import import parse_workbook, import_products, catalog_fingerprint, lock_catalog, HEADERS
|
||||
from utils.admin_excel import excel_response
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def preview_batch(owner, workspace, upload, options, sheet='', header_row=1, mapping=None):
|
||||
rows = parse_workbook(upload, sheet, header_row, mapping)
|
||||
lock_catalog(workspace)
|
||||
fingerprint = catalog_fingerprint(workspace)
|
||||
result = import_products(workspace, rows, options, dry_run=True)
|
||||
return ProductImportBatch.objects.create(sys_ws=workspace, created_by=owner,
|
||||
expires_at=timezone.now()+timedelta(minutes=30), filename=upload.name[:255],
|
||||
fingerprint=fingerprint, payload={'rows': rows, 'options': options}, result=result)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def confirm_batch(batch_id, owner, workspace=None):
|
||||
queryset = ProductImportBatch.objects.select_for_update().filter(created_by=owner)
|
||||
if workspace is not None:
|
||||
queryset = queryset.filter(sys_ws=workspace)
|
||||
batch = get_object_or_404(queryset, pk=batch_id)
|
||||
if batch.confirmed_at:
|
||||
return batch
|
||||
if batch.expires_at <= timezone.now():
|
||||
raise ValidationError('پیشنمایش منقضی شده؛ فایل را دوباره اعتبارسنجی کنید.')
|
||||
lock_catalog(batch.sys_ws)
|
||||
if catalog_fingerprint(batch.sys_ws) != batch.fingerprint:
|
||||
raise ValidationError('محصولات یا دستهها پس از پیشنمایش تغییر کردهاند؛ پیشنمایش تازه بسازید.')
|
||||
result = import_products(batch.sys_ws, batch.payload['rows'], batch.payload['options'])
|
||||
batch.result = result
|
||||
if result['committed']:
|
||||
batch.confirmed_at = timezone.now()
|
||||
LogEntry.objects.log_action(user_id=owner.pk, content_type_id=ContentType.objects.get_for_model(Product).pk,
|
||||
object_id=None, object_repr=f'Excel import #{batch.pk}', action_flag=ADDITION,
|
||||
change_message=json.dumps({'import': batch.pk, 'created': result['created'], 'updated': result['updated'], 'errors': result['errors']}))
|
||||
batch.save(update_fields=('result', 'confirmed_at'))
|
||||
return batch
|
||||
|
||||
|
||||
def batch_report(batch):
|
||||
rows = ((row['row'], *(row['values'].get(key) for key in HEADERS), row['status'], row['errors'])
|
||||
for row in batch.result.get('rows', []))
|
||||
return excel_response(('ردیف', *HEADERS, 'وضعیت', 'خطاها'), rows, f'import-report-{batch.pk}.xlsx')
|
||||
|
||||
|
||||
def import_template():
|
||||
return excel_response(HEADERS.keys(), [('محصول نمونه', 'دسته اصلی/زیردسته', 'CODE-001', 1000, 1, 1, 1, 1, False, '{}', 'عدد')], 'product-import-template.xlsx')
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ProductImportBatch(models.Model):
|
||||
sys_ws = models.ForeignKey('workspace.Workspace', on_delete=models.CASCADE)
|
||||
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
confirmed_at = models.DateTimeField(null=True, blank=True)
|
||||
filename = models.CharField(max_length=255)
|
||||
fingerprint = models.CharField(max_length=64)
|
||||
payload = models.JSONField()
|
||||
result = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
ordering = ('-pk',)
|
||||
verbose_name = 'ورود اکسل محصولات'
|
||||
verbose_name_plural = 'سوابق ورود اکسل محصولات'
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.pk}: {self.filename}'
|
||||
@@ -0,0 +1,89 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.shop.models import Product, ProductCategory
|
||||
|
||||
|
||||
class CategoryManagementSerializer(serializers.ModelSerializer):
|
||||
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)
|
||||
product_count = serializers.IntegerField(read_only=True)
|
||||
child_count = serializers.IntegerField(read_only=True)
|
||||
parent = serializers.PrimaryKeyRelatedField(queryset=ProductCategory.objects.all(), allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = ProductCategory
|
||||
fields = ('id', 'name', 'parent', 'image', 'order_cat', 'excel_order', 'product_count', 'child_count')
|
||||
|
||||
def validate(self, attrs):
|
||||
workspace = self.context['workspace']
|
||||
parent = attrs.get('parent', self.instance.parent if self.instance else None)
|
||||
if parent and parent.sys_ws_id != workspace.pk:
|
||||
raise serializers.ValidationError({'parent': 'دسته والد متعلق به فضای کاری دیگری است.'})
|
||||
visited = set()
|
||||
cursor = parent
|
||||
while cursor:
|
||||
if cursor.pk in visited or self.instance and cursor.pk == self.instance.pk:
|
||||
raise serializers.ValidationError({'parent': 'انتخاب این والد باعث چرخه در دستهها میشود.'})
|
||||
visited.add(cursor.pk)
|
||||
cursor = cursor.parent
|
||||
name = attrs.get('name', self.instance.name if self.instance else '')
|
||||
matches = ProductCategory.objects.filter(sys_ws=workspace, name=name, parent=parent)
|
||||
if self.instance:
|
||||
matches = matches.exclude(pk=self.instance.pk)
|
||||
if matches.exists():
|
||||
raise serializers.ValidationError({'name': 'این نام در همین دسته والد قبلاً ثبت شده است.'})
|
||||
return attrs
|
||||
|
||||
|
||||
class ProductManagementSerializer(serializers.ModelSerializer):
|
||||
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)
|
||||
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||
price = serializers.IntegerField(min_value=0, max_value=9223372036854775807)
|
||||
category = serializers.PrimaryKeyRelatedField(queryset=ProductCategory.objects.all())
|
||||
acc_code = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = ('id', 'name', 'price', 'category', 'category_name', 'acc_code', 'attr', 'view_only', 'order_prd', 'excel_order', 'created_at', 'updated_at')
|
||||
read_only_fields = ('id', 'created_at', 'updated_at')
|
||||
|
||||
def validate_category(self, category):
|
||||
if category.sys_ws_id != self.context['workspace'].pk:
|
||||
raise serializers.ValidationError('دسته متعلق به فضای کاری دیگری است.')
|
||||
return category
|
||||
|
||||
def validate_acc_code(self, code):
|
||||
if code:
|
||||
matches = Product.objects.filter(sys_ws=self.context['workspace'], acc_code=code)
|
||||
if self.instance:
|
||||
matches = matches.exclude(pk=self.instance.pk)
|
||||
if matches.exists():
|
||||
raise serializers.ValidationError('کد محصول در این فضای کاری تکراری است.')
|
||||
return code
|
||||
|
||||
def validate_attr(self, value):
|
||||
if value is not None and not isinstance(value, dict):
|
||||
raise serializers.ValidationError('ویژگیها باید یک شیء JSON باشد.')
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
data['price'] = str(instance.price)
|
||||
return data
|
||||
|
||||
|
||||
class ImportOptionsSerializer(serializers.Serializer):
|
||||
file = serializers.FileField()
|
||||
sheet = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
header_row = serializers.IntegerField(min_value=1, max_value=100, default=1)
|
||||
mode = serializers.ChoiceField(choices=('create', 'upsert', 'update'), default='create')
|
||||
create_categories = serializers.BooleanField(default=True)
|
||||
errors = serializers.ChoiceField(choices=('atomic', 'skip'), default='atomic')
|
||||
ordering = serializers.ChoiceField(choices=('preserve', 'rows', 'append'), default='preserve')
|
||||
mapping = serializers.JSONField(default=dict)
|
||||
|
||||
def validate_mapping(self, value):
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) and isinstance(item, str) for key, item in value.items()):
|
||||
raise serializers.ValidationError('نگاشت ستونها باید شیء JSON با مقدارهای متنی باشد.')
|
||||
return value
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 4.2.1 on 2026-10-08 08:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0020_product_view_only'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='product',
|
||||
options={'ordering': (models.OrderBy(models.F('category__order_cat'), nulls_last=True), 'category__name', 'category__pk', models.OrderBy(models.F('order_prd'), nulls_last=True), 'name', 'pk')},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='productcategory',
|
||||
options={'ordering': (models.OrderBy(models.F('order_cat'), nulls_last=True), 'name', 'pk')},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='excel_order',
|
||||
field=models.PositiveIntegerField(blank=True, default=None, help_text='عدد کمتر زودتر نمایش داده می\u200cشود؛ خالی یعنی استفاده از ترتیب نمایش.', null=True, verbose_name='ترتیب محصول در اکسل'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productcategory',
|
||||
name='excel_order',
|
||||
field=models.PositiveIntegerField(blank=True, default=None, help_text='عدد کمتر زودتر نمایش داده می\u200cشود؛ خالی یعنی استفاده از ترتیب نمایش.', null=True, verbose_name='ترتیب دسته در اکسل'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='product',
|
||||
name='order_prd',
|
||||
field=models.PositiveIntegerField(blank=True, default=None, null=True, verbose_name='ترتیب نمایش محصول'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='productcategory',
|
||||
name='order_cat',
|
||||
field=models.PositiveIntegerField(blank=True, default=None, null=True, verbose_name='ترتیب نمایش دسته'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 4.2.1 on 2026-10-08 09:52
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('workspace', '0016_remove_workspace_cuser_owner'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('shop', '0021_product_display_excel_ordering'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProductImportBatch',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('confirmed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('filename', models.CharField(max_length=255)),
|
||||
('fingerprint', models.CharField(max_length=64)),
|
||||
('payload', models.JSONField()),
|
||||
('result', models.JSONField(default=dict)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
('sys_ws', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='workspace.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ورود اکسل محصولات',
|
||||
'verbose_name_plural': 'سوابق ورود اکسل محصولات',
|
||||
'ordering': ('-pk',),
|
||||
},
|
||||
),
|
||||
]
|
||||
+14
-2
@@ -1,8 +1,10 @@
|
||||
from django.db import models
|
||||
from apps.shop.import_models import ProductImportBatch
|
||||
from utils.base_model import BaseModel
|
||||
from apps.workspace.models import Workspace as WorkspaceModel
|
||||
from apps.cuser.models import CuserRole as CuserRoleModel
|
||||
from apps.customer.models import ResponsibleGroup as ResponsibleGroupModel, Customer as CustomerModel
|
||||
from apps.shop.ordering import category_ordering, product_ordering
|
||||
# Create your models here.
|
||||
|
||||
|
||||
@@ -18,9 +20,14 @@ class ProductCategory(BaseModel):
|
||||
name = models.CharField(max_length=60, null=False, blank=False)
|
||||
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True)
|
||||
image = models.ImageField(null=True, blank=True, default=None, upload_to='product-cat/')
|
||||
order_cat = models.PositiveIntegerField(null=True, blank=True, default=None)
|
||||
order_cat = models.PositiveIntegerField('ترتیب نمایش دسته', null=True, blank=True, default=None)
|
||||
excel_order = models.PositiveIntegerField('ترتیب دسته در اکسل', null=True, blank=True, default=None,
|
||||
help_text='عدد کمتر زودتر نمایش داده میشود؛ خالی یعنی استفاده از ترتیب نمایش.')
|
||||
sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, null=False, blank=False)
|
||||
|
||||
class Meta:
|
||||
ordering = category_ordering()
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}'
|
||||
|
||||
@@ -30,12 +37,17 @@ class Product(BaseModel):
|
||||
price = models.PositiveBigIntegerField(null=False, blank=False)
|
||||
category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, null=False, blank=False)
|
||||
acc_code = models.CharField(null=True, blank=True)
|
||||
order_prd = models.PositiveIntegerField(null=True, blank=True, default=None)
|
||||
order_prd = models.PositiveIntegerField('ترتیب نمایش محصول', null=True, blank=True, default=None)
|
||||
excel_order = models.PositiveIntegerField('ترتیب محصول در اکسل', null=True, blank=True, default=None,
|
||||
help_text='عدد کمتر زودتر نمایش داده میشود؛ خالی یعنی استفاده از ترتیب نمایش.')
|
||||
attr = models.JSONField(null=True, blank=True)
|
||||
view_only = models.BooleanField(null=True, blank=True, default=False)
|
||||
|
||||
sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, null=False, blank=False)
|
||||
|
||||
class Meta:
|
||||
ordering = product_ordering()
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}'
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.db.models import F
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
|
||||
def category_ordering(prefix='', excel=False):
|
||||
field = F(prefix + 'order_cat')
|
||||
if excel:
|
||||
field = Coalesce(prefix + 'excel_order', prefix + 'order_cat')
|
||||
return (field.asc(nulls_last=True), prefix + 'name', prefix + 'pk')
|
||||
|
||||
|
||||
def product_ordering(prefix='', excel=False):
|
||||
field = F(prefix + 'order_prd')
|
||||
if excel:
|
||||
field = Coalesce(prefix + 'excel_order', prefix + 'order_prd')
|
||||
return category_ordering(prefix + 'category__', excel) + (
|
||||
field.asc(nulls_last=True), prefix + 'name', prefix + 'pk',
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Bounded Excel parsing and transactional product import, shared by admin views."""
|
||||
import hashlib
|
||||
import json
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import DataError, IntegrityError, transaction
|
||||
from django.db.models import Max
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from apps.shop.models import Product, ProductCategory
|
||||
from apps.workspace.models import Workspace
|
||||
|
||||
MAX_ROWS = 5000
|
||||
MAX_FILE_BYTES = 2 * 1024 * 1024
|
||||
HEADERS = {
|
||||
'name': ('name', 'محصول', 'نام محصول'),
|
||||
'category': ('category', 'cate_name', 'دسته', 'مسیر دسته'),
|
||||
'acc_code': ('acc_code', 'کد محصول', 'کد'),
|
||||
'price': ('price', 'قیمت'),
|
||||
'order_prd': ('order_prd', 'size', 'ترتیب نمایش محصول'),
|
||||
'excel_order': ('excel_order', 'ترتیب اکسل محصول'),
|
||||
'order_cat': ('order_cat', 'ترتیب نمایش دسته'),
|
||||
'category_excel_order': ('category_excel_order', 'ترتیب اکسل دسته'),
|
||||
'view_only': ('view_only', 'فقط نمایش'),
|
||||
'attr': ('attr', 'ویژگیها'),
|
||||
'unit': ('unit', 'vahed', 'واحد'),
|
||||
}
|
||||
DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
|
||||
|
||||
|
||||
def text_value(value):
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def integer_value(value, field, maximum=2147483647):
|
||||
try:
|
||||
number = Decimal(text_value(value).translate(DIGITS).replace(',', '').replace('٬', ''))
|
||||
if not number.is_finite() or number != number.to_integral_value() or not 0 <= number <= maximum:
|
||||
raise InvalidOperation
|
||||
return int(number)
|
||||
except (InvalidOperation, ValueError):
|
||||
raise ValidationError({field: 'عدد صحیح نامنفی در محدوده مجاز وارد کنید.'})
|
||||
|
||||
|
||||
def boolean_value(value):
|
||||
normalized = text_value(value).translate(DIGITS).lower()
|
||||
if normalized in ('1', 'true', 'yes', 'بله'):
|
||||
return True
|
||||
if normalized in ('0', 'false', 'no', 'خیر'):
|
||||
return False
|
||||
raise ValidationError({'view_only': 'مقدار بله/خیر یا true/false وارد کنید.'})
|
||||
|
||||
|
||||
def parse_workbook(upload, sheet_name='', header_row=1, mapping=None):
|
||||
"""Never evaluate formula cells; cap compressed and uncompressed workbook sizes."""
|
||||
if not upload.name.lower().endswith('.xlsx'):
|
||||
raise ValidationError('فقط فایل xlsx پذیرفته میشود؛ فایل xls را به xlsx تبدیل کنید.')
|
||||
content = upload.read(MAX_FILE_BYTES + 1)
|
||||
if len(content) > MAX_FILE_BYTES:
|
||||
raise ValidationError('حداکثر حجم فایل ۲ مگابایت است.')
|
||||
try:
|
||||
with ZipFile(BytesIO(content)) as archive:
|
||||
if sum(item.file_size for item in archive.infolist()) > 25 * 1024 * 1024:
|
||||
raise ValidationError('حجم بازشده فایل بیش از حد مجاز است.')
|
||||
book = load_workbook(BytesIO(content), read_only=True, data_only=False)
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception:
|
||||
# Workbook parsers raise several XML/ZIP-specific exception types.
|
||||
raise ValidationError('فایل اکسل خراب است یا ساختار معتبر ندارد.')
|
||||
try:
|
||||
if sheet_name and sheet_name not in book.sheetnames:
|
||||
raise ValidationError('برگه انتخابشده در فایل وجود ندارد.')
|
||||
sheet = book[sheet_name] if sheet_name else book.worksheets[0]
|
||||
if sheet.max_row and sheet.max_row > header_row + MAX_ROWS:
|
||||
raise ValidationError('حداکثر ۵۰۰۰ ردیف پس از سرستون مجاز است؛ ردیفهای خالی انتهای فایل را حذف کنید.')
|
||||
if sheet.max_column and sheet.max_column > 100:
|
||||
raise ValidationError('حداکثر ۱۰۰ ستون مجاز است.')
|
||||
iterator = sheet.iter_rows(min_row=header_row, max_row=header_row + MAX_ROWS + 1, max_col=100)
|
||||
headers = [text_value(cell.value) for cell in next(iterator, ())]
|
||||
indexes = resolve_headers(headers, mapping or {})
|
||||
rows = []
|
||||
for number, cells in enumerate(iterator, header_row + 1):
|
||||
if not any(cell.value is not None for cell in cells):
|
||||
continue
|
||||
if number > header_row + MAX_ROWS:
|
||||
raise ValidationError('حداکثر ۵۰۰۰ ردیف پس از سرستون مجاز است.')
|
||||
values = {key: text_value(cells[index].value) for key, index in indexes.items()}
|
||||
formulas = [key for key, index in indexes.items() if cells[index].data_type == 'f']
|
||||
rows.append({'row': number, 'values': values, 'formulas': formulas})
|
||||
if not rows:
|
||||
raise ValidationError('فایل ردیف محصول ندارد.')
|
||||
return rows
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception:
|
||||
raise ValidationError('محتوای برگه اکسل خراب است یا خوانده نمیشود.')
|
||||
finally:
|
||||
book.close()
|
||||
|
||||
|
||||
def resolve_headers(headers, mapping):
|
||||
if set(mapping) - set(HEADERS):
|
||||
raise ValidationError('کلید نگاشت ستون ناشناخته است.')
|
||||
indexes = {}
|
||||
for key, aliases in HEADERS.items():
|
||||
names = (mapping[key],) if key in mapping else aliases
|
||||
matches = [index for index, name in enumerate(headers) if name and name in names]
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(f'ستون {key} تکراری یا مبهم است.')
|
||||
if matches:
|
||||
indexes[key] = matches[0]
|
||||
if len(set(indexes.values())) != len(indexes):
|
||||
raise ValidationError('یک ستون به چند فیلد نگاشت شده است.')
|
||||
if not {'name', 'category', 'acc_code'}.issubset(indexes):
|
||||
raise ValidationError('ستونهای نام محصول، دسته و کد محصول الزامی هستند.')
|
||||
return indexes
|
||||
|
||||
|
||||
def catalog_fingerprint(workspace):
|
||||
"""Detect intervening writes between preview and confirmation."""
|
||||
digest = hashlib.sha256()
|
||||
for model in (ProductCategory, Product):
|
||||
for item in model.objects.filter(sys_ws=workspace).order_by('pk').values().iterator():
|
||||
digest.update(json.dumps(item, sort_keys=True, default=str, ensure_ascii=False).encode())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def lock_catalog(workspace):
|
||||
"""Lock existing rows in stable order as well as the workspace during confirmation."""
|
||||
Workspace.objects.select_for_update().get(pk=workspace.pk)
|
||||
for model in (ProductCategory, Product):
|
||||
list(model.objects.select_for_update().filter(sys_ws=workspace).order_by('pk').values_list('pk', flat=True))
|
||||
|
||||
|
||||
def category_for_path(workspace, path, create):
|
||||
names = [name.strip() for name in path.split('/')]
|
||||
if not names or len(names) > 10 or any(not name or len(name) > 60 for name in names):
|
||||
raise ValidationError({'category': 'مسیر دسته با /، حداکثر ۱۰ سطح و نامهای ۱ تا ۶۰ کاراکتری لازم است.'})
|
||||
parent = None
|
||||
for name in names:
|
||||
matches = ProductCategory.objects.filter(sys_ws=workspace, name=name, parent=parent)
|
||||
if matches.count() > 1:
|
||||
raise ValidationError({'category': 'دسته تکراری و مبهم است؛ ابتدا دستههای موجود را اصلاح کنید.'})
|
||||
category = matches.first()
|
||||
if category is None:
|
||||
if not create:
|
||||
raise ValidationError({'category': f'دسته {name} وجود ندارد و ساخت دسته غیرفعال است.'})
|
||||
category = ProductCategory(sys_ws=workspace, name=name, parent=parent)
|
||||
category.full_clean()
|
||||
category.save()
|
||||
parent = category
|
||||
return parent
|
||||
|
||||
|
||||
def product_values(values):
|
||||
fields = {'name': values.get('name', '').strip()}
|
||||
if not fields['name']:
|
||||
raise ValidationError({'name': 'نام محصول الزامی است.'})
|
||||
for key in ('price', 'order_prd', 'excel_order'):
|
||||
if values.get(key):
|
||||
maximum = 9223372036854775807 if key == 'price' else 2147483647
|
||||
fields[key] = integer_value(values[key], key, maximum)
|
||||
if values.get('view_only'):
|
||||
fields['view_only'] = boolean_value(values['view_only'])
|
||||
if values.get('attr'):
|
||||
try:
|
||||
fields['attr'] = json.loads(values['attr'])
|
||||
if not isinstance(fields['attr'], dict):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationError({'attr': 'ویژگیها باید یک شیء JSON معتبر باشد.'})
|
||||
return fields
|
||||
|
||||
|
||||
def apply_product_row(workspace, row, options):
|
||||
values = row['values']
|
||||
if row.get('formulas'):
|
||||
raise ValidationError({key: 'فرمول مجاز نیست؛ مقدار ثابت جایگزین کنید.' for key in row['formulas']})
|
||||
code = values.get('acc_code', '').strip()
|
||||
if not code:
|
||||
raise ValidationError({'acc_code': 'کد ثابت و غیرخالی محصول الزامی است.'})
|
||||
matches = Product.objects.filter(sys_ws=workspace, acc_code=code)
|
||||
if matches.count() > 1:
|
||||
raise ValidationError({'acc_code': 'کد در دیتابیس تکراری است؛ تطبیق یکتا امکانپذیر نیست.'})
|
||||
product = matches.first()
|
||||
mode = options['mode']
|
||||
if product is not None and mode == 'create':
|
||||
raise ValidationError({'acc_code': 'محصول موجود است؛ حالت ایجاد اجازه بهروزرسانی ندارد.'})
|
||||
if product is None and mode == 'update':
|
||||
raise ValidationError({'acc_code': 'محصول برای بهروزرسانی پیدا نشد.'})
|
||||
created = product is None
|
||||
fields = product_values(values)
|
||||
category = category_for_path(workspace, values.get('category', ''), options['create_categories'])
|
||||
changed_order = False
|
||||
for source, target in (('order_cat', 'order_cat'), ('category_excel_order', 'excel_order')):
|
||||
if values.get(source):
|
||||
setattr(category, target, integer_value(values[source], source))
|
||||
changed_order = True
|
||||
if changed_order:
|
||||
category.full_clean()
|
||||
category.save()
|
||||
product = product or Product(sys_ws=workspace, acc_code=code, price=0)
|
||||
product.category = category
|
||||
for key, value in fields.items():
|
||||
setattr(product, key, value)
|
||||
if values.get('unit'):
|
||||
product.attr = dict(product.attr or {}, acc_vahed=values['unit'])
|
||||
set_product_order(product, values, options['ordering'])
|
||||
product.full_clean()
|
||||
product.save()
|
||||
return 'created' if created else 'updated'
|
||||
|
||||
|
||||
def set_product_order(product, values, ordering):
|
||||
if ordering == 'append' and product.pk is None and not values.get('order_prd'):
|
||||
maximum = Product.objects.filter(category=product.category).aggregate(value=Max('order_prd'))['value']
|
||||
product.order_prd = (maximum or 0) + 1
|
||||
|
||||
|
||||
def import_products(workspace, rows, options, dry_run=False):
|
||||
"""Each rejected row rolls back its categories too; atomic mode rolls back all rows."""
|
||||
if options.get('mode') not in ('create', 'update', 'upsert') or options.get('errors') not in ('atomic', 'skip') or options.get('ordering') not in ('preserve', 'rows', 'append') or not isinstance(options.get('create_categories'), bool):
|
||||
raise ValidationError('تنظیمات ورود معتبر نیست.')
|
||||
results, seen, category_orders = [], set(), {}
|
||||
with transaction.atomic():
|
||||
Workspace.objects.select_for_update().get(pk=workspace.pk)
|
||||
for position, row in enumerate(rows, 1):
|
||||
previous_orders = dict(category_orders)
|
||||
values = dict(row['values'])
|
||||
if options['ordering'] == 'rows' and not values.get('order_prd'):
|
||||
values['order_prd'] = str(position)
|
||||
candidate = dict(row, values=values)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
code = values.get('acc_code', '').strip()
|
||||
if code in seen:
|
||||
raise ValidationError({'acc_code': 'کد در همین فایل تکراری است.'})
|
||||
seen.add(code)
|
||||
check_category_orders(values, category_orders)
|
||||
status = apply_product_row(workspace, candidate, options)
|
||||
results.append(dict(row, status=status, errors={}))
|
||||
except ValidationError as error:
|
||||
category_orders = previous_orders
|
||||
errors = error.message_dict if hasattr(error, 'message_dict') else {'row': error.messages}
|
||||
results.append(dict(row, status='error', errors=errors))
|
||||
except (IntegrityError, DataError):
|
||||
category_orders = previous_orders
|
||||
results.append(dict(row, status='error', errors={'row': ['تعارض دیتابیس؛ اطلاعات یا کد تکراری را بررسی کنید.']}))
|
||||
has_errors = any(result['status'] == 'error' for result in results)
|
||||
if dry_run or (has_errors and options['errors'] == 'atomic'):
|
||||
transaction.set_rollback(True)
|
||||
return {'rows': results, 'errors': sum(row['status'] == 'error' for row in results),
|
||||
'created': sum(row['status'] == 'created' for row in results),
|
||||
'updated': sum(row['status'] == 'updated' for row in results),
|
||||
'committed': not dry_run and not (has_errors and options['errors'] == 'atomic')}
|
||||
|
||||
|
||||
def check_category_orders(values, orders):
|
||||
path = '/'.join(part.strip() for part in values.get('category', '').split('/'))
|
||||
for key in ('order_cat', 'category_excel_order'):
|
||||
if values.get(key):
|
||||
number = integer_value(values[key], key)
|
||||
previous = orders.get((path, key), number)
|
||||
if previous != number:
|
||||
raise ValidationError({key: 'ترتیب همین دسته در ردیفهای فایل ناسازگار است.'})
|
||||
orders[(path, key)] = number
|
||||
@@ -9,6 +9,7 @@ from apps.customer.models import Customer as CustomerModel
|
||||
from django.http import HttpResponse
|
||||
import pandas as pd
|
||||
from django.utils import timezone
|
||||
from apps.shop.ordering import product_ordering
|
||||
|
||||
|
||||
def retrieve_order(cuser_role_uuid: str, order_id: int, workspace_uuid: str) -> OrderModel:
|
||||
@@ -104,20 +105,12 @@ def retrieve_order_operators(request, order_id) -> OrderModel:
|
||||
def retrieve_order_item_excel(request, order_id):
|
||||
try:
|
||||
workspace_uuid = get_user_workspace_uuid(request)
|
||||
cuser_role_uuid = get_cuser_role_uuid(request)
|
||||
customer_responsibles = CustomerResponsibleModel.objects.filter(cuser_role__uuid=cuser_role_uuid,
|
||||
sys_ws__uuid=workspace_uuid)
|
||||
|
||||
#responsible_group_ids = customer_responsibles.values_list('responsible_group__id', flat=True)
|
||||
|
||||
#order_items = OrderItemModel.objects.filter(order_id=order_id,
|
||||
# order__responsible_group_id__in=responsible_group_ids)
|
||||
|
||||
|
||||
order_items = OrderItemModel.objects.filter(order_id=order_id)
|
||||
|
||||
|
||||
df = pd.DataFrame(list(order_items.values('product__acc_code', 'product__category__name', 'product_name', 'amount', 'product__attr__acc_vahed')))
|
||||
OrderModel.objects.get(pk=order_id, sys_ws__uuid=workspace_uuid)
|
||||
order_items = OrderItemModel.objects.filter(order_id=order_id).order_by(
|
||||
*product_ordering(prefix='product__', excel=True), 'pk',
|
||||
)
|
||||
columns = ['product__acc_code', 'product__category__name', 'product_name', 'amount', 'product__attr__acc_vahed']
|
||||
df = pd.DataFrame.from_records(list(order_items.values(*columns)), columns=columns)
|
||||
|
||||
#df['واحد سنجش'] = ''
|
||||
df['عامل کنترل موجودی اول'] = ''
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from django.core.exceptions import ValidationError as ModelValidationError
|
||||
from django.db import DataError, IntegrityError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import exceptions
|
||||
from apps.workspace.models import Workspace
|
||||
|
||||
|
||||
def validate_catalog_save(record):
|
||||
try:
|
||||
record.full_clean()
|
||||
record.save()
|
||||
except ModelValidationError as error:
|
||||
raise exceptions.ValidationError(error.message_dict if hasattr(error, 'message_dict') else error.messages)
|
||||
except (DataError, IntegrityError):
|
||||
raise exceptions.ValidationError('دادهها با محدودیتهای دیتابیس سازگار نیستند؛ اطلاعات را تازه کنید.')
|
||||
return record
|
||||
|
||||
|
||||
def update_catalog_record(model, serializer_type, workspace_uuid, record_id, values):
|
||||
workspace = get_object_or_404(Workspace.objects.select_for_update(), uuid=workspace_uuid)
|
||||
record = get_object_or_404(model.objects.select_for_update(), pk=record_id, sys_ws=workspace)
|
||||
serializer = serializer_type(record, data=values, partial=True, context={'workspace': workspace})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
for key, value in serializer.validated_data.items():
|
||||
setattr(record, key, value)
|
||||
return validate_catalog_save(record)
|
||||
|
||||
|
||||
def delete_catalog_record(model, check, workspace_uuid, record_id):
|
||||
workspace = get_object_or_404(Workspace.objects.select_for_update(), uuid=workspace_uuid)
|
||||
record = get_object_or_404(model.objects.select_for_update(), pk=record_id, sys_ws=workspace)
|
||||
check(record)
|
||||
return record.delete()
|
||||
@@ -1,55 +1,29 @@
|
||||
from django.db import transaction
|
||||
from apps.shop.models import Product as ProductModel
|
||||
from decimal import Decimal
|
||||
from rest_framework import exceptions
|
||||
from apps.shop.management_serializers import ProductManagementSerializer
|
||||
from apps.workspace.models import Workspace
|
||||
|
||||
|
||||
def create_product(name: str, price: Decimal, category: int, acc_code: str = None, attr: str = None, workspace=None) -> ProductModel:
|
||||
try:
|
||||
product_obj = ProductModel()
|
||||
product_obj.name = name
|
||||
product_obj.price = price
|
||||
product_obj.category_id = category
|
||||
if acc_code is not None:
|
||||
product_obj.acc_code = acc_code
|
||||
if attr is not None:
|
||||
product_obj.attr = attr
|
||||
product_obj.sys_ws = workspace
|
||||
|
||||
product_obj.full_clean()
|
||||
product_obj.save()
|
||||
|
||||
return product_obj
|
||||
|
||||
except Exception as exp:
|
||||
message = f'Error Create Product: {exp}'
|
||||
exceptions.ValidationError({'error': message})
|
||||
from apps.shop.services.catalog_records import validate_catalog_save, update_catalog_record, delete_catalog_record
|
||||
|
||||
|
||||
def update_product(product_id: int, name: str, price: Decimal, category: int, acc_code: str = None, attr: str = None, workspace_uuid: str = None) -> ProductModel:
|
||||
try:
|
||||
|
||||
product_obj = ProductModel.objects.get(id=product_id, sys_ws__uuid=workspace_uuid)
|
||||
|
||||
product_obj.name = name
|
||||
product_obj.price = price
|
||||
product_obj.category_id = category
|
||||
if acc_code is not None:
|
||||
product_obj.acc_code = acc_code
|
||||
if attr is not None:
|
||||
product_obj.attr = attr
|
||||
product_obj.full_clean()
|
||||
product_obj.save()
|
||||
|
||||
return product_obj
|
||||
except Exception as exp:
|
||||
message = f'Error Updating Product: {exp}'
|
||||
raise exceptions.ValidationError({'error': message})
|
||||
@transaction.atomic
|
||||
def create_product(name, price, category, acc_code=None, attr=None, workspace=None, **fields):
|
||||
Workspace.objects.select_for_update().get(pk=workspace.pk)
|
||||
data = dict(name=name, price=price, category=category, acc_code=acc_code, attr=attr, **fields)
|
||||
serializer = ProductManagementSerializer(data=data, context={'workspace': workspace})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return validate_catalog_save(ProductModel(sys_ws=workspace, **serializer.validated_data))
|
||||
|
||||
|
||||
def delete_product(product_id: int, workspace_uuid: str) -> ProductModel:
|
||||
try:
|
||||
product_obj = ProductModel.objects.get(id=product_id, sys_ws__uuid=workspace_uuid).delete()
|
||||
return product_obj
|
||||
except Exception as exp:
|
||||
message = f'Error Deleting Product: {exp}'
|
||||
raise exceptions.ValidationError({'error': message})
|
||||
@transaction.atomic
|
||||
def update_product(product_id, name=None, price=None, category=None, acc_code=None, attr=None, workspace_uuid=None, **fields):
|
||||
values = {key: value for key, value in dict(name=name, price=price, category=category, acc_code=acc_code, attr=attr).items() if value is not None}
|
||||
values.update(fields)
|
||||
return update_catalog_record(ProductModel, ProductManagementSerializer, workspace_uuid, product_id, values)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_product(product_id, workspace_uuid):
|
||||
from apps.shop.views.management import ensure_product_deletable
|
||||
return delete_catalog_record(ProductModel, ensure_product_deletable, workspace_uuid, product_id)
|
||||
@@ -1,40 +1,29 @@
|
||||
from django.db import transaction
|
||||
from apps.shop.models import ProductCategory as ProductCategoryModel
|
||||
from rest_framework import exceptions
|
||||
from apps.shop.management_serializers import CategoryManagementSerializer
|
||||
from apps.workspace.models import Workspace
|
||||
|
||||
|
||||
def create_product_category(name: str, parent: int = None, workspace=None) -> ProductCategoryModel:
|
||||
try:
|
||||
product_category_obj = ProductCategoryModel(name=name, sys_ws=workspace)
|
||||
|
||||
if parent is not None:
|
||||
product_category_obj.parent_id = parent
|
||||
|
||||
product_category_obj.full_clean()
|
||||
product_category_obj.save()
|
||||
|
||||
return product_category_obj
|
||||
|
||||
except Exception as exp:
|
||||
message = f'Error Create ProductCategory: {exp}'
|
||||
exceptions.ValidationError({'error': message})
|
||||
from apps.shop.services.catalog_records import validate_catalog_save, update_catalog_record, delete_catalog_record
|
||||
|
||||
|
||||
def update_product_category(category_id: int, name: str, workspace_uuid: str) -> ProductCategoryModel:
|
||||
try:
|
||||
product_category_obj = ProductCategoryModel.objects.get(id=category_id, sys_ws__uuid=workspace_uuid)
|
||||
product_category_obj.name = name
|
||||
product_category_obj.full_clean()
|
||||
product_category_obj.save()
|
||||
return product_category_obj
|
||||
except Exception as exp:
|
||||
message = f'Error Updating ProductCategory: {exp}'
|
||||
raise exceptions.ValidationError({'error': message})
|
||||
@transaction.atomic
|
||||
def create_product_category(name, parent=None, workspace=None, **fields):
|
||||
Workspace.objects.select_for_update().get(pk=workspace.pk)
|
||||
serializer = CategoryManagementSerializer(data=dict(name=name, parent=parent, **fields), context={'workspace': workspace})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return validate_catalog_save(ProductCategoryModel(sys_ws=workspace, **serializer.validated_data))
|
||||
|
||||
|
||||
def delete_product_category(category_id: int, workspace_uuid: str) -> ProductCategoryModel:
|
||||
try:
|
||||
product_category_obj = ProductCategoryModel.objects.get(id=category_id, sys_ws__uuid=workspace_uuid).delete()
|
||||
return product_category_obj
|
||||
except Exception as exp:
|
||||
message = f'Error Deleting ProductCategory: {exp}'
|
||||
raise exceptions.ValidationError({'error': message})
|
||||
@transaction.atomic
|
||||
def update_product_category(category_id, name=None, workspace_uuid=None, **fields):
|
||||
values = dict(fields)
|
||||
if name is not None:
|
||||
values['name'] = name
|
||||
return update_catalog_record(ProductCategoryModel, CategoryManagementSerializer, workspace_uuid, category_id, values)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_product_category(category_id, workspace_uuid):
|
||||
from apps.shop.views.management import ensure_category_deletable
|
||||
return delete_catalog_record(ProductCategoryModel, ensure_category_deletable, workspace_uuid, category_id)
|
||||
@@ -0,0 +1,5 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% block object-tools-items %}
|
||||
{% if can_import %}<li><a href="{% url 'admin:shop_product_import' %}">ورود محصولات از اکسل</a></li>{% endif %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% block content %}
|
||||
<div dir="rtl">
|
||||
<p><a href="{% url 'admin:shop_product_changelist' %}">فهرست محصولات</a> | <a href="{% url 'admin:shop_product_import_template' %}">دریافت فایل نمونه</a></p>
|
||||
<p>تطبیق محصولات بر اساس کد در فضای کاری انتخابشده است. مسیر دسته با / جدا میشود. فیلدهای خالی مقدار فعلی را حفظ میکنند؛ قیمت محصول جدید بدون قیمت صفر است. کدهای دارای صفر ابتدایی را در اکسل به صورت متن ذخیره کنید. فرمول پذیرفته نمیشود.</p>
|
||||
{% if problem %}<p class="errornote">{{ problem }}</p>{% endif %}
|
||||
{% if batch %}
|
||||
<h2>نتیجه ورود شماره {{ batch.pk }} — {{ batch.filename }}</h2>
|
||||
<p>ایجاد: {{ result.created }} | بهروزرسانی: {{ result.updated }} | خطا: {{ result.errors }}</p>
|
||||
{% if batch.confirmed_at %}<p>ثبت نهایی انجام شد: {{ batch.confirmed_at }}</p>{% else %}<p>هنوز محصولی ثبت نشده است. اعتبار پیشنمایش تا {{ batch.expires_at }}.</p>{% endif %}
|
||||
<p><a href="{% url 'admin:shop_product_import_report' batch.pk %}">دریافت گزارش همه ردیفها و خطاها</a></p>
|
||||
<table><thead><tr><th>ردیف</th><th>کد</th><th>نام</th><th>دسته</th><th>عملیات</th><th>خطا</th></tr></thead><tbody>
|
||||
{% for row in result.rows|slice:':100' %}<tr><td>{{ row.row }}</td><td>{{ row.values.acc_code }}</td><td>{{ row.values.name }}</td><td>{{ row.values.category }}</td><td>{{ row.status }}</td><td>{{ row.errors }}</td></tr>{% endfor %}
|
||||
</tbody></table><p>در صفحه حداکثر ۱۰۰ ردیف نمایش داده میشود؛ گزارش اکسل شامل تمام ردیفهاست.</p>
|
||||
{% if can_confirm %}<form method="post">{% csrf_token %}<input type="hidden" name="batch_id" value="{{ batch.pk }}"><button type="submit" name="confirm" value="1">تأیید و ثبت نهایی</button></form>{% endif %}
|
||||
{% endif %}
|
||||
<h2>بارگذاری و پیشنمایش فایل</h2>
|
||||
<form method="post" enctype="multipart/form-data">{% csrf_token %}{{ form.as_p }}<input type="submit" value="اعتبارسنجی و پیشنمایش"></form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,338 @@
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from django.apps import apps
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
from apps.shop.import_models import ProductImportBatch
|
||||
from apps.shop.models import Product, ProductCategory
|
||||
from apps.shop.product_import import parse_workbook, import_products, HEADERS
|
||||
from apps.workspace.models import Workspace
|
||||
|
||||
|
||||
class ExcelImportTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.workspace = Workspace.objects.create(name='Main', username='main')
|
||||
cls.other = Workspace.objects.create(name='Other', username='other')
|
||||
cls.user = get_user_model().objects.create_user(username='staff', mobile=9123456789, password='secret', is_active=True, is_staff=True, is_superuser=True)
|
||||
|
||||
def setUp(self):
|
||||
self.client.force_login(self.user)
|
||||
self.options = {'mode': 'upsert', 'create_categories': True, 'errors': 'atomic', 'ordering': 'preserve'}
|
||||
|
||||
def row(self, code='001', name='Product', category='Parent/Child', **values):
|
||||
return {'row': 2, 'values': dict(acc_code=code, name=name, category=category, **values), 'formulas': []}
|
||||
|
||||
def upload(self, headers=None, data=None, name='products.xlsx'):
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = 'Products'
|
||||
sheet.append(headers or ['name', 'category', 'acc_code', 'price'])
|
||||
for row in data or [['Product', 'Parent/Child', '001', 1200]]:
|
||||
sheet.append(row)
|
||||
content = BytesIO()
|
||||
workbook.save(content)
|
||||
return SimpleUploadedFile(name, content.getvalue())
|
||||
|
||||
def preview(self, **overrides):
|
||||
data = dict(workspace=self.workspace.pk, file=self.upload(), header_row=1,
|
||||
mode='upsert', create_categories='on', errors='atomic', ordering='preserve')
|
||||
data.update(overrides)
|
||||
return self.client.post(reverse('admin:shop_product_import'), data)
|
||||
|
||||
def confirm(self, batch):
|
||||
return self.client.post(reverse('admin:shop_product_import'), {'confirm': '1', 'batch_id': batch.pk})
|
||||
|
||||
def test_parse_aliases_persian_digits_and_text_codes(self):
|
||||
rows = parse_workbook(self.upload(['نام محصول', 'دسته', 'کد محصول', 'قیمت'], [['P', 'C', '0001', '۱٬۲۰۰']]))
|
||||
result = import_products(self.workspace, rows, self.options)
|
||||
self.assertTrue(result['committed'])
|
||||
self.assertEqual(Product.objects.get().acc_code, '0001')
|
||||
self.assertEqual(Product.objects.get().price, 1200)
|
||||
|
||||
def test_manual_mapping_sheet_and_header_row(self):
|
||||
rows = parse_workbook(self.upload(['Title', 'Group', 'Code']), 'Products', 1, {'name': 'Title', 'category': 'Group', 'acc_code': 'Code'})
|
||||
self.assertEqual(rows[0]['values']['acc_code'], '001')
|
||||
with self.assertRaises(ValidationError):
|
||||
parse_workbook(self.upload(), 'Absent')
|
||||
|
||||
def test_duplicate_headers_mapping_and_wrong_file_rejected(self):
|
||||
for file, mapping in ((self.upload(['name', 'name', 'category', 'acc_code']), {}),
|
||||
(self.upload(), {'unknown': 'X'}),
|
||||
(self.upload(name='bad.xls'), {}),
|
||||
(SimpleUploadedFile('bad.xlsx', b'broken'), {})):
|
||||
with self.assertRaises(ValidationError):
|
||||
parse_workbook(file, mapping=mapping)
|
||||
|
||||
def test_formula_is_row_error(self):
|
||||
rows = parse_workbook(self.upload(data=[['=1+1', 'C', 'X', 1]]))
|
||||
result = import_products(self.workspace, rows, self.options)
|
||||
self.assertEqual(result['errors'], 1)
|
||||
self.assertEqual(Product.objects.count(), 0)
|
||||
|
||||
def test_dry_run_creates_no_products_or_categories(self):
|
||||
result = import_products(self.workspace, [self.row()], self.options, dry_run=True)
|
||||
self.assertEqual(result['created'], 1)
|
||||
self.assertFalse(result['committed'])
|
||||
self.assertEqual(ProductCategory.objects.count(), 0)
|
||||
self.assertEqual(Product.objects.count(), 0)
|
||||
|
||||
def test_nested_categories_and_independent_orders(self):
|
||||
result = import_products(self.workspace, [self.row(order_prd='3', excel_order='8', order_cat='6', category_excel_order='9', unit='عدد', view_only='بله', attr='{"color": "blue"}')], self.options)
|
||||
self.assertTrue(result['committed'])
|
||||
product = Product.objects.get()
|
||||
self.assertEqual(product.category.parent.name, 'Parent')
|
||||
self.assertEqual((product.order_prd, product.excel_order, product.category.order_cat, product.category.excel_order), (3, 8, 6, 9))
|
||||
self.assertEqual(product.attr, {'color': 'blue', 'acc_vahed': 'عدد'})
|
||||
self.assertTrue(product.view_only)
|
||||
|
||||
def test_atomic_errors_rollback_everything(self):
|
||||
result = import_products(self.workspace, [self.row(), self.row('002', price='-1')], self.options)
|
||||
self.assertFalse(result['committed'])
|
||||
self.assertEqual(ProductCategory.objects.count(), 0)
|
||||
self.assertEqual(Product.objects.count(), 0)
|
||||
self.assertIn('price', result['rows'][1]['errors'])
|
||||
|
||||
def test_skip_errors_rollback_categories_of_rejected_row(self):
|
||||
self.options['errors'] = 'skip'
|
||||
result = import_products(self.workspace, [self.row(), self.row('002', category='Rejected', name='X'*201)], self.options)
|
||||
self.assertTrue(result['committed'])
|
||||
self.assertEqual(Product.objects.count(), 1)
|
||||
self.assertFalse(ProductCategory.objects.filter(name='Rejected').exists())
|
||||
|
||||
def test_duplicate_code_in_file_and_database_are_errors(self):
|
||||
result = import_products(self.workspace, [self.row(), self.row()], self.options)
|
||||
self.assertEqual(result['errors'], 1)
|
||||
category = ProductCategory.objects.create(sys_ws=self.workspace, name='C')
|
||||
for _ in range(2):
|
||||
Product.objects.create(sys_ws=self.workspace, category=category, name='P', acc_code='001', price=0)
|
||||
result = import_products(self.workspace, [self.row()], self.options)
|
||||
self.assertEqual(result['errors'], 1)
|
||||
|
||||
def test_matching_is_scoped_to_workspace(self):
|
||||
import_products(self.other, [self.row()], self.options)
|
||||
import_products(self.workspace, [self.row(name='Mine')], self.options)
|
||||
self.assertEqual(Product.objects.count(), 2)
|
||||
self.assertEqual(Product.objects.get(sys_ws=self.other).name, 'Product')
|
||||
|
||||
def test_update_create_modes_and_preserve_blank(self):
|
||||
import_products(self.workspace, [self.row(price='80', order_prd='4')], self.options)
|
||||
self.options['mode'] = 'create'
|
||||
self.assertEqual(import_products(self.workspace, [self.row()], self.options)['errors'], 1)
|
||||
self.options['mode'] = 'update'
|
||||
self.assertEqual(import_products(self.workspace, [self.row('missing')], self.options)['errors'], 1)
|
||||
result = import_products(self.workspace, [self.row(name='Updated', price='')], self.options)
|
||||
self.assertTrue(result['committed'])
|
||||
self.assertEqual(Product.objects.get().price, 80)
|
||||
self.assertEqual(Product.objects.get().order_prd, 4)
|
||||
|
||||
def test_category_creation_disabled_and_ambiguous(self):
|
||||
self.options['create_categories'] = False
|
||||
self.assertEqual(import_products(self.workspace, [self.row()], self.options)['errors'], 1)
|
||||
self.options['create_categories'] = True
|
||||
for _ in range(2):
|
||||
ProductCategory.objects.create(sys_ws=self.workspace, name='C')
|
||||
self.assertEqual(import_products(self.workspace, [self.row(category='C')], self.options)['errors'], 1)
|
||||
|
||||
def test_conflicting_category_orders(self):
|
||||
result = import_products(self.workspace, [self.row(order_cat='1'), self.row('002', order_cat='2')], self.options)
|
||||
self.assertEqual(result['errors'], 1)
|
||||
self.assertFalse(result['committed'])
|
||||
|
||||
def test_rows_order_and_append(self):
|
||||
self.options['ordering'] = 'rows'
|
||||
import_products(self.workspace, [self.row('1'), self.row('2')], self.options)
|
||||
self.assertEqual(list(Product.objects.order_by('acc_code').values_list('order_prd', flat=True)), [1, 2])
|
||||
self.options['ordering'] = 'append'
|
||||
import_products(self.workspace, [self.row('3')], self.options)
|
||||
self.assertEqual(Product.objects.get(acc_code='3').order_prd, 3)
|
||||
|
||||
def test_bad_numbers_booleans_attributes_and_category_path(self):
|
||||
for values in ({'price': '1.5'}, {'price': 'NaN'}, {'view_only': 'maybe'}, {'attr': '[]'}, {'category': 'A//B'}, {'price': '999999999999999999999'}):
|
||||
self.assertEqual(import_products(self.workspace, [self.row(**values)], self.options)['errors'], 1)
|
||||
|
||||
def test_admin_preview_confirm_replay_and_audit(self):
|
||||
response = self.preview()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
batch = ProductImportBatch.objects.get()
|
||||
self.assertEqual(Product.objects.count(), 0)
|
||||
self.assertContains(response, 'ثبت نهایی')
|
||||
self.assertEqual(self.confirm(batch).status_code, 200)
|
||||
batch.refresh_from_db()
|
||||
self.assertIsNotNone(batch.confirmed_at)
|
||||
self.assertEqual(Product.objects.count(), 1)
|
||||
self.assertContains(self.confirm(batch), 'ثبت نهایی انجام شد')
|
||||
self.assertEqual(Product.objects.count(), 1)
|
||||
from django.contrib.admin.models import LogEntry
|
||||
self.assertEqual(LogEntry.objects.filter(object_repr=f'Excel import #{batch.pk}').count(), 1)
|
||||
|
||||
def test_admin_expired_preview_rejected(self):
|
||||
self.preview()
|
||||
batch = ProductImportBatch.objects.get()
|
||||
batch.expires_at = timezone.now()-timedelta(seconds=1)
|
||||
batch.save()
|
||||
self.assertContains(self.confirm(batch), 'منقضی')
|
||||
self.assertEqual(Product.objects.count(), 0)
|
||||
|
||||
def test_admin_changed_catalog_requires_new_preview(self):
|
||||
self.preview()
|
||||
batch = ProductImportBatch.objects.get()
|
||||
ProductCategory.objects.create(sys_ws=self.workspace, name='Changed')
|
||||
self.assertContains(self.confirm(batch), 'تغییر کرده')
|
||||
self.assertEqual(Product.objects.count(), 0)
|
||||
|
||||
def test_other_admin_cannot_confirm_or_download_batch(self):
|
||||
self.preview()
|
||||
batch = ProductImportBatch.objects.get()
|
||||
other = get_user_model().objects.create_user(username='otherstaff', mobile=9123456790, password='secret', is_active=True, is_staff=True, is_superuser=True)
|
||||
self.client.force_login(other)
|
||||
self.assertEqual(self.confirm(batch).status_code, 404)
|
||||
self.assertEqual(self.client.get(reverse('admin:shop_product_import_report', args=[batch.pk])).status_code, 404)
|
||||
|
||||
def test_template_and_formula_safe_report(self):
|
||||
response = self.client.get(reverse('admin:shop_product_import_template'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
sheet = load_workbook(BytesIO(response.content)).active
|
||||
self.assertEqual([cell.value for cell in sheet[1]], list(HEADERS))
|
||||
self.preview(file=self.upload(data=[['=1+1', 'C', 'X', 1]]))
|
||||
batch = ProductImportBatch.objects.get()
|
||||
response = self.client.get(reverse('admin:shop_product_import_report', args=[batch.pk]))
|
||||
sheet = load_workbook(BytesIO(response.content)).active
|
||||
self.assertEqual(sheet.cell(2, 2).data_type, 's')
|
||||
self.assertEqual(sheet.cell(2, 2).value, '=1+1')
|
||||
|
||||
def test_nonstaff_and_staff_without_permissions_cannot_import(self):
|
||||
self.user.is_superuser = False
|
||||
self.user.save()
|
||||
self.assertEqual(self.preview().status_code, 403)
|
||||
self.assertEqual(self.client.get(reverse('admin:shop_product_import_template')).status_code, 403)
|
||||
|
||||
def test_atomic_preview_cannot_be_forced_to_commit_errors(self):
|
||||
self.preview(file=self.upload(data=[['Valid', 'C', '1', 10], ['Invalid', 'D', '2', -1]]))
|
||||
batch = ProductImportBatch.objects.get()
|
||||
self.confirm(batch)
|
||||
batch.refresh_from_db()
|
||||
self.assertIsNone(batch.confirmed_at)
|
||||
self.assertEqual(Product.objects.count(), 0)
|
||||
|
||||
def test_all_application_models_have_working_admin_lists_and_forms(self):
|
||||
labels = {'shop', 'workspace', 'customer', 'cuser', 'jwt_auth', 'lead', 'ticket', 'customform', 'reminder'}
|
||||
for model in apps.get_models():
|
||||
if model._meta.app_label not in labels:
|
||||
continue
|
||||
with self.subTest(model=model._meta.label):
|
||||
self.assertIn(model, admin.site._registry)
|
||||
url = reverse(f'admin:{model._meta.app_label}_{model._meta.model_name}_changelist')
|
||||
self.assertEqual(self.client.get(url).status_code, 200)
|
||||
model_admin = admin.site._registry[model]
|
||||
from django.test import RequestFactory
|
||||
request = RequestFactory().get('/')
|
||||
request.user = self.user
|
||||
if model_admin.has_add_permission(request):
|
||||
url = reverse(f'admin:{model._meta.app_label}_{model._meta.model_name}_add')
|
||||
self.assertEqual(self.client.get(url).status_code, 200)
|
||||
|
||||
def test_cross_workspace_relation_and_category_cycle_rejected(self):
|
||||
category = ProductCategory.objects.create(sys_ws=self.other, name='C')
|
||||
response = self.client.post(reverse('admin:shop_product_add'), {'name': 'P', 'price': '1', 'category': category.pk, 'sys_ws': self.workspace.pk, 'acc_code': '1', 'view_only': '2'})
|
||||
self.assertContains(response, 'فضای کاری دیگری')
|
||||
self.assertEqual(Product.objects.count(), 0)
|
||||
category.parent = category
|
||||
from utils.admin_base import RelatedWorkspaceForm
|
||||
from django.forms import modelform_factory
|
||||
form = modelform_factory(ProductCategory, form=RelatedWorkspaceForm, fields=['name', 'parent', 'sys_ws'])(data={'name': 'C', 'parent': category.pk, 'sys_ws': self.other.pk}, instance=category)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('parent', form.errors)
|
||||
|
||||
def test_product_export_can_be_reimported_with_category_path_and_attributes(self):
|
||||
import_products(self.workspace, [self.row(price='70', attr='{"color":"blue"}', view_only='true')], self.options)
|
||||
from apps.shop.admin import export_products
|
||||
response = export_products(Product.objects.all())
|
||||
rows = parse_workbook(SimpleUploadedFile('export.xlsx', response.content))
|
||||
self.assertEqual(rows[0]['values']['category'], 'Parent/Child')
|
||||
result = import_products(self.workspace, rows, self.options)
|
||||
self.assertEqual(result['updated'], 1)
|
||||
self.assertEqual(ProductCategory.objects.count(), 2)
|
||||
self.assertEqual(Product.objects.get().attr, {'color': 'blue'})
|
||||
|
||||
def test_skip_rejected_category_order_does_not_poison_later_rows(self):
|
||||
self.options['errors'] = 'skip'
|
||||
rows = [self.row('1', name='X'*201, order_cat='1'), self.row('2', order_cat='2')]
|
||||
result = import_products(self.workspace, rows, self.options)
|
||||
self.assertEqual(result['errors'], 1)
|
||||
self.assertEqual(Product.objects.get().category.order_cat, 2)
|
||||
|
||||
def test_permissions_rechecked_after_preview(self):
|
||||
self.preview()
|
||||
batch = ProductImportBatch.objects.get()
|
||||
self.user.is_superuser = False
|
||||
self.user.save()
|
||||
self.assertEqual(self.confirm(batch).status_code, 403)
|
||||
self.assertEqual(Product.objects.count(), 0)
|
||||
|
||||
def test_secret_tokens_and_otps_are_not_in_admin_forms_or_exports(self):
|
||||
from apps.jwt_auth.models import JwtToken, CUserRoleLoginOpt
|
||||
from apps.cuser.models import SmsOpt
|
||||
from django.test import RequestFactory
|
||||
request = RequestFactory().get('/')
|
||||
request.user = self.user
|
||||
for model, secrets in ((JwtToken, ('refresh', 'access')), (CUserRoleLoginOpt, ('uuid',)), (SmsOpt, ('opt',))):
|
||||
model_admin = admin.site._registry[model]
|
||||
form = model_admin.get_form(request)
|
||||
self.assertFalse(set(secrets).intersection(form.base_fields))
|
||||
response = model_admin.export_selected(request, model.objects.none())
|
||||
headers = list(load_workbook(BytesIO(response.content)).active.values)[0]
|
||||
self.assertFalse(set(secrets).intersection(headers))
|
||||
|
||||
|
||||
from apps.jwt_auth.test_regressions import RegressionFixtures
|
||||
|
||||
|
||||
class LegacyProductImportTests(RegressionFixtures):
|
||||
def admin_login(self):
|
||||
from apps.cuser.models import CuserRole
|
||||
role = CuserRole.objects.create(cuser=self.user, role_type=self.admin_type, sys_ws=self.workspace,
|
||||
name_family='Admin', mobile=self.user.mobile, is_active=True, mobile_verify=True)
|
||||
self.use_role(role)
|
||||
|
||||
def workbook_content(self, data):
|
||||
book = Workbook()
|
||||
sheet = book.active
|
||||
sheet.append(['name', 'cate_name', 'acc_code', 'size', 'vahed'])
|
||||
for row in data:
|
||||
sheet.append(row)
|
||||
output = BytesIO()
|
||||
book.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
def test_legacy_api_uses_shared_validation_and_rolls_back(self):
|
||||
from unittest.mock import patch
|
||||
self.admin_login()
|
||||
content = self.workbook_content([['P', 'C', '1', 1, 'عدد'], ['Bad', 'D', '2', -1, 'عدد']])
|
||||
with patch('apps.shop.views.excel_import.open', return_value=BytesIO(content), create=True):
|
||||
response = self.client.get('/shop/import/')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(Product.objects.count(), 0)
|
||||
self.assertEqual(ProductCategory.objects.count(), 0)
|
||||
|
||||
def test_legacy_api_import_still_works_and_scopes_workspace(self):
|
||||
from unittest.mock import patch
|
||||
self.admin_login()
|
||||
content = self.workbook_content([['P', 'C', '0001', 1, 'عدد']])
|
||||
with patch('apps.shop.views.excel_import.open', return_value=BytesIO(content), create=True):
|
||||
response = self.client.get('/shop/import/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(Product.objects.get().acc_code, '0001')
|
||||
self.assertEqual(Product.objects.get().sys_ws, self.workspace)
|
||||
self.assertTrue(Product.objects.get().view_only)
|
||||
|
||||
def test_legacy_api_requires_admin_role(self):
|
||||
self.use_role()
|
||||
self.assertEqual(self.client.get('/shop/import/').status_code, 403)
|
||||
@@ -0,0 +1,289 @@
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.utils import timezone
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from apps.jwt_auth.test_regressions import RegressionFixtures
|
||||
from apps.cuser.models import CuserRole, RoleType
|
||||
from apps.shop.models import Product, ProductCategory, Order, OrderItem, Cart
|
||||
from apps.shop.import_models import ProductImportBatch
|
||||
|
||||
ROOT = '/shop/manage/'
|
||||
|
||||
|
||||
class ProductManagementTests(RegressionFixtures):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
cls.admin_role = CuserRole.objects.create(cuser=cls.user, role_type=cls.admin_type, sys_ws=cls.workspace,
|
||||
name_family='Admin', mobile=cls.user.mobile, is_active=True, mobile_verify=True)
|
||||
cls.category = ProductCategory.objects.create(sys_ws=cls.workspace, name='C', order_cat=1)
|
||||
cls.foreign_category = ProductCategory.objects.create(sys_ws=cls.other_workspace, name='Foreign')
|
||||
cls.product = Product.objects.create(sys_ws=cls.workspace, category=cls.category, name='P', acc_code='P1', price=10)
|
||||
cls.foreign_product = Product.objects.create(sys_ws=cls.other_workspace, category=cls.foreign_category, name='Foreign', acc_code='F1', price=20)
|
||||
|
||||
def setUp(self):
|
||||
self.use_role(self.admin_role)
|
||||
|
||||
def payload(self, **values):
|
||||
return dict(name='New product', price='100', category=self.category.pk, acc_code='NEW', **values)
|
||||
|
||||
def patch(self, path, data):
|
||||
return self.client.patch(ROOT+path, data, format='json')
|
||||
|
||||
def upload(self, rows=None):
|
||||
book = Workbook()
|
||||
sheet = book.active
|
||||
sheet.append(['name', 'category', 'acc_code', 'price'])
|
||||
for row in rows or [['Imported', 'Root/Child', 'EXCEL1', 50]]:
|
||||
sheet.append(row)
|
||||
output = BytesIO()
|
||||
book.save(output)
|
||||
return SimpleUploadedFile('products.xlsx', output.getvalue())
|
||||
|
||||
def preview(self, **values):
|
||||
data = dict(file=self.upload(), mode='upsert', create_categories='true', errors='atomic', ordering='preserve')
|
||||
data.update(values)
|
||||
return self.client.post(ROOT+'import/preview/', data, format='multipart')
|
||||
|
||||
def test_operator_customer_even_flagged_admin_denied_every_management_endpoint(self):
|
||||
paths = [('get', 'products/'), ('post', 'products/'), ('get', f'products/{self.product.pk}/'),
|
||||
('patch', f'products/{self.product.pk}/'), ('delete', f'products/{self.product.pk}/'),
|
||||
('post', 'products/bulk/'), ('get', 'products/export/'), ('get', 'categories/'),
|
||||
('post', 'categories/'), ('patch', f'categories/{self.category.pk}/'),
|
||||
('delete', f'categories/{self.category.pk}/'), ('post', 'import/preview/'),
|
||||
('get', 'import/template/'), ('get', 'imports/'), ('get', 'imports/999/'),
|
||||
('post', 'imports/999/confirm/'), ('get', 'imports/999/report/')]
|
||||
for role in (self.role, self.operator):
|
||||
role.is_admin = True
|
||||
role.save()
|
||||
self.use_role(role)
|
||||
for method, path in paths:
|
||||
with self.subTest(role=role.role_type.role_name_en, method=method, path=path):
|
||||
response = getattr(self.client, method)(ROOT+path)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_unauthenticated_and_revoked_session_cannot_manage(self):
|
||||
self.client.credentials()
|
||||
self.assertEqual(self.client.get(ROOT+'products/').status_code, 401)
|
||||
self.use_role(self.admin_role)
|
||||
from apps.jwt_auth.models import JwtToken
|
||||
JwtToken.objects.update(is_blocked=True)
|
||||
self.assertEqual(self.client.get(ROOT+'products/').status_code, 401)
|
||||
|
||||
def test_capabilities_match_current_role_and_owner(self):
|
||||
self.assertTrue(self.client.get('/cuser/info/').data['permissions']['manage_products'])
|
||||
self.use_role(self.operator)
|
||||
self.assertFalse(self.client.get('/cuser/info/').data['permissions']['manage_products'])
|
||||
owner_type = RoleType.objects.create(role_name='Owner', role_name_en='workspace_owner')
|
||||
owner = CuserRole.objects.create(cuser=self.user, role_type=owner_type, sys_ws=self.workspace, name_family='Owner', mobile=self.user.mobile, is_active=True, mobile_verify=True)
|
||||
self.use_role(owner)
|
||||
self.assertEqual(self.client.get(ROOT+'products/').status_code, 200)
|
||||
|
||||
def test_product_list_paginated_filtered_and_workspace_scoped(self):
|
||||
result = self.client.get(ROOT+'products/').data
|
||||
self.assertEqual(result['count'], 1)
|
||||
self.assertEqual(result['results'][0]['id'], self.product.pk)
|
||||
self.assertEqual(self.client.get(ROOT+'products/?search=Foreign').data['count'], 0)
|
||||
self.assertEqual(self.client.get(ROOT+'products/?search=P1&price_min=10&price_max=10').data['count'], 1)
|
||||
self.assertEqual(self.client.get(ROOT+'products/?view_only=true').data['count'], 0)
|
||||
self.assertEqual(self.client.get(ROOT+'products/?price_min=20&price_max=1').status_code, 400)
|
||||
self.assertEqual(self.client.get(ROOT+'products/?ordering=unknown').status_code, 400)
|
||||
|
||||
def test_categories_include_empty_and_counts(self):
|
||||
empty = ProductCategory.objects.create(sys_ws=self.workspace, name='Empty', parent=self.category)
|
||||
response = self.client.get(ROOT+'categories/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rows = {row['id']: row for row in response.data}
|
||||
self.assertNotIn(self.foreign_category.pk, rows)
|
||||
self.assertEqual(rows[self.category.pk]['product_count'], 1)
|
||||
self.assertEqual(rows[self.category.pk]['child_count'], 1)
|
||||
self.assertEqual(rows[empty.pk]['product_count'], 0)
|
||||
|
||||
def test_product_crud_attributes_orders_and_big_price_precision(self):
|
||||
data = self.payload()
|
||||
data.update(name='N'*150, price='9223372036854775807', attr={'unit': 'عدد'}, order_prd=3, excel_order=7)
|
||||
response = self.client.post(ROOT+'products/', data, format='json')
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
self.assertEqual(response.data['price'], '9223372036854775807')
|
||||
product_id = response.data['id']
|
||||
response = self.patch(f'products/{product_id}/', {'price': '0', 'order_prd': None, 'attr': None, 'view_only': True})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.data['order_prd'])
|
||||
self.assertIsNone(response.data['attr'])
|
||||
self.assertTrue(response.data['view_only'])
|
||||
self.assertEqual(self.client.delete(ROOT+f'products/{product_id}/').status_code, 204)
|
||||
|
||||
def test_product_duplicate_code_invalid_values_and_foreign_category(self):
|
||||
for values in ({'acc_code': 'P1'}, {'category': self.foreign_category.pk}, {'price': -1}, {'price': '1.5'}, {'price': '9223372036854775808'}, {'attr': []}, {'order_prd': -1}):
|
||||
data = self.payload()
|
||||
data.update(values)
|
||||
response = self.client.post(ROOT+'products/', data, format='json')
|
||||
self.assertEqual(response.status_code, 400, response.data)
|
||||
self.assertEqual(Product.objects.count(), 2)
|
||||
|
||||
def test_foreign_records_not_read_changed_or_deleted(self):
|
||||
for path in (f'products/{self.foreign_product.pk}/', f'categories/{self.foreign_category.pk}/'):
|
||||
self.assertEqual(self.client.get(ROOT+path).status_code, 404)
|
||||
self.assertEqual(self.patch(path, {'name': 'Stolen'}).status_code, 404)
|
||||
self.assertEqual(self.client.delete(ROOT+path).status_code, 404)
|
||||
self.foreign_product.refresh_from_db()
|
||||
self.assertEqual(self.foreign_product.name, 'Foreign')
|
||||
|
||||
def test_category_crud_duplicate_and_foreign_parent(self):
|
||||
response = self.client.post(ROOT+'categories/', {'name': 'New category', 'parent': self.category.pk, 'order_cat': 2, 'excel_order': 9}, format='json')
|
||||
self.assertEqual(response.status_code, 201)
|
||||
pk = response.data['id']
|
||||
self.assertEqual(self.client.post(ROOT+'categories/', {'name': 'New category', 'parent': self.category.pk}, format='json').status_code, 400)
|
||||
self.assertEqual(self.patch(f'categories/{pk}/', {'parent': self.foreign_category.pk}).status_code, 400)
|
||||
response = self.patch(f'categories/{pk}/', {'name': 'Renamed', 'parent': None, 'order_cat': None})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['name'], 'Renamed')
|
||||
self.assertEqual(self.client.delete(ROOT+f'categories/{pk}/').status_code, 204)
|
||||
|
||||
def test_category_cycles_and_nonempty_deletion_blocked(self):
|
||||
child = ProductCategory.objects.create(sys_ws=self.workspace, name='Child', parent=self.category)
|
||||
self.assertEqual(self.patch(f'categories/{self.category.pk}/', {'parent': child.pk}).status_code, 400)
|
||||
self.assertEqual(self.patch(f'categories/{child.pk}/', {'parent': child.pk}).status_code, 400)
|
||||
self.assertEqual(self.client.delete(ROOT+f'categories/{self.category.pk}/').status_code, 409)
|
||||
self.assertTrue(Product.objects.filter(pk=self.product.pk).exists())
|
||||
|
||||
def test_product_with_order_or_cart_cannot_delete(self):
|
||||
order = Order.objects.create(sys_ws=self.workspace, customer=self.customer, total_price=10, factor_type=1)
|
||||
OrderItem.objects.create(order=order, product=self.product, product_name='P', price=10, amount=1)
|
||||
self.assertEqual(self.client.delete(ROOT+f'products/{self.product.pk}/').status_code, 409)
|
||||
self.assertTrue(OrderItem.objects.filter(order=order).exists())
|
||||
OrderItem.objects.all().delete()
|
||||
Cart.objects.create(sys_ws=self.workspace, product=self.product, cuser_role=self.role, quantity=1)
|
||||
self.assertEqual(self.client.delete(ROOT+f'products/{self.product.pk}/').status_code, 409)
|
||||
|
||||
def test_bulk_move_toggle_and_reorder(self):
|
||||
second = Product.objects.create(sys_ws=self.workspace, category=self.category, name='Second', price=0)
|
||||
target = ProductCategory.objects.create(sys_ws=self.workspace, name='Target')
|
||||
for action, fields in (('move', {'category': target.pk}), ('view_only', {'view_only': True}), ('excel_order', {})):
|
||||
response = self.client.post(ROOT+'products/bulk/', dict(ids=[second.pk, self.product.pk], action=action, **fields), format='json')
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.product.refresh_from_db()
|
||||
self.assertEqual(self.product.category, target)
|
||||
self.assertTrue(self.product.view_only)
|
||||
self.assertEqual(self.product.excel_order, 2)
|
||||
|
||||
def test_bulk_foreign_or_duplicate_ids_roll_back(self):
|
||||
for ids in ([self.product.pk, self.foreign_product.pk], [self.product.pk, self.product.pk]):
|
||||
response = self.client.post(ROOT+'products/bulk/', {'ids': ids, 'action': 'view_only', 'view_only': True}, format='json')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.product.refresh_from_db()
|
||||
self.assertFalse(self.product.view_only)
|
||||
|
||||
def test_bulk_deletion_all_or_nothing(self):
|
||||
removable = Product.objects.create(sys_ws=self.workspace, category=self.category, name='Removable', price=0)
|
||||
Cart.objects.create(sys_ws=self.workspace, product=self.product, cuser_role=self.role, quantity=1)
|
||||
response = self.client.post(ROOT+'products/bulk/', {'ids': [removable.pk, self.product.pk], 'action': 'delete'}, format='json')
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertTrue(Product.objects.filter(pk=removable.pk).exists())
|
||||
|
||||
def test_legacy_category_update_fixed_and_cross_workspace_product_rejected(self):
|
||||
response = self.client.put(f'/shop/product-category/update/{self.category.pk}/', {'name': 'Fixed'}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(response.data['name'], 'Fixed')
|
||||
response = self.client.post('/shop/product/create/', {'name': 'X', 'price': 1, 'category': self.foreign_category.pk}, format='json')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(self.client.delete(f'/shop/product-category/delete/{self.category.pk}/').status_code, 409)
|
||||
|
||||
def test_excel_template_export_is_workspace_scoped_and_formula_safe(self):
|
||||
self.product.name = '=1+1'
|
||||
self.product.save()
|
||||
response = self.client.get(ROOT+'products/export/?search=P1')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
sheet = load_workbook(BytesIO(response.content)).active
|
||||
self.assertEqual(sheet.cell(2, 3).data_type, 's')
|
||||
self.assertEqual(sheet.max_row, 2)
|
||||
self.assertEqual(self.client.get(ROOT+'import/template/').status_code, 200)
|
||||
|
||||
def test_import_preview_confirm_replay_and_report(self):
|
||||
response = self.preview()
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
batch_id = response.data['id']
|
||||
self.assertFalse(response.data['committed'])
|
||||
self.assertFalse(Product.objects.filter(acc_code='EXCEL1').exists())
|
||||
self.assertEqual(self.client.get(ROOT+'imports/').data['count'], 1)
|
||||
self.assertEqual(self.client.get(ROOT+f'imports/{batch_id}/report/').status_code, 200)
|
||||
for _ in range(2):
|
||||
response = self.client.post(ROOT+f'imports/{batch_id}/confirm/', {}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertTrue(response.data['committed'])
|
||||
self.assertEqual(Product.objects.filter(acc_code='EXCEL1').count(), 1)
|
||||
|
||||
def test_import_atomic_and_skip_modes(self):
|
||||
bad = [['Good', 'Valid', 'GOOD', 1], ['Bad', 'Invalid', 'BAD', -1]]
|
||||
response = self.preview(file=self.upload(bad))
|
||||
self.assertEqual(response.data['errors'], 1)
|
||||
self.assertFalse(response.data['can_confirm'])
|
||||
batch_id = response.data['id']
|
||||
self.client.post(ROOT+f'imports/{batch_id}/confirm/')
|
||||
self.assertFalse(Product.objects.filter(acc_code='GOOD').exists())
|
||||
response = self.preview(file=self.upload(bad), errors='skip')
|
||||
batch_id = response.data['id']
|
||||
result = self.client.post(ROOT+f'imports/{batch_id}/confirm/').data
|
||||
self.assertTrue(result['committed'])
|
||||
self.assertEqual(result['errors'], 1)
|
||||
self.assertTrue(Product.objects.filter(acc_code='GOOD').exists())
|
||||
self.assertFalse(ProductCategory.objects.filter(name='Invalid').exists())
|
||||
|
||||
def test_import_wrong_options_mapping_or_file_returns_error(self):
|
||||
for values in ({'mode': 'unknown'}, {'mapping': '[]'}, {'header_row': 0}, {'file': SimpleUploadedFile('bad.xlsx', b'bad')}):
|
||||
response = self.preview(**values)
|
||||
self.assertEqual(response.status_code, 400, response.data)
|
||||
|
||||
def test_import_expired_or_changed_catalog_cannot_confirm(self):
|
||||
for expired in (True, False):
|
||||
batch_id = self.preview().data['id']
|
||||
if expired:
|
||||
ProductImportBatch.objects.filter(pk=batch_id).update(expires_at=timezone.now()-timedelta(seconds=1))
|
||||
else:
|
||||
self.product.name = 'Changed'
|
||||
self.product.save()
|
||||
self.assertEqual(self.client.post(ROOT+f'imports/{batch_id}/confirm/').status_code, 400)
|
||||
self.assertFalse(Product.objects.filter(acc_code='EXCEL1').exists())
|
||||
|
||||
def test_import_other_workspace_and_other_owner_hidden(self):
|
||||
batch_id = self.preview().data['id']
|
||||
self.admin_role.sys_ws = self.other_workspace
|
||||
self.admin_role.save()
|
||||
self.use_role(self.admin_role)
|
||||
for suffix in ('', 'report/', 'confirm/'):
|
||||
method = self.client.post if suffix == 'confirm/' else self.client.get
|
||||
self.assertEqual(method(ROOT+f'imports/{batch_id}/'+suffix).status_code, 404)
|
||||
self.assertEqual(self.client.get(ROOT+'imports/').data['count'], 0)
|
||||
|
||||
def test_import_rows_paginate_without_losing_report_rows(self):
|
||||
rows = [[f'P{i}', 'C', f'CODE{i}', 1] for i in range(101)]
|
||||
response = self.preview(file=self.upload(rows))
|
||||
self.assertEqual(len(response.data['rows']), 100)
|
||||
self.assertEqual(response.data['row_count'], 101)
|
||||
batch_id = response.data['id']
|
||||
self.assertEqual(len(self.client.get(ROOT+f'imports/{batch_id}/?row_page=2').data['rows']), 1)
|
||||
self.assertEqual(self.client.get(ROOT+f'imports/{batch_id}/?row_page=0').status_code, 400)
|
||||
|
||||
def test_category_multipart_image_and_clearing_nullable_fields(self):
|
||||
from tempfile import TemporaryDirectory
|
||||
from django.test import override_settings
|
||||
from PIL import Image
|
||||
content = BytesIO()
|
||||
Image.new('RGB', (2, 2), color='blue').save(content, format='PNG')
|
||||
with TemporaryDirectory(dir='../../reports/product-management') as directory, override_settings(MEDIA_ROOT=directory):
|
||||
upload = SimpleUploadedFile('category.png', content.getvalue(), content_type='image/png')
|
||||
response = self.client.post(ROOT+'categories/', {'name': 'Image', 'parent': '', 'order_cat': '', 'excel_order': '', 'image': upload}, format='multipart')
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
self.assertIsNone(response.data['order_cat'])
|
||||
pk = response.data['id']
|
||||
self.assertTrue(response.data['image'].endswith('.png'))
|
||||
response = self.patch(f'categories/{pk}/', {'image': None, 'order_cat': 4})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.data['image'])
|
||||
|
||||
def test_category_negative_and_out_of_range_orders_are_rejected(self):
|
||||
for values in ({'order_cat': -1}, {'excel_order': -1}, {'order_cat': 2147483648}):
|
||||
data = {'name': 'Invalid'}
|
||||
data.update(values)
|
||||
self.assertEqual(self.client.post(ROOT+'categories/', data, format='json').status_code, 400)
|
||||
@@ -1,7 +1,25 @@
|
||||
from django.urls import path
|
||||
from apps.shop.views import *
|
||||
from apps.shop.views.management import (
|
||||
ProductManagementListApi, ProductManagementDetailApi, CategoryManagementListApi,
|
||||
CategoryManagementDetailApi, ProductBulkApi, ProductExportApi,
|
||||
ProductImportPreviewApi, ProductImportListApi, ProductImportDetailApi,
|
||||
ProductImportConfirmApi, ProductImportReportApi, ProductImportTemplateApi,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('manage/products/', ProductManagementListApi.as_view()),
|
||||
path('manage/products/bulk/', ProductBulkApi.as_view()),
|
||||
path('manage/products/export/', ProductExportApi.as_view()),
|
||||
path('manage/products/<int:pk>/', ProductManagementDetailApi.as_view()),
|
||||
path('manage/categories/', CategoryManagementListApi.as_view()),
|
||||
path('manage/categories/<int:pk>/', CategoryManagementDetailApi.as_view()),
|
||||
path('manage/import/preview/', ProductImportPreviewApi.as_view()),
|
||||
path('manage/import/template/', ProductImportTemplateApi.as_view()),
|
||||
path('manage/imports/', ProductImportListApi.as_view()),
|
||||
path('manage/imports/<int:pk>/', ProductImportDetailApi.as_view()),
|
||||
path('manage/imports/<int:pk>/confirm/', ProductImportConfirmApi.as_view()),
|
||||
path('manage/imports/<int:pk>/report/', ProductImportReportApi.as_view()),
|
||||
path('product-category/create/', ProductCategoryCreateApi.as_view(), name='create_product_category'),
|
||||
path('product-category/<int:category_id>/', ProductCategoryRetrieveApi.as_view(), name='retrieve_product_category'),
|
||||
path('product-category/update/<int:category_id>/', ProductCategoryUpdateApi.as_view(), name='update_product_category'),
|
||||
|
||||
@@ -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)})
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
+16
-3
@@ -1,19 +1,32 @@
|
||||
from utils.admin_base import CRMModelAdmin
|
||||
from django.contrib import admin
|
||||
from apps.ticket.models import Ticket, TicketComment, TicketCommentFile
|
||||
|
||||
# Register your models here.
|
||||
|
||||
|
||||
class TicketCommentInline(admin.TabularInline):
|
||||
model = TicketComment
|
||||
extra = 0
|
||||
fields = ["desc","cuser_role","created_at"]
|
||||
readonly_fields = fields
|
||||
can_delete = False
|
||||
show_change_link = True
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(Ticket)
|
||||
class TicketAdmin(admin.ModelAdmin):
|
||||
class TicketAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(TicketComment)
|
||||
class TicketCommentAdmin(admin.ModelAdmin):
|
||||
class TicketCommentAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(TicketCommentFile)
|
||||
class TicketCommentFileAdmin(admin.ModelAdmin):
|
||||
class TicketCommentFileAdmin(CRMModelAdmin):
|
||||
pass
|
||||
@@ -1,3 +1,4 @@
|
||||
from utils.admin_base import CRMModelAdmin
|
||||
from django.contrib import admin
|
||||
from apps.workspace.models import (
|
||||
Workspace as WorkspaceModel,
|
||||
@@ -6,7 +7,7 @@ from apps.workspace.models import (
|
||||
# Register your models here.
|
||||
|
||||
|
||||
class WorkspaceAdmin(admin.ModelAdmin):
|
||||
class WorkspaceAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@@ -14,6 +15,6 @@ admin.site.register(WorkspaceModel, WorkspaceAdmin)
|
||||
|
||||
|
||||
@admin.register(CityModel)
|
||||
class CityAdmin(admin.ModelAdmin):
|
||||
class CityAdmin(CRMModelAdmin):
|
||||
pass
|
||||
|
||||
Reference in new issue
Block a user