add product

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

No files matched your search

+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)