"""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