Compare commits

...
18 Commits
Author SHA1 Message Date
javad a28ac2167c fix refresh token 2026-10-09 11:33:00 +03:30
javad 4dc6d98abf fix order 2026-10-09 11:05:10 +03:30
javad ae4dbab3cc fix bug sms 2026-10-09 10:32:27 +03:30
javad 48d0ecba79 fix login and jwt 2026-10-09 10:07:31 +03:30
javad 56d8760613 migration repaire 2026-10-08 18:09:12 +03:30
javad c8d45dd07c add product 2026-10-08 16:37:45 +03:30
javad 423d4cf40e edit production media root and url 2026-06-21 08:36:26 +03:30
javad 2dde1c8a0f edit storage in settings 2026-06-21 08:18:08 +03:30
javad 5e29e9d902 base settings edit url 2026-06-21 07:52:55 +03:30
javad c4e1444452 fix production.py 2026-06-20 19:47:13 +03:30
javad 05037793e1 fix production.py 2026-06-20 19:40:45 +03:30
javad 9c2c2d91de fix production.py 2026-06-20 19:33:55 +03:30
javad b1023a161f fix production.py 2026-06-20 19:26:04 +03:30
javad 46971f67e9 fix entrypoint 2026-06-20 19:19:25 +03:30
javad c7577185a0 fix production.py 2026-06-20 19:15:46 +03:30
javad 2db88970ed config fix for staticfiles 2026-06-20 19:03:31 +03:30
javad 550c113f7f dockerfile 2026-06-20 18:38:11 +03:30
javad 708d24c613 dockerfile 2026-06-20 18:26:05 +03:30
69 changed files with 4008 additions and 666 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
# ripwire quality acks v1 — written by --quality-ack; a finding stays suppressed until it worsens past its acked magnitude
# format: ack <kind> <16-hex-key> <ackNow> [cid=<16-hex-content-id>] [by=<scope that acked it>] <reason to end of line> — one per line, kept SORTED by (kind,key) on every write (merge-friendly)
ack duplication 07b21eccba4b853f 90 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 0808552a2cb156e2 64 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 1c54979316d47755 59 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 1ebf7141892dce85 59 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 36c53d31427d737f 77 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 36dd407bf7995740 61 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 43ffa97050be9c59 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 46611f8550817a04 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 58bb2dbc45626a89 77 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 5ce836b9523750f3 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 60368ec2cee5e29e 21 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 62c88879e07b9e3b 73 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 677faf5f36583e91 84 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 68c7d3f08353288f 207 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 6feffa5e3876b705 53 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 72f5e86186615047 59 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 760e5582f9cac278 38 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 764331dacd10a89e 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 816088d74742a3bd 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 85a85b24c763964c 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 8c9a9e87785f4880 55 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 95149d1a0d230890 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 976339e40ee6a2cb 44 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication ae5d16bb65bfd38b 84 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication b4969500bb084d10 77 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication bc3635ebb8739295 84 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication bdfe7d60141f2ce6 73 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication be4e5df94b1b82e5 100 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication c1c4b99cde75e374 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication c8109dee754a200e 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication c9f7a6966acae775 48 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication cbb16dac41720d6a 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication cfb7f7a3a970d0b3 63 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication d0993f4aa7ad017b 81 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication e4d6ebd79eb27cc3 74 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication efc7a9030246fb5d 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack short-horizon-churn 89776c50b7500989 4 cid=4c88db074226651a Intentional authentication backend replacement to enforce recorded session validity; recent-edit churn is expected for this setting.
+40 -7
View File
@@ -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,20 +11,50 @@ 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)
+12 -1
View File
@@ -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": "کاربری با این شماره موبایل قبلاً ثبت شده است."})
+30
View File
@@ -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__'
+7 -1
View File
@@ -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)
+38 -7
View File
@@ -1,5 +1,7 @@
from utils.admin_base import CRMModelAdmin
from django.contrib import admin
from apps.customer.models import (
MobileConflict,
Customer as CustomerModel,
CustomerNote as CustomerNoteModel,
ShiveAshenaei as ShiveAshenaeiModel,
@@ -11,36 +13,65 @@ from apps.customer.models import (
# Register your models here.
@admin.register(MobileConflict)
class MobileConflictAdmin(CRMModelAdmin):
list_display = ('source_model', 'record_id', 'workspace_id', 'original_mobile', 'canonical_mobile', 'created_at')
list_filter = ('source_model', 'workspace_id')
search_fields = ('=record_id', '=original_mobile', '=canonical_mobile')
readonly_fields = ('source_model', 'record_id', 'workspace_id', 'original_mobile', 'canonical_mobile', 'snapshot', 'created_at')
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
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
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,100 @@
"""Read-only diagnostics for the legacy mobile uniqueness migration."""
import json
from collections import defaultdict
from importlib import import_module
from django.core.management.base import BaseCommand, CommandError
from django.db import connections
from apps.cuser.cuser_model import CUser
from apps.customer.models import Customer
canonical_mobile = import_module(
'apps.customer.migrations.0008_customer_mobile_unique'
).canonical_mobile
def _duplicate_groups(model, alias, per_workspace=False):
"""Use the migration's exact comparison, including country-prefix collisions."""
groups = defaultdict(list)
fields = ['pk', 'mobile'] + (['sys_ws_id'] if per_workspace else [])
for row in model.objects.using(alias).order_by('pk').values(*fields).iterator():
mobile = canonical_mobile(row['mobile'])
if mobile is None:
continue
key = (row['sys_ws_id'], mobile) if per_workspace else (None, mobile)
groups[key].append(row['pk'])
return [
{'workspace_id': workspace, 'canonical_mobile': mobile, 'record_ids': ids}
for (workspace, mobile), ids in groups.items() if len(ids) > 1
]
def _related_counts(record, alias):
"""Count direct foreign-key dependents without reading their private contents."""
counts = {}
for relation in record._meta.related_objects:
if relation.one_to_many or relation.one_to_one:
label = f'{relation.related_model._meta.label}.{relation.field.name}'
counts[label] = relation.related_model._default_manager.using(alias).filter(
**{relation.field.attname: record.pk}
).count()
return counts
def _customer_details(ids, alias):
rows = Customer.objects.using(alias).filter(pk__in=ids).select_related(
'sys_ws', 'cuser_role__cuser', 'cuser_role__role_type'
).order_by('pk')
details = []
for row in rows:
role = row.cuser_role
details.append({
'id': row.pk, 'name_family': row.name_family,
'mobile': row.mobile, 'canonical_mobile': canonical_mobile(row.mobile),
'workspace_id': row.sys_ws_id, 'workspace_name': row.sys_ws.name,
'panel_role': None if role is None else {
'id': role.pk, 'user_id': role.cuser_id, 'workspace_id': role.sys_ws_id,
'role': role.role_type.role_name_en, 'mobile': role.mobile,
'user_mobile': role.cuser.mobile if role.cuser_id else None,
'is_active': role.is_active, 'customer_panel': role.customer_panel,
},
'related_counts': _related_counts(row, alias),
})
return details
class Command(BaseCommand):
help = 'Report legacy Customer/CUser mobile conflicts; never modify or delete records.'
# Must work before migrations, without checking unrelated model declarations.
requires_system_checks = []
def add_arguments(self, parser):
parser.add_argument('--database', default='default')
parser.add_argument('--customer-ids', nargs='+', type=int, default=[])
def handle(self, *args, **options):
alias = options['database']
if alias not in connections:
raise CommandError(f'Unknown database alias: {alias}')
customer_groups = _duplicate_groups(Customer, alias, per_workspace=True)
user_groups = _duplicate_groups(CUser, alias)
customer_ids = set(options['customer_ids'])
customer_ids.update(pk for group in customer_groups for pk in group['record_ids'])
user_ids = [pk for group in user_groups for pk in group['record_ids']]
report = {
'read_only': True, 'database': alias,
'duplicate_group_count': len(customer_groups) + len(user_groups),
'customer_duplicate_groups': customer_groups,
'user_duplicate_groups': user_groups,
'customers': _customer_details(customer_ids, alias),
'missing_requested_customer_ids': sorted(customer_ids - set(
Customer.objects.using(alias).filter(pk__in=customer_ids).values_list('pk', flat=True)
)),
'users': [dict(row, canonical_mobile=canonical_mobile(row['mobile'])) for row in
CUser.objects.using(alias).filter(pk__in=user_ids).order_by('pk').values(
'id', 'username', 'name_family', 'mobile', 'is_active'
)],
}
self.stdout.write(json.dumps(report, ensure_ascii=False, indent=2))
@@ -0,0 +1,69 @@
from django.db import migrations, models
from apps.customer.mobile_migration_repair import repair_conflicts
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, skip_ids=None):
seen = {}
updates = []
fields = ('pk', 'mobile', 'sys_ws_id') if per_workspace else ('pk', 'mobile')
for row in model.objects.using(alias).only(*fields).iterator():
if skip_ids and row.pk in skip_ids:
continue
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')
# Quarantine legacy conflicts automatically, retaining a persistent audit.
skipped_users = repair_conflicts(apps, schema_editor, canonical_mobile)
customer_updates = normalized_rows(customer, alias, per_workspace=True)
user_updates = normalized_rows(user, alias, skip_ids=skipped_users)
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)
if schema_editor.connection.vendor == 'postgresql':
# Drain deferred FK checks from panel detachment before AddConstraint
# alters the same table in this atomic migration.
with schema_editor.connection.cursor() as cursor:
cursor.execute('SET CONSTRAINTS ALL IMMEDIATE')
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'),
),
]
@@ -0,0 +1,37 @@
from django.db import migrations, models
from django.utils import timezone
class CreateAuditModel(migrations.CreateModel):
"""0008 can create this audit table before repairing legacy data.
Existing installations that already applied 0008 create it here instead.
Keep audit history on rollback; it is evidence of irreversible data repairs.
"""
def database_forwards(self, app_label, schema_editor, from_state, to_state):
model = to_state.apps.get_model(app_label, self.name)
if model._meta.db_table not in schema_editor.connection.introspection.table_names():
super().database_forwards(app_label, schema_editor, from_state, to_state)
def database_backwards(self, app_label, schema_editor, from_state, to_state):
pass
class Migration(migrations.Migration):
dependencies = [('customer', '0008_customer_mobile_unique')]
operations = [CreateAuditModel(
name='MobileConflict',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source_model', models.CharField(max_length=100)),
('record_id', models.PositiveBigIntegerField()),
('workspace_id', models.PositiveBigIntegerField(null=True, blank=True)),
('original_mobile', models.PositiveBigIntegerField(null=True, blank=True)),
('canonical_mobile', models.PositiveBigIntegerField()),
('snapshot', models.JSONField()),
('created_at', models.DateTimeField(default=timezone.now, editable=False)),
],
options={'ordering': ['-created_at', '-pk'],
'unique_together': (('source_model', 'record_id'),),
'verbose_name': 'تعارض شماره قدیمی', 'verbose_name_plural': 'تعارض‌های شماره قدیمی'},
)]
+88
View File
@@ -0,0 +1,88 @@
"""Frozen, historical-model-only repair used by migration 0008."""
from collections import defaultdict
from importlib import import_module
import logging
from django.db.migrations.state import ProjectState
def _groups(model, alias, canonical, workspace=False):
groups = defaultdict(list)
for row in model.objects.using(alias).order_by('pk').iterator():
mobile = canonical(row.mobile)
if mobile is not None:
groups[(row.sys_ws_id if workspace else None, mobile)].append(row)
return {key: rows for key, rows in groups.items() if len(rows) > 1}
def _audit_model(schema_editor):
# Render the frozen 0009 schema; never depend on future live model fields.
operation = import_module('apps.customer.migrations.0009_mobileconflict').Migration.operations[0]
state = ProjectState()
operation.state_forwards('customer', state)
model = state.apps.get_model('customer', 'MobileConflict')
if model._meta.db_table not in schema_editor.connection.introspection.table_names():
schema_editor.create_model(model)
return model
def _archive(audit, alias, row, mobile, snapshot, workspace=None):
audit.objects.using(alias).get_or_create(
source_model=row._meta.label, record_id=row.pk,
defaults={'workspace_id': workspace, 'original_mobile': row.mobile,
'canonical_mobile': mobile, 'snapshot': snapshot},
)
def repair_conflicts(apps, schema_editor, canonical):
"""Quarantine ambiguous identities; never choose an owner or merge/delete rows."""
alias = schema_editor.connection.alias
customer = apps.get_model('customer', 'Customer')
user = apps.get_model('cuser', 'CUser')
role = apps.get_model('cuser', 'CuserRole')
if schema_editor.connection.vendor == 'postgresql':
# This migration is atomic. Prevent legacy writers inserting new conflicts
# between the audit and the unique constraint, including empty tables.
tables = ', '.join(schema_editor.connection.ops.quote_name(model._meta.db_table)
for model in (customer, user, role))
with schema_editor.connection.cursor() as cursor:
cursor.execute(f'LOCK TABLE {tables} IN SHARE ROW EXCLUSIVE MODE')
customer_groups = _groups(customer, alias, canonical, workspace=True)
user_groups = _groups(user, alias, canonical)
if not customer_groups and not user_groups:
return set()
audit = _audit_model(schema_editor)
for (workspace, mobile), rows in customer_groups.items():
ids = [row.pk for row in rows]
for row in rows:
roles = list(role.objects.using(alias).filter(pk=row.cuser_role_id).values(
'id', 'cuser_id', 'sys_ws_id', 'mobile', 'is_active', 'customer_panel'
))
_archive(audit, alias, row, mobile, {
'reason': 'duplicate_customer_mobile', 'conflicting_record_ids': ids,
'name_family': row.name_family, 'cuser_role_id': row.cuser_role_id,
'roles': roles, 'action': 'clear_mobile_and_detach_panel',
}, workspace)
role_ids = [row.cuser_role_id for row in rows if row.cuser_role_id is not None]
role.objects.using(alias).filter(pk__in=role_ids).update(customer_panel=False)
customer.objects.using(alias).filter(pk__in=ids).update(mobile=None, cuser_role_id=None)
skipped_users = set()
for (_, mobile), rows in user_groups.items():
ids = [row.pk for row in rows]
for row in rows:
_archive(audit, alias, row, mobile, {
'reason': 'duplicate_account_mobile', 'conflicting_record_ids': ids,
'username': row.username, 'name_family': row.name_family, 'is_active': row.is_active,
'roles': list(role.objects.using(alias).filter(cuser_id=row.pk).values(
'id', 'sys_ws_id', 'mobile', 'is_active', 'customer_panel'
)), 'action': 'disable_ambiguous_accounts_preserve_mobile',
})
user.objects.using(alias).filter(pk__in=ids).update(is_active=False)
role.objects.using(alias).filter(cuser_id__in=ids).update(is_active=False, customer_panel=False)
skipped_users.update(ids)
logging.getLogger(__name__).warning(
'Legacy mobile conflicts quarantined: %s customers, %s accounts. '
'No records deleted. Review customer.MobileConflict in Django Admin.',
sum(len(rows) for rows in customer_groups.values()), len(skipped_users),
)
return skipped_users
+32
View File
@@ -3,9 +3,30 @@ 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
from django.utils import timezone
# Create your models here.
class MobileConflict(models.Model):
source_model = models.CharField(max_length=100)
record_id = models.PositiveBigIntegerField()
workspace_id = models.PositiveBigIntegerField(null=True, blank=True)
original_mobile = models.PositiveBigIntegerField(null=True, blank=True)
canonical_mobile = models.PositiveBigIntegerField()
snapshot = models.JSONField()
created_at = models.DateTimeField(default=timezone.now, editable=False)
class Meta:
ordering = ['-created_at', '-pk']
unique_together = (('source_model', 'record_id'),)
verbose_name = 'تعارض شماره قدیمی'
verbose_name_plural = 'تعارض‌های شماره قدیمی'
def __str__(self):
return f'{self.source_model} #{self.record_id}: {self.original_mobile}'
class ShiveAshenaei(BaseModel):
name = models.CharField(max_length=15, null=False, blank=False)
sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, null=False, blank=False)
@@ -61,13 +82,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):
+73 -47
View File
@@ -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
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 cuser_role_obj:
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
else:
raise Exception
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 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})
+205
View File
@@ -0,0 +1,205 @@
"""Legacy deployment diagnostics must expose conflicts without changing data."""
import json
from importlib import import_module
from io import StringIO
from unittest.mock import MagicMock, Mock, patch
from django.apps import apps
from django.contrib import admin
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db import connection, transaction
from django.db.migrations.executor import MigrationExecutor
from django.test import TransactionTestCase
from django.test.utils import CaptureQueriesContext
from apps.cuser.cuser_model import CUser
from apps.customer.models import Customer, CustomerNote, MobileConflict
from apps.jwt_auth.test_regressions import RegressionFixtures
from apps.workspace.models import Workspace
from apps.shop.models import Order
def audit(*args):
output = StringIO()
call_command('audit_mobile_duplicates', *args, stdout=output)
return json.loads(output.getvalue())
def repair():
migration = import_module('apps.customer.migrations.0008_customer_mobile_unique')
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
class MobileAuditTests(RegressionFixtures):
def test_country_prefix_conflict_reports_all_members_and_dependencies_read_only(self):
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
note = CustomerNote.objects.create(customer=other, cuser_role=self.role,
desc='Keep this note', sys_ws=self.workspace)
before = list(Customer.objects.order_by('pk').values())
with CaptureQueriesContext(connection) as queries:
report = audit()
self.assertEqual(report['customer_duplicate_groups'], [{
'workspace_id': self.workspace.pk, 'canonical_mobile': self.customer.mobile,
'record_ids': [self.customer.pk, other.pk],
}])
self.assertEqual(report['duplicate_group_count'], 1)
self.assertEqual(report['customers'][1]['related_counts']['customer.CustomerNote.customer'], 1)
self.assertEqual(report['customers'][0]['panel_role']['user_id'], self.user.pk)
self.assertEqual(before, list(Customer.objects.order_by('pk').values()))
self.assertTrue(CustomerNote.objects.filter(pk=note.pk).exists())
writes = [query['sql'] for query in queries if query['sql'].lstrip().split()[0].upper()
in {'INSERT', 'UPDATE', 'DELETE', 'ALTER', 'DROP', 'CREATE'}]
self.assertEqual(writes, [])
def test_cross_workspace_numbers_and_multiple_nulls_are_allowed(self):
Customer.objects.create(name_family='Other tenant', mobile=989123456789, sys_ws=self.other_workspace)
Customer.objects.create(name_family='No number 1', mobile=None, sys_ws=self.workspace)
Customer.objects.create(name_family='No number 2', mobile=None, sys_ws=self.workspace)
self.assertEqual(audit()['duplicate_group_count'], 0)
def test_user_country_prefix_conflict_is_global_and_excludes_secrets(self):
user = CUser.objects.create(username='legacy', name_family='Legacy', mobile=989123456789)
report = audit()
self.assertEqual(report['user_duplicate_groups'][0]['record_ids'], [self.user.pk, user.pk])
self.assertEqual(report['duplicate_group_count'], 1)
self.assertNotIn('password', report['users'][0])
user.refresh_from_db()
self.assertEqual(user.mobile, 989123456789)
def test_requested_customer_details_and_missing_ids_without_duplicates(self):
report = audit('--customer-ids', str(self.customer.pk), '999999')
self.assertEqual(report['customers'][0]['id'], self.customer.pk)
self.assertEqual(report['missing_requested_customer_ids'], [999999])
self.assertEqual(report['duplicate_group_count'], 0)
def test_invalid_database_has_clear_error(self):
with self.assertRaisesMessage(CommandError, 'Unknown database alias'):
audit('--database', 'missing')
def test_automatic_repair_preserves_notes_orders_audit_and_blocks_old_panel_session(self):
tokens = self.use_role()
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
note = CustomerNote.objects.create(customer=self.customer, cuser_role=self.role,
desc='Keep this note', sys_ws=self.workspace)
order = Order.objects.create(customer=other, total_price=100, factor_type=1, sys_ws=self.workspace)
repair()
self.assertTrue(CustomerNote.objects.filter(pk=note.pk, customer=self.customer).exists())
self.assertTrue(Order.objects.filter(pk=order.pk, customer=other).exists())
entries = MobileConflict.objects.filter(source_model='customer.Customer')
self.assertEqual(entries.count(), 2)
archived = entries.get(record_id=self.customer.pk)
self.assertEqual(archived.snapshot['cuser_role_id'], self.role.pk)
self.assertTrue(archived.snapshot['roles'][0]['customer_panel'])
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
repair()
self.assertEqual(entries.count(), 2)
self.assertEqual(entries.get(record_id=self.customer.pk).snapshot, archived.snapshot)
def test_account_collision_disables_all_ambiguous_accounts_without_changing_their_numbers(self):
tokens = self.use_role()
other = CUser.objects.create(username='legacy', mobile=989123456789, is_active=True)
repair()
self.user.refresh_from_db()
other.refresh_from_db()
self.role.refresh_from_db()
self.operator.refresh_from_db()
self.assertFalse(self.user.is_active)
self.assertFalse(other.is_active)
self.assertFalse(self.role.is_active)
self.assertFalse(self.operator.is_active)
self.assertEqual(self.user.mobile, 9123456789)
self.assertEqual(other.mobile, 989123456789)
self.assertEqual(MobileConflict.objects.filter(source_model='cuser.CUser').count(), 2)
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
before = list(MobileConflict.objects.order_by('pk').values())
repair()
self.assertEqual(before, list(MobileConflict.objects.order_by('pk').values()))
def test_failed_archival_rolls_back_entire_repair(self):
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
Customer.objects.create(name_family='Second group', mobile=9333333333, sys_ws=self.workspace)
Customer.objects.create(name_family='Second group legacy', mobile=989333333333, sys_ws=self.workspace)
module = import_module('apps.customer.mobile_migration_repair')
archive = module._archive
calls = 0
def failing_archive(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 3:
raise RuntimeError('Simulated audit storage failure')
return archive(*args, **kwargs)
with self.assertRaisesMessage(RuntimeError, 'Simulated audit storage failure'):
with transaction.atomic(), patch.object(module, '_archive', side_effect=failing_archive):
repair()
self.customer.refresh_from_db()
other.refresh_from_db()
self.assertEqual(self.customer.mobile, 9123456789)
self.assertEqual(other.mobile, 989123456789)
self.assertEqual(MobileConflict.objects.count(), 0)
def test_admin_audit_history_cannot_be_added_changed_or_deleted(self):
model_admin = admin.site._registry[MobileConflict]
request = Mock(user=Mock(is_superuser=True, is_active=True))
self.assertFalse(model_admin.has_add_permission(request))
self.assertFalse(model_admin.has_change_permission(request))
self.assertFalse(model_admin.has_delete_permission(request))
def test_postgres_migration_locks_legacy_writers_and_drains_deferred_checks(self):
cursor = MagicMock()
postgres = Mock(alias='default', vendor='postgresql', ops=connection.ops)
postgres.cursor.return_value.__enter__ = Mock(return_value=cursor)
postgres.cursor.return_value.__exit__ = Mock(return_value=False)
migration = import_module('apps.customer.migrations.0008_customer_mobile_unique')
migration.check_and_normalize_mobiles(apps, Mock(connection=postgres))
statements = [call.args[0] for call in cursor.execute.call_args_list]
self.assertTrue(statements[0].startswith('LOCK TABLE '))
self.assertIn('SHARE ROW EXCLUSIVE MODE', statements[0])
self.assertEqual(statements[-1], 'SET CONSTRAINTS ALL IMMEDIATE')
class LegacyMobileAuditTests(TransactionTestCase):
def test_exact_duplicates_before_migration_are_automatically_quarantined(self):
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
executor.migrate([('customer', '0007_alter_customer_name_family')])
try:
# Real legacy databases do not have the new audit table yet.
with connection.schema_editor() as editor:
editor.delete_model(MobileConflict)
workspace = Workspace.objects.create(name='Legacy workspace', username='legacy-workspace')
first = Customer.objects.create(name_family='First', mobile=9123456789, sys_ws=workspace)
second = Customer.objects.create(name_family='Second', mobile=9123456789, sys_ws=workspace)
report = audit('--customer-ids', str(first.pk), str(second.pk))
self.assertEqual(report['customer_duplicate_groups'][0]['record_ids'], [first.pk, second.pk])
self.assertEqual(Customer.objects.filter(mobile=9123456789).count(), 2)
MigrationExecutor(connection).migrate(targets)
self.assertEqual(Customer.objects.filter(pk__in=[first.pk, second.pk]).count(), 2)
self.assertEqual(Customer.objects.filter(pk__in=[first.pk, second.pk], mobile=None).count(), 2)
self.assertEqual(MobileConflict.objects.filter(source_model='customer.Customer').count(), 2)
self.assertEqual(MobileConflict.objects.get(record_id=first.pk).original_mobile, 9123456789)
self.assertEqual(audit()['duplicate_group_count'], 0)
finally:
# Test data only: restore the normal schema for subsequent tests.
Customer.objects.all().delete()
MigrationExecutor(connection).migrate(targets)
def test_already_applied_0008_installation_creates_missing_audit_table(self):
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
executor.migrate([('customer', '0008_customer_mobile_unique')])
try:
# Simulate an installation that applied the original 0008 before
# this automatic repair feature existed.
with connection.schema_editor() as editor:
editor.delete_model(MobileConflict)
self.assertNotIn(MobileConflict._meta.db_table, connection.introspection.table_names())
MigrationExecutor(connection).migrate(targets)
self.assertIn(MobileConflict._meta.db_table, connection.introspection.table_names())
self.assertEqual(MobileConflict.objects.count(), 0)
finally:
MigrationExecutor(connection).migrate(targets)
+15 -2
View File
@@ -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)
+1 -1
View File
@@ -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)
+16 -3
View File
@@ -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
View File
@@ -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
+19
View File
@@ -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),
),
]
+1 -1
View File
@@ -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)
+112 -100
View File
@@ -1,110 +1,122 @@
import uuid
import random
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.utils import timezone
from rest_framework import exceptions
import requests
import secrets
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.utils import timezone
from rest_framework import exceptions
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()
SMS_RESEND_INTERVAL = 60
def login_by_username(username: str, password: str) -> CUserRoleLoginOpt:
class SmsRequestThrottled(exceptions.Throttled):
def __init__(self, wait):
super().__init__(wait=wait)
self.detail = {'detail': 'کد ورود قبلاً ارسال شده است. کمی صبر کنید و کد قبلی را وارد کنید.',
'retry_after': wait}
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)
# Recheck eligibility after waiting for concurrent requests/administrative edits.
user, _ = mobile_user(user.mobile)
current = SmsOpt.objects.filter(mobile=user.mobile, expire__gt=timezone.now()).order_by('-created_at').first()
if current:
remaining = SMS_RESEND_INTERVAL - (timezone.now() - current.created_at).total_seconds()
if remaining > 0:
from math import ceil
raise SmsRequestThrottled(ceil(remaining))
code = secrets.randbelow(900000) + 100000
delivery = DefaultSmsProvider().send_sms(str(user.mobile), f'کد ورود شما: {code}')
if delivery is False:
raise SmsDeliveryUnavailable()
if delivery is True:
SmsOpt.objects.filter(mobile=user.mobile).delete()
else:
# Keep any previous unexpired code too: either SMS may have arrived.
SmsOpt.objects.filter(mobile=user.mobile, expire__lte=timezone.now()).delete()
SmsOpt.objects.create(mobile=user.mobile, opt=code, expire=timezone.now() + timedelta(minutes=3))
return {'mobile': user.mobile, 'delivery_status': 'accepted' if delivery is True else 'unconfirmed',
'message': 'opt code send' if delivery is True else 'وضعیت ارسال پیامک تأیید نشد. اگر کد را دریافت کرده‌اید، آن را وارد کنید.',
'retry_after': SMS_RESEND_INTERVAL}
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
+49
View File
@@ -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')
+131
View File
@@ -0,0 +1,131 @@
"""Customer OTP journey, repeat submits, and recoverable failure contracts."""
from datetime import timedelta
from unittest.mock import Mock, patch
from django.test import override_settings
from django.utils import timezone
import requests
from apps.cuser.models import SmsOpt
from apps.jwt_auth.test_regressions import RegressionFixtures
class CustomerLoginTests(RegressionFixtures):
def test_unrecognized_provider_reply_keeps_delivered_code_usable(self):
for body in ('1', '654321987', '<string>accepted</string>', ''):
with self.subTest(body=body):
SmsOpt.objects.all().delete()
with patch('utils.services.sms.requests.get', return_value=Mock(text=body)):
sent = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(sent.status_code, 202)
self.assertEqual(sent.data['result']['delivery_status'], 'unconfirmed')
code = SmsOpt.objects.get(mobile=self.user.mobile).opt
verified = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code})
self.assertEqual(verified.status_code, 200)
self.assertEqual(self.post('token/', {'uuid_opt': verified.data[0]['uuid']}).status_code, 200)
def test_lost_provider_reply_keeps_code_and_still_throttles_repeat_sms(self):
for error in (requests.Timeout, requests.ConnectionError):
with self.subTest(error=error):
SmsOpt.objects.all().delete()
with patch('utils.services.sms.requests.get', side_effect=error) as send:
sent = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(sent.status_code, 202)
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 429)
send.assert_called_once()
code = SmsOpt.objects.get().opt
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code}).status_code, 200)
def test_uncertain_resend_retains_previous_delivered_code(self):
original = self.otp()
SmsOpt.objects.filter(pk=original.pk).update(created_at=timezone.now() - timedelta(seconds=61))
with patch('utils.services.sms.requests.get', return_value=Mock(text='unexpected')):
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 202)
self.assertEqual(SmsOpt.objects.filter(mobile=self.user.mobile).count(), 2)
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': original.opt}).status_code, 200)
self.assertFalse(SmsOpt.objects.filter(mobile=self.user.mobile).exists())
@override_settings(SMS_SUCCESS_CODES='0,1', SMS_FAILURE_CODES='error,-1')
def test_configured_response_codes_and_explicit_rejection(self):
with patch('utils.services.sms.requests.get', return_value=Mock(text='\ufeff1\r\n')):
sent = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(sent.status_code, 200)
self.assertEqual(sent.data['result']['delivery_status'], 'accepted')
original = SmsOpt.objects.get()
SmsOpt.objects.filter(pk=original.pk).update(created_at=timezone.now() - timedelta(seconds=61))
with patch('utils.services.sms.requests.get', return_value=Mock(text='-1')):
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 503)
self.assertEqual(SmsOpt.objects.get().pk, original.pk)
def test_complete_customer_journey_login_refresh_and_logout(self):
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True) as send:
sent = self.post('login/mobile/', {'mobile': '۰۹۱۲۳۴۵۶۷۸۹'})
self.assertEqual(sent.status_code, 200)
self.assertEqual(sent.data['result']['mobile'], self.user.mobile)
self.assertEqual(sent.data['result']['retry_after'], 60)
send.assert_called_once()
code = SmsOpt.objects.get(mobile=self.user.mobile).opt
options = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code})
self.assertEqual(options.status_code, 200)
self.assertEqual(len(options.data), 1)
self.assertEqual(options.data[0]['cuser_role']['role_type']['role_name_en'], 'workspace_customer')
tokens = self.post('token/', {'uuid_opt': options.data[0]['uuid']})
self.assertEqual(tokens.status_code, 200)
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + tokens.data['access'])
self.assertEqual(self.client.get('/cuser/info/').status_code, 200)
rotated = self.post('token/refresh/', {'refresh': tokens.data['refresh']})
self.assertEqual(rotated.status_code, 200)
self.assertNotEqual(rotated.data['refresh'], tokens.data['refresh'])
self.assertEqual(self.post('logout/', {'refresh': tokens.data['refresh']}).status_code, 204)
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + rotated.data['access'])
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': rotated.data['refresh']}).status_code, 401)
def test_repeat_send_does_not_invalidate_code_or_send_another_sms(self):
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True) as send:
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 200)
original = SmsOpt.objects.get(mobile=self.user.mobile)
for _ in range(3):
repeated = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(repeated.status_code, 429)
self.assertIn('قبلاً ارسال', repeated.data['detail'])
self.assertGreater(int(repeated['Retry-After']), 0)
self.assertEqual(SmsOpt.objects.get().pk, original.pk)
self.assertEqual(SmsOpt.objects.get().opt, original.opt)
send.assert_called_once()
verified = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': original.opt})
self.assertEqual(verified.status_code, 200)
def test_resend_after_interval_replaces_code_once(self):
original = self.otp()
SmsOpt.objects.filter(pk=original.pk).update(created_at=timezone.now() - timedelta(seconds=61))
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True) as send:
response = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(response.status_code, 200)
self.assertFalse(SmsOpt.objects.filter(pk=original.pk).exists())
self.assertEqual(SmsOpt.objects.filter(mobile=self.user.mobile).count(), 1)
send.assert_called_once()
def test_expired_code_does_not_prevent_requesting_a_new_one(self):
self.otp(expired=True)
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True):
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 200)
def test_panel_disabled_between_send_and_verify_has_clear_error(self):
code = self.otp()
self.role.customer_panel = False
self.role.save(update_fields=['customer_panel'])
response = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code.opt})
self.assertEqual(response.status_code, 403)
self.assertIn('پنل مشتریان', str(response.data))
def test_double_verify_and_double_token_exchange_cannot_create_multiple_sessions(self):
from apps.jwt_auth.models import JwtToken
code = self.otp()
first = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code.opt})
self.assertEqual(first.status_code, 200)
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code.opt}).status_code, 400)
option = first.data[0]['uuid']
self.assertEqual(self.post('token/', {'uuid_opt': option}).status_code, 200)
self.assertEqual(self.post('token/', {'uuid_opt': option}).status_code, 401)
self.assertEqual(JwtToken.objects.count(), 1)
+42
View File
@@ -0,0 +1,42 @@
"""Refresh locking must work on PostgreSQL despite nullable role relations."""
from django.db import connection
from django.test import skipUnlessDBFeature
from django.test.utils import CaptureQueriesContext
from apps.cuser.models import CuserRole
from apps.jwt_auth.models import JwtToken
from apps.jwt_auth.test_regressions import RegressionFixtures
class RefreshLockingTests(RegressionFixtures):
@skipUnlessDBFeature('has_select_for_update_of')
def test_refresh_locks_only_session_and_rejects_reuse(self):
tokens = self.use_role()
with CaptureQueriesContext(connection) as queries:
response = self.post('token/refresh/', {'refresh': tokens['refresh']})
self.assertEqual(response.status_code, 200)
locking = [query['sql'] for query in queries if 'FOR UPDATE' in query['sql'].upper()]
self.assertEqual(len(locking), 1)
# The actual endpoint SQL retains LEFT JOINs for nullable relations but
# targets only the JWT row in its PostgreSQL locking clause.
self.assertIn('LEFT OUTER JOIN', locking[0].upper())
expected = 'FOR UPDATE OF ' + connection.ops.quote_name(JwtToken._meta.db_table)
self.assertEqual(locking[0].split('FOR UPDATE', 1)[1].strip(), expected.split('FOR UPDATE', 1)[1].strip())
self.assertNotEqual(response.data['refresh'], tokens['refresh'])
self.assertEqual(JwtToken.objects.count(), 1)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
def test_refresh_for_role_without_workspace_is_unauthorized(self):
tokens = self.use_role()
CuserRole.objects.filter(pk=self.role.pk).update(sys_ws=None)
response = self.post('token/refresh/', {'refresh': tokens['refresh']})
self.assertEqual(response.status_code, 401)
self.assertIn('فضای کاری', str(response.data))
self.assertEqual(JwtToken.objects.get().refresh, tokens['refresh'])
def test_refresh_for_role_without_user_is_unauthorized(self):
tokens = self.use_role()
CuserRole.objects.filter(pk=self.role.pk).update(cuser=None)
response = self.post('token/refresh/', {'refresh': tokens['refresh']})
self.assertEqual(response.status_code, 401)
self.assertEqual(JwtToken.objects.get().refresh, tokens['refresh'])
+494
View File
@@ -0,0 +1,494 @@
"""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()
SmsOpt.objects.filter(pk=old.pk).update(created_at=timezone.now() - timedelta(minutes=2))
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):
old = self.otp()
SmsOpt.objects.filter(pk=old.pk).update(created_at=timezone.now() - timedelta(minutes=2))
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.assertIsNone(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_quarantines_without_deleting_customers(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')
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
duplicate.refresh_from_db()
self.customer.refresh_from_db()
self.role.refresh_from_db()
self.assertIsNone(duplicate.mobile)
self.assertIsNone(self.customer.mobile)
self.assertIsNone(self.customer.cuser_role_id)
self.assertFalse(self.role.customer_panel)
from apps.customer.models import MobileConflict
self.assertEqual(MobileConflict.objects.get(record_id=duplicate.pk).original_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_catalog_order_changes_follow_database_and_zero_precedes_null(self):
Product.objects.create(name='Unordered product', category=self.unordered, sys_ws=self.workspace, price=1)
self.use_role()
ProductCategory.objects.filter(pk=self.second.pk).update(order_cat=0)
response = self.client.get('/shop/v2/product-category/list/')
self.assertEqual([row['name'] for row in response.data], ['Second', 'First', 'Unordered'])
Product.objects.filter(pk=self.a.pk).update(order_prd=0)
Product.objects.filter(pk=self.b.pk).update(order_prd=None)
response = self.client.get(f'/shop/product/list/{self.first.pk}/')
self.assertEqual([row['name'] for row in response.data['products']], ['A', 'B'])
Product.objects.create(name='Custom later', category=self.first, sys_ws=self.workspace, price=1, view_only=True, order_prd=None)
Product.objects.create(name='Custom first', category=self.first, sys_ws=self.workspace, price=1, view_only=True, order_prd=0)
response = self.client.get(f'/shop/product/list/{self.first.pk}/')
self.assertEqual([row['name'] for row in response.data['view_only_products']], ['Custom first', 'Custom later'])
def test_order_excel_uses_current_database_order_independently_of_insertion(self):
order = Order.objects.create(customer=self.customer, total_price=60, factor_type=1, sys_ws=self.workspace)
for product in [self.c, self.b, self.a]:
OrderItem.objects.create(order=order, product=product, product_name=product.name, price=product.price, amount=1)
self.use_role(self.operator)
ProductCategory.objects.filter(pk=self.first.pk).update(excel_order=0)
Product.objects.filter(pk=self.b.pk).update(excel_order=0)
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:]], ['FirstB', 'FirstA', 'SecondC'])
ProductCategory.objects.update(excel_order=None)
Product.objects.update(excel_order=None)
ProductCategory.objects.filter(pk=self.second.pk).update(order_cat=0)
response = self.client.get(f'/shop/order/{order.pk}/operator/excel/')
sheet = load_workbook(BytesIO(response.content)).active
self.assertEqual([row[0] for row in list(sheet.values)[1:]], ['SecondC', 'FirstB', 'FirstA'])
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)
+3 -1
View File
@@ -4,10 +4,12 @@ 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'),
+98 -244
View File
@@ -1,297 +1,151 @@
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()
from apps.jwt_auth.session import issue_tokens, validate_session_claims
from apps.workspace.models import Workspace
# Create your views here.
class VerifyMobileAndGetTokenView(APIView):
class InputSerializer(serializers.Serializer):
mobile = serializers.IntegerField(required=True)
opt = serializers.IntegerField(required=True)
class OutputSerializer(serializers.ModelSerializer):
class CUserRoleSerializer(serializers.ModelSerializer):
class RoleTypeSerializer(serializers.ModelSerializer):
class Meta:
model = RoleTypeModel
fields = ('role_name', )
model = RoleType
fields = ('role_name', 'role_name_en')
class RoleSerializer(serializers.ModelSerializer):
class WorkspaceSerializer(serializers.ModelSerializer):
class Meta:
model = Workspace
fields = ('id', 'name')
role_type = RoleTypeSerializer()
sys_ws = WorkspaceSerializer()
class Meta:
model = CuserRoleModel
fields = ('name_family', 'title', 'role_type')
model = CuserRole
fields = ('name_family', 'title', 'role_type', 'sys_ws')
cuser_role = CUserRoleSerializer()
class LoginOptionSerializer(serializers.ModelSerializer):
cuser_role = RoleSerializer()
class Meta:
model = CUserRoleLoginOpt
fields = ('cuser_role', 'expire', 'uuid')
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
login_opts = verify_mobile_and_get_token(**serializer.validated_data)
class PublicAuthView(APIView):
authentication_classes = ()
permission_classes = (AllowAny,)
response_data = self.OutputSerializer(login_opts, many=True).data
return Response(response_data)
def get_authenticate_header(self, request):
return 'Bearer realm="api"'
class LoginByMobileView(APIView):
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)
result = login_by_mobile(**serializer.validated_data)
return Response({'result': result}, status=202 if result['delivery_status'] == 'unconfirmed' else 200)
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)
# Nullable user/workspace joins must not participate in PostgreSQL's
# row lock. The session alone serializes refresh-token rotation.
session = JwtToken.objects.select_for_update(of=('self',)).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))
value = serializer.validated_data['refresh']
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)})
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
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
+113
View File
@@ -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)
+59
View File
@@ -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')
+22
View File
@@ -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}'
+89
View File
@@ -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 -4
View File
@@ -1,10 +1,10 @@
from turtle import mode
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.
@@ -20,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}'
@@ -32,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}'
+18
View File
@@ -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',
)
+274
View File
@@ -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
+7 -14
View File
@@ -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['عامل کنترل موجودی اول'] = ''
+3 -2
View File
@@ -1,5 +1,6 @@
from apps.shop.models import Product as ProductModel
from rest_framework import exceptions
from apps.shop.ordering import product_ordering
def retrieve_product(product_id: int, workspace_uuid: str) -> ProductModel:
@@ -23,7 +24,7 @@ def list_product(workspace_uuid: str) -> ProductModel:
def list_product_by_category(workspace_uuid: str, category_id: int) -> ProductModel:
try:
product_category_obj = ProductModel.objects.filter(sys_ws__uuid=workspace_uuid, category_id=category_id,
view_only=False)
view_only=False).order_by(*product_ordering())
return product_category_obj
except Exception as exp:
message = f'Error Fetching List Product by category: {exp}'
@@ -33,7 +34,7 @@ def list_product_by_category(workspace_uuid: str, category_id: int) -> ProductMo
def list_view_only_product_by_category(workspace_uuid: str, category_id: int) -> ProductModel:
try:
view_only_product_category_obj = ProductModel.objects.filter(sys_ws__uuid=workspace_uuid, category_id=category_id,
view_only=True)
view_only=True).order_by(*product_ordering())
return view_only_product_category_obj
except Exception as exp:
message = f'Error Fetching View Only List Product by category: {exp}'
+33
View File
@@ -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()
+22 -48
View File
@@ -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)
+22 -33
View File
@@ -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 %}
+338
View File
@@ -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)
+289
View File
@@ -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)
+18
View File
@@ -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'),
+59 -67
View File
@@ -5,45 +5,34 @@ from apps.shop.models import Product as ProductModel, ProductCategory as Product
from apps.workspace.models import Workspace as WorkspaceModel, City as CityModel
from apps.customer.models import Customer as CustomerModel
from apps.customer.models import ResponsibleGroup as ResponsibleGroupModel
import os
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError, transaction
from rest_framework import exceptions
from rest_framework.permissions import IsAuthenticated
from utils import custom_permissions
from utils.get_user_workspace import get_user_workspace
from utils.validations.mobile import normalize_mobile
class ImportExcelView(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission)
def get(self, request):
df = pd.read_excel('pe.xlsx')
for index, row in df.iterrows():
cate_name = row['cate_name']
#order_cat = row['order_cat']
acc_code = row['acc_code']
prd_name = row['name']
order_prd = row['size']
vahed = row['vahed']
workspace = WorkspaceModel.objects.get(id=2)
# 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:
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'})
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'})
class ImportExcelCustomersView(APIView):
def get(self, request):
df = pd.read_excel('customersData.xlsx')
for index, row in df.iterrows():
name = row['name']
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()
if name and mobile:
phone = row['phone']
address = row['address']
ostan = row['ostan']
shahr = row['shar']
mantaghe = row['id_mantaghe']
class ImportExcelCustomersView(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission)
@transaction.atomic
def get(self, request):
workspace = get_user_workspace(request)
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)
data = pd.read_excel('customersData.xlsx')
except (OSError, ValueError):
raise exceptions.ValidationError('فایل اکسل مشتریان خوانده نشد.')
for index, row in data.iterrows():
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'})
customer_from_excel(row, workspace)
except DjangoValidationError as error:
raise exceptions.ValidationError({'row': int(index)+2, 'errors': error.message_dict})
except IntegrityError:
raise exceptions.ValidationError({'row': int(index)+2, 'mobile': 'شماره موبایل مشتری تکراری است.'})
except exceptions.ValidationError as error:
raise exceptions.ValidationError({'row': int(index)+2, 'errors': error.detail})
except (KeyError, TypeError, ValueError):
raise exceptions.ValidationError({'row': int(index)+2, 'error': 'اطلاعات این ردیف معتبر نیست.'})
return Response(data={'result': 'ok', 'imported': len(data)})
+293
View File
@@ -0,0 +1,293 @@
"""JWT-protected workspace administration for the CRM product pages."""
from django.core.exceptions import ValidationError as ModelValidationError
from django.db import DataError, IntegrityError, transaction
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import serializers
from rest_framework.exceptions import APIException, ValidationError
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.shop.import_batches import preview_batch, confirm_batch, batch_report, import_template
from apps.shop.import_models import ProductImportBatch
from apps.shop.management_serializers import ProductManagementSerializer, CategoryManagementSerializer, ImportOptionsSerializer
from apps.shop.models import Product, ProductCategory, Cart, OrderItem
from apps.shop.ordering import product_ordering
from apps.workspace.models import Workspace
from utils.custom_permissions import WorkspaceAdminPermission
from utils.get_user_workspace import get_user_workspace
class CatalogConflict(APIException):
status_code = 409
default_detail = 'این عملیات با داده‌های فعلی سازگار نیست.'
def ensure_product_deletable(product):
if OrderItem.objects.filter(product=product).exists() or Cart.objects.filter(product=product).exists():
raise CatalogConflict('محصول در سفارش یا سبد استفاده شده است؛ برای حفظ سوابق امکان حذف ندارد.')
def ensure_category_deletable(category):
if Product.objects.filter(category=category).exists() or ProductCategory.objects.filter(parent=category).exists():
raise CatalogConflict('دسته دارای محصول یا زیردسته است؛ ابتدا محصولات و زیردسته‌ها را منتقل کنید.')
class ManagementPagination(PageNumberPagination):
page_size = 25
page_size_query_param = 'page_size'
max_page_size = 100
class ManagementApi(APIView):
permission_classes = (IsAuthenticated, WorkspaceAdminPermission)
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
self.workspace = get_user_workspace(request)
def handle_exception(self, exc):
if isinstance(exc, ModelValidationError):
exc = ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else {'detail': exc.messages})
elif isinstance(exc, (DataError, IntegrityError)):
exc = CatalogConflict('داده‌ها با محدودیت‌های دیتابیس سازگار نیستند یا هم‌زمان تغییر کرده‌اند؛ اطلاعات را تازه کنید.')
return super().handle_exception(exc)
def serializer_context(self):
return {'request': self.request, 'workspace': self.workspace}
def lock_workspace(self):
Workspace.objects.select_for_update().get(pk=self.workspace.pk)
class ProductFilters(serializers.Serializer):
search = serializers.CharField(required=False, allow_blank=True, max_length=200)
category = serializers.IntegerField(required=False, min_value=1)
view_only = serializers.BooleanField(required=False)
price_min = serializers.IntegerField(required=False, min_value=0)
price_max = serializers.IntegerField(required=False, min_value=0)
ordering = serializers.ChoiceField(choices=('display', 'excel', 'name', '-name', 'price', '-price', '-created_at'), default='display')
def validate(self, attrs):
if attrs.get('price_min', 0) > attrs.get('price_max', 9223372036854775807):
raise ValidationError({'price_max': 'حداکثر قیمت باید از حداقل کمتر نباشد.'})
return attrs
def filtered_products(workspace, parameters):
serializer = ProductFilters(data=parameters)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
queryset = Product.objects.filter(sys_ws=workspace).select_related('category')
if data.get('search'):
queryset = queryset.filter(Q(name__icontains=data['search']) | Q(acc_code__icontains=data['search']) | Q(category__name__icontains=data['search']))
if 'category' in data:
queryset = queryset.filter(category_id=data['category'])
if 'view_only' in data:
queryset = queryset.filter(view_only=data['view_only'])
for key, lookup in (('price_min', 'price__gte'), ('price_max', 'price__lte')):
if key in data:
queryset = queryset.filter(**{lookup: data[key]})
ordering = data['ordering']
return queryset.order_by(*product_ordering(excel=ordering == 'excel')) if ordering in ('display', 'excel') else queryset.order_by(ordering, 'pk')
class ProductManagementListApi(ManagementApi):
def get(self, request):
pagination = ManagementPagination()
rows = pagination.paginate_queryset(filtered_products(self.workspace, request.query_params), request)
return pagination.get_paginated_response(ProductManagementSerializer(rows, many=True).data)
@transaction.atomic
def post(self, request):
self.lock_workspace()
serializer = ProductManagementSerializer(data=request.data, context=self.serializer_context())
serializer.is_valid(raise_exception=True)
product = Product(sys_ws=self.workspace, **serializer.validated_data)
product.full_clean()
product.save()
return Response(ProductManagementSerializer(product).data, status=201)
class ProductManagementDetailApi(ManagementApi):
def get(self, request, pk):
product = get_object_or_404(Product, pk=pk, sys_ws=self.workspace)
return Response(ProductManagementSerializer(product).data)
@transaction.atomic
def patch(self, request, pk):
self.lock_workspace()
product = get_object_or_404(Product.objects.select_for_update(), pk=pk, sys_ws=self.workspace)
serializer = ProductManagementSerializer(product, data=request.data, partial=True, context=self.serializer_context())
serializer.is_valid(raise_exception=True)
for name, value in serializer.validated_data.items():
setattr(product, name, value)
product.full_clean()
product.save()
return Response(ProductManagementSerializer(product).data)
put = patch
@transaction.atomic
def delete(self, request, pk):
self.lock_workspace()
product = get_object_or_404(Product.objects.select_for_update(), pk=pk, sys_ws=self.workspace)
ensure_product_deletable(product)
product.delete()
return Response(status=204)
class CategoryManagementListApi(ManagementApi):
def get(self, request):
queryset = ProductCategory.objects.filter(sys_ws=self.workspace).annotate(product_count=Count('product', distinct=True), child_count=Count('productcategory', distinct=True))
return Response(CategoryManagementSerializer(queryset, many=True, context=self.serializer_context()).data)
@transaction.atomic
def post(self, request):
self.lock_workspace()
serializer = CategoryManagementSerializer(data=request.data, context=self.serializer_context())
serializer.is_valid(raise_exception=True)
category = ProductCategory(sys_ws=self.workspace, **serializer.validated_data)
category.full_clean()
category.save()
return Response(CategoryManagementSerializer(category, context=self.serializer_context()).data, status=201)
class CategoryManagementDetailApi(ManagementApi):
def get(self, request, pk):
category = get_object_or_404(ProductCategory, pk=pk, sys_ws=self.workspace)
return Response(CategoryManagementSerializer(category, context=self.serializer_context()).data)
@transaction.atomic
def patch(self, request, pk):
self.lock_workspace()
category = get_object_or_404(ProductCategory.objects.select_for_update(), pk=pk, sys_ws=self.workspace)
serializer = CategoryManagementSerializer(category, data=request.data, partial=True, context=self.serializer_context())
serializer.is_valid(raise_exception=True)
for name, value in serializer.validated_data.items():
setattr(category, name, value)
category.full_clean()
category.save()
return Response(CategoryManagementSerializer(category, context=self.serializer_context()).data)
put = patch
@transaction.atomic
def delete(self, request, pk):
self.lock_workspace()
category = get_object_or_404(ProductCategory.objects.select_for_update(), pk=pk, sys_ws=self.workspace)
ensure_category_deletable(category)
category.delete()
return Response(status=204)
class ProductBulkSerializer(serializers.Serializer):
ids = serializers.ListField(child=serializers.IntegerField(min_value=1), min_length=1, max_length=100)
action = serializers.ChoiceField(choices=('delete', 'move', 'view_only', 'display_order', 'excel_order'))
category = serializers.IntegerField(min_value=1, required=False)
view_only = serializers.BooleanField(required=False)
def validate(self, attrs):
if len(set(attrs['ids'])) != len(attrs['ids']):
raise ValidationError({'ids': 'شناسه‌های تکراری مجاز نیست.'})
for action, key in (('move', 'category'), ('view_only', 'view_only')):
if attrs['action'] == action and key not in attrs:
raise ValidationError({key: 'این مقدار برای عملیات انتخاب‌شده لازم است.'})
return attrs
class ProductBulkApi(ManagementApi):
@transaction.atomic
def post(self, request):
self.lock_workspace()
input_serializer = ProductBulkSerializer(data=request.data)
input_serializer.is_valid(raise_exception=True)
data = input_serializer.validated_data
products = {item.pk: item for item in Product.objects.select_for_update().filter(sys_ws=self.workspace, pk__in=data['ids'])}
if len(products) != len(data['ids']):
raise ValidationError({'ids': 'یک یا چند محصول در فضای کاری شما پیدا نشد.'})
for position, pk in enumerate(data['ids'], 1):
product = products[pk]
if data['action'] == 'delete':
ensure_product_deletable(product)
product.delete()
else:
values = bulk_values(data, position)
serializer = ProductManagementSerializer(product, data=values, partial=True, context=self.serializer_context())
serializer.is_valid(raise_exception=True)
for name, value in serializer.validated_data.items():
setattr(product, name, value)
product.full_clean()
product.save()
return Response({'updated': len(products), 'action': data['action']})
def bulk_values(data, position):
if data['action'] == 'move':
return {'category': data['category']}
if data['action'] == 'view_only':
return {'view_only': data['view_only']}
return {'order_prd' if data['action'] == 'display_order' else 'excel_order': position}
class ProductExportApi(ManagementApi):
def get(self, request):
from apps.shop.admin import export_products
return export_products(filtered_products(self.workspace, request.query_params))
def batch_data(batch, page=1):
result = batch.result
rows = result.get('rows', [])
return {'id': batch.pk, 'filename': batch.filename, 'created_at': batch.created_at, 'expires_at': batch.expires_at,
'confirmed_at': batch.confirmed_at, 'options': batch.payload['options'],
'created': result.get('created', 0), 'updated': result.get('updated', 0), 'errors': result.get('errors', 0),
'committed': bool(batch.confirmed_at), 'can_confirm': not batch.confirmed_at and batch.expires_at > timezone.now()
and (batch.payload['options']['errors'] == 'skip' or not result.get('errors')),
'row_count': len(rows), 'row_page': page, 'rows': rows[(page-1)*100:page*100]}
class ProductImportPreviewApi(ManagementApi):
def post(self, request):
serializer = ImportOptionsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
options = {key: data[key] for key in ('mode', 'create_categories', 'errors', 'ordering')}
batch = preview_batch(request.user, self.workspace, data['file'], options, data['sheet'], data['header_row'], data['mapping'])
return Response(batch_data(batch), status=201)
class ProductImportListApi(ManagementApi):
def get(self, request):
queryset = ProductImportBatch.objects.filter(sys_ws=self.workspace, created_by=request.user)
pagination = ManagementPagination()
rows = pagination.paginate_queryset(queryset, request)
results = [{key: value for key, value in batch_data(batch).items() if key != 'rows'} for batch in rows]
return pagination.get_paginated_response(results)
class ProductImportDetailApi(ManagementApi):
def get(self, request, pk):
batch = get_object_or_404(ProductImportBatch, pk=pk, sys_ws=self.workspace, created_by=request.user)
page_serializer = serializers.IntegerField(min_value=1, max_value=50)
page = page_serializer.run_validation(request.query_params.get('row_page', 1))
return Response(batch_data(batch, page))
class ProductImportConfirmApi(ManagementApi):
def post(self, request, pk):
return Response(batch_data(confirm_batch(pk, request.user, self.workspace)))
class ProductImportReportApi(ManagementApi):
def get(self, request, pk):
batch = get_object_or_404(ProductImportBatch, pk=pk, sys_ws=self.workspace, created_by=request.user)
return batch_report(batch)
class ProductImportTemplateApi(ManagementApi):
def get(self, request):
return import_template()
+4 -2
View File
@@ -11,6 +11,7 @@ from rest_framework import generics
from apps.customer.models import Customer as CustomerModel
from rest_framework.permissions import IsAuthenticated
from utils import custom_permissions
import pandas as pd
from utils.tools import inline_serializer
from io import BytesIO
@@ -212,13 +213,14 @@ class OrderRetrieveOperatorApi(APIView):
class OrderRetrieveOperatorExcelApi(APIView):
permission_classes = (IsAuthenticated, )
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission)
def get(self, request, order_id):
order_df = retrieve_order_item_excel(request=request, order_id=order_id)
excel_file = BytesIO()
order_df.to_excel(excel_file, engine='xlsxwriter', index=False)
with pd.ExcelWriter(excel_file, engine='xlsxwriter', engine_kwargs={'options': {'strings_to_formulas': False, 'strings_to_urls': False}}) as writer:
order_df.to_excel(writer, index=False)
excel_file.seek(0)
response = HttpResponse(excel_file.read(), content_type='application/ms-excel')
+15 -9
View File
@@ -16,11 +16,14 @@ class ProductCreateApi(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission)
class InputSerializer(serializers.Serializer):
name = serializers.CharField(required=True, max_length=20)
price = serializers.DecimalField(required=True, max_digits=20, decimal_places=2)
name = serializers.CharField(required=True, max_length=200)
price = serializers.IntegerField(required=True, min_value=0, max_value=9223372036854775807)
category = serializers.IntegerField(required=True)
acc_code = serializers.CharField(required=False)
attr = serializers.CharField(required=False)
attr = serializers.JSONField(required=False, allow_null=True)
order_prd = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
view_only = serializers.BooleanField(required=False)
class OutputSerializer(serializers.ModelSerializer):
class Meta:
@@ -64,11 +67,14 @@ class ProductUpdateApi(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceAdminPermission)
class InputSerializer(serializers.Serializer):
name = serializers.CharField(required=True, max_length=20)
price = serializers.DecimalField(required=True, max_digits=20, decimal_places=2)
name = serializers.CharField(required=True, max_length=200)
price = serializers.IntegerField(required=True, min_value=0, max_value=9223372036854775807)
category = serializers.IntegerField(required=True)
acc_code = serializers.CharField(required=False)
attr = serializers.CharField(required=False)
attr = serializers.JSONField(required=False, allow_null=True)
order_prd = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
excel_order = serializers.IntegerField(required=False, allow_null=True, min_value=0, max_value=2147483647)
view_only = serializers.BooleanField(required=False)
class OutputSerializer(serializers.ModelSerializer):
class Meta:
@@ -77,7 +83,7 @@ class ProductUpdateApi(APIView):
def put(self, request, product_id: int):
serializer = self.InputSerializer(data=request.data)
serializer = self.InputSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
user_workspace_uuid = get_user_workspace_uuid(request)
@@ -116,7 +122,7 @@ class ProductListApi(generics.ListAPIView):
class OutputSerializer(serializers.ModelSerializer):
class Meta:
model = ProductCategoryModel
model = ProductModel
exclude = ('sys_ws', )
def get_queryset(self):
@@ -154,7 +160,7 @@ class ProductListCategoryApi(generics.ListAPIView):
)
data = {
"category_name": category.name,
"category_img": category.image.url,
"category_img": category.image.url if category.image else None,
"products": serializer.data,
}
+15 -6
View File
@@ -9,6 +9,7 @@ from apps.shop.selectors import *
from rest_framework import generics
from rest_framework.permissions import IsAuthenticated
from django.db.models import Count
from apps.shop.ordering import category_ordering
from utils.tools import inline_serializer
from utils import custom_permissions
@@ -18,8 +19,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 +65,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 +80,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 +152,7 @@ class ProductCategoryListApi(generics.ListAPIView):
class ProductsCategoryApi(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceCustomerPermission)
class OutputSerializer(serializers.ModelSerializer):
product_count = serializers.IntegerField()
@@ -156,7 +163,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).order_by(*category_ordering())
output = self.OutputSerializer(p_cate, many=True)
return Response(data=output.data, status=status.HTTP_200_OK)
+16 -3
View File
@@ -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
+3 -2
View File
@@ -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
+7 -2
View File
@@ -37,8 +37,12 @@ CORS_ALLOWED_ORIGINS = [
"http://localhost:3002",
"http://localhost:3000",
"https://loolegostar.liara.run",
"https://crm-api.loolegostar.com",
"https://crm.loolegostar.com",
"https://portal.loolegostar.com",
"https://loolegostar-crm.liara.run",
]
CSRF_TRUSTED_ORIGINS = CORS_ALLOWED_ORIGINS
CORS_ALLOW_METHODS = [
"DELETE",
@@ -84,6 +88,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -97,7 +102,7 @@ ROOT_URLCONF = 'config.urls'
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
'apps.jwt_auth.authentication.SessionJWTAuthentication',
),
}
@@ -175,7 +180,7 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
+18 -4
View File
@@ -1,12 +1,26 @@
from .base import *
DEBUG = False
MEDIA_ROOT = '/app/media'
MEDIA_URL = '/media/'
STATIC_URL = '/static/'
STATIC_ROOT = '/app/staticfiles'
#DEBUG = False
# 🚀 اضافه کردن تنظیمات ذخیره‌سازی وایت‌نویز
# 🚀 اصلاح تنظیمات ذخیره‌سازی برای جنگو ۴.۲ و بالاتر
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'crm-api',
'USER': 'root',
'PASSWORD': '1yI0Iw2Fpjm2BXhEnwUKVdDg',
'HOST': 'loolegostar-db',
'USER': 'postgres',
'PASSWORD': '1D24yc3FvHHDJdv3g8wbxjXhy9FiFCRCROiTsqBjOd2MV0Q49MX457Aq9CQX7fxV',
'HOST': 'bp6c6o5jtfzo8px8cdc8juej',
'PORT': '5432'
}
}
+7
View File
@@ -0,0 +1,7 @@
"""Isolated regression settings; production data and SMS are never used."""
from .base import *
DATABASES = {'default': {'ENGINE': 'config.test_sqlite', 'NAME': ':memory:'}}
SILENCED_SYSTEM_CHECKS = ['fields.E120'] # Existing PostgreSQL unbounded varchar.
MIDDLEWARE = [item for item in MIDDLEWARE if not item.startswith('whitenoise.')]
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
View File
Whitespace-only changes.
+6
View File
@@ -0,0 +1,6 @@
"""Mirror PostgreSQL's existing unbounded varchar fields in SQLite tests."""
from django.db.backends.sqlite3.base import DatabaseWrapper as SQLiteDatabaseWrapper
class DatabaseWrapper(SQLiteDatabaseWrapper):
data_types = {**SQLiteDatabaseWrapper.data_types, 'CharField': 'varchar'}
+1 -1
View File
@@ -12,4 +12,4 @@ echo "==> Running Database Migrations..."
python manage.py migrate --noinput
echo "==> Starting Gunicorn..."
exec gunicorn --bind 0.0.0.0:8000 myproject.wsgi:application
exec gunicorn --bind 0.0.0.0:8000 config.wsgi:application
+2
View File
@@ -1,3 +1,5 @@
gunicorn==22.0.0
whitenoise==6.0.0
Django==4.2.1
djangorestframework==3.14.0
Markdown==3.4.3
+109
View File
@@ -0,0 +1,109 @@
"""Common admin ergonomics without changing application API contracts."""
from django import forms
from django.contrib import admin
from django.db import models
from django.core.exceptions import ValidationError
from utils.admin_excel import excel_response
def workspace_id(obj):
if obj is None:
return None
if hasattr(obj, 'sys_ws_id'):
return obj.sys_ws_id
for relation in ('order', 'form', 'ticket_comment'):
if hasattr(obj, relation):
return workspace_id(getattr(obj, relation))
return None
class RelatedWorkspaceForm(forms.ModelForm):
def clean(self):
cleaned = super().clean()
workspace = cleaned.get('sys_ws')
scope = workspace.pk if workspace else None
if scope is None:
for key in ('order', 'form', 'ticket_comment'):
scope = workspace_id(cleaned.get(key))
if scope is not None:
break
if scope is not None:
for field in self._meta.model._meta.fields:
if field.is_relation and field.name != 'sys_ws':
value = cleaned.get(field.name)
related_scope = workspace_id(value)
if related_scope is not None and related_scope != scope:
self.add_error(field.name, 'این رابطه متعلق به فضای کاری دیگری است.')
self.check_category_parent(cleaned)
self.check_domain_values(cleaned)
return cleaned
def check_domain_values(self, cleaned):
label = self._meta.model._meta.label_lower
if label == 'cuser.cuserrole' and cleaned.get('mobile') is not None:
from utils.validations.mobile import normalize_mobile
try:
cleaned['mobile'] = normalize_mobile(cleaned['mobile'])
except ValidationError as error:
self.add_error('mobile', error.messages)
user = cleaned.get('cuser')
if user and user.mobile != cleaned.get('mobile'):
self.add_error('mobile', 'شماره نقش باید با شماره کاربر یکسان باشد.')
if label in ('shop.cart', 'shop.orderitem'):
key = 'quantity' if label == 'shop.cart' else 'amount'
if cleaned.get(key) is not None and cleaned[key] < 1:
self.add_error(key, 'تعداد باید حداقل یک باشد.')
if label == 'shop.orderitem' and cleaned.get('price') is not None and cleaned['price'] < 0:
self.add_error('price', 'قیمت نباید منفی باشد.')
def check_category_parent(self, cleaned):
if self._meta.model._meta.label_lower != 'shop.productcategory':
return
parent = cleaned.get('parent')
visited = set()
while parent is not None:
if parent.pk == self.instance.pk or parent.pk in visited:
self.add_error('parent', 'دسته والد نباید یک چرخه ایجاد کند.')
return
visited.add(parent.pk)
parent = parent.parent
class CRMModelAdmin(admin.ModelAdmin):
form = RelatedWorkspaceForm
list_per_page = 50
show_full_result_count = False
save_on_top = True
actions = ('export_selected',)
sensitive_fields = ()
def __init__(self, model, admin_site):
super().__init__(model, admin_site)
fields = model._meta.fields
names = {field.name for field in fields}
if self.list_display == ('__str__',):
candidates = ('id', 'name', 'name_family', 'title', 'mobile', 'customer', 'lead', 'form',
'product', 'order', 'cuser_role', 'role_type', 'sys_ws', 'status', 'type',
'is_active', 'expire', 'datetime', 'created_at')
self.list_display = tuple(name for name in candidates if name in names)[:9]
if not self.search_fields:
self.search_fields = tuple(field.name for field in fields if isinstance(field, (models.CharField, models.TextField))
and field.name not in self.sensitive_fields)
if not self.list_filter:
self.list_filter = tuple(field.name for field in fields if field.name == 'sys_ws' or field.choices
or isinstance(field, models.BooleanField))
self.readonly_fields = tuple(dict.fromkeys((*self.readonly_fields,
*(name for name in ('created_at', 'updated_at', 'uuid') if name in names))))
self.raw_id_fields = tuple(field.name for field in fields if field.many_to_one)
self.list_select_related = tuple(field.name for field in fields if field.many_to_one)
if 'created_at' in names:
self.date_hierarchy = 'created_at'
if not self.ordering and not model._meta.ordering:
self.ordering = ('-pk',)
@admin.action(description='خروجی اکسل ردیف‌های انتخاب‌شده', permissions=['view'])
def export_selected(self, request, queryset):
fields = [field for field in self.model._meta.fields if field.name not in self.sensitive_fields]
rows = queryset.values_list(*(field.attname for field in fields)).iterator()
return excel_response([str(field.verbose_name) for field in fields], rows, f'{self.model._meta.model_name}.xlsx')
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime
from io import BytesIO
from itertools import chain
import json
from django.http import HttpResponse
from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
from openpyxl.utils import get_column_letter
def excel_response(headers, rows, filename):
workbook = Workbook(write_only=True)
sheet = workbook.create_sheet('Products')
sheet.sheet_view.rightToLeft = True
sheet.freeze_panes = 'A2'
for index in range(1, len(headers) + 1):
sheet.column_dimensions[get_column_letter(index)].width = 24
for number, row in enumerate(chain((headers,), rows), 1):
cells = []
for value in row:
if isinstance(value, datetime) and value.tzinfo:
value = value.isoformat()
if isinstance(value, (dict, list)):
value = json.dumps(value, ensure_ascii=False)
if isinstance(value, str):
value = ILLEGAL_CHARACTERS_RE.sub('', value)
cell = WriteOnlyCell(sheet, value=value)
if isinstance(value, str):
cell.data_type = 's'
cells.append(cell)
sheet.append(cells)
sheet.auto_filter.ref = f'A1:{get_column_letter(len(headers))}{number}'
content = BytesIO()
workbook.save(content)
response = HttpResponse(content.getvalue(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
+36 -8
View File
@@ -1,21 +1,25 @@
from abc import ABC, abstractmethod
import requests
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
class SmsProviderABC(ABC):
@abstractmethod
def send_sms(self, mobile: str, message: str) -> bool:
def send_sms(self, mobile: str, message: str) -> bool | None:
pass
class DefaultSmsProvider(SmsProviderABC):
def send_sms(self, mobile: str, message: str) -> bool:
def send_sms(self, mobile: str, message: str) -> bool | None:
default_provider = DosefrNavadHashtSMS()
return default_provider.send_sms(mobile, message)
class DosefrNavadHashtSMS(SmsProviderABC):
def send_sms(self, mobile: str, message: str) -> bool:
def send_sms(self, mobile: str, message: str) -> bool | None:
payload = {
'USERNAME': 'vsms7937',
'PASSWORD': '335577775533',
@@ -23,10 +27,34 @@ class DosefrNavadHashtSMS(SmsProviderABC):
'TO': int(mobile),
'TEXT': f'{message}'
}
r = requests.get('http://www.0098sms.com/sendsmslink.aspx', params=payload)
if r.status_code == 200:
return True
else:
try:
r = requests.get('http://www.0098sms.com/sendsmslink.aspx', params=payload, timeout=10)
r.raise_for_status()
except (requests.Timeout, requests.ConnectionError):
# The provider may have dispatched the SMS before its reply was lost.
logger.warning('SMS provider reply unavailable; dispatch is unconfirmed')
return None
except requests.HTTPError as error:
if error.response is not None and error.response.status_code >= 500:
logger.warning('SMS provider server error; dispatch is unconfirmed')
return None
return False
except requests.RequestException:
return False
# Unknown bodies are NOT evidence of rejection. In particular, never
# discard a delivered OTP just because the response contract differs.
success_codes = getattr(settings, 'SMS_SUCCESS_CODES', ('0',))
failure_codes = getattr(settings, 'SMS_FAILURE_CODES', ('error',))
body = r.text.strip().lstrip('\ufeff').strip()
def codes(values):
if isinstance(values, str):
values = values.split(',')
return {str(value).strip() for value in values}
if body in codes(success_codes):
return True
if body in codes(failure_codes):
return False
# Do not log the reply, URL, credentials, mobile, or OTP.
logger.warning('SMS provider returned an unrecognized reply; dispatch is unconfirmed')
return None
+14
View File
@@ -0,0 +1,14 @@
from django.core.exceptions import ValidationError
def normalize_mobile(value):
"""Keep the integer storage format while accepting Iranian mobile formats."""
mobile = str(value).strip().translate(str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789"))
mobile = mobile.replace(" ", "").replace("-", "")
for prefix in ("+98", "0098", "98", "0"):
if mobile.startswith(prefix) and len(mobile) == 10 + len(prefix):
mobile = mobile[len(prefix):]
break
if len(mobile) != 10 or not mobile.isascii() or not mobile.isdigit() or not mobile.startswith("9"):
raise ValidationError({"mobile": "شماره موبایل معتبر وارد کنید."})
return int(mobile)