275 lines
14 KiB
Python
275 lines
14 KiB
Python
"""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
|