import hashlib import io from pathlib import Path import warnings from dateutil import parser as date_parser from django.core.files.base import ContentFile from django.db import transaction from django.utils import timezone from PIL import Image, ImageOps from commerce.integrations.moysklad import MoySkladClient from commerce.models import IntegrationConnection, ProductImage PRODUCT_THUMBNAIL_SIZE = (480, 480) MAX_PRODUCT_IMAGE_PIXELS = 40_000_000 IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"} def _source_updated_at(value: str): if not value: return None try: parsed = date_parser.parse(value) except (TypeError, ValueError, OverflowError): return None if timezone.is_naive(parsed): parsed = timezone.make_aware(parsed) return parsed def _validated_image_and_thumbnail(content: bytes) -> tuple[bytes, str]: with warnings.catch_warnings(): warnings.simplefilter("error", Image.DecompressionBombWarning) with Image.open(io.BytesIO(content)) as probe: if probe.width * probe.height > MAX_PRODUCT_IMAGE_PIXELS: raise ValueError("Изображение превышает лимит 40 мегапикселей.") image_format = (probe.format or "").upper() probe.verify() with Image.open(io.BytesIO(content)) as source: source.seek(0) image = ImageOps.exif_transpose(source) if image.mode not in {"RGB", "RGBA"}: image = image.convert("RGBA" if "transparency" in image.info else "RGB") image.thumbnail(PRODUCT_THUMBNAIL_SIZE, Image.Resampling.LANCZOS) output = io.BytesIO() image.save(output, format="WEBP", quality=84, method=5) suffix = { "JPEG": ".jpg", "PNG": ".png", "WEBP": ".webp", "GIF": ".gif", "BMP": ".bmp", }.get(image_format, ".img") return output.getvalue(), suffix def _storage_filename( external_id: str, source_name: str, content: bytes, detected_suffix: str, ) -> str: suffix = Path(source_name).suffix.lower() if suffix not in IMAGE_SUFFIXES: suffix = detected_suffix digest = hashlib.sha256(content).hexdigest()[:16] return f"{external_id}-{digest}{suffix}" def _delete_replaced_files(image, old_names): for field_name, old_name in old_names.items(): field = getattr(image, field_name) if old_name and old_name != field.name: field.storage.delete(old_name) def sync_moysklad_product_images( *, connection: IntegrationConnection, row: dict, client: MoySkladClient, ) -> int: external_product_id = str(row.get("id") or "").strip() if not external_product_id: return 0 binding = connection.source.product_bindings.select_related("product").get( external_id=external_product_id ) product = binding.product images_meta = row.get("images") if isinstance(row.get("images"), dict) else {} collection_meta = ( images_meta.get("meta") if isinstance(images_meta.get("meta"), dict) else {} ) image_count = max(0, int(collection_meta.get("size") or 0)) if image_count == 0: product.images.filter(source=connection.source).delete() return 0 entity_meta = row.get("meta") if isinstance(row.get("meta"), dict) else {} entity_type = str(entity_meta.get("type") or "") embedded_rows = images_meta.get("rows") remote_images = ( client.parse_product_images(embedded_rows) if isinstance(embedded_rows, list) else client.product_images(entity_type, external_product_id) ) retained_ids = [] for position, remote in enumerate(remote_images): source_updated_at = _source_updated_at(remote.updated) image = ProductImage.objects.filter( product=product, source=connection.source, external_id=remote.external_id, ).first() unchanged = bool( image and image.source_size == remote.size and image.source_updated_at == source_updated_at and image.file and image.file.storage.exists(image.file.name) and image.thumbnail and image.thumbnail.storage.exists(image.thumbnail.name) ) if unchanged: image.product = product image.filename = remote.filename image.title = remote.title image.position = position image.is_primary = position == 0 image.save( update_fields=[ "product", "filename", "title", "position", "is_primary", "updated_at", ] ) retained_ids.append(remote.external_id) continue content, _content_type = client.download_image(remote.download_href) thumbnail, detected_suffix = _validated_image_and_thumbnail(content) storage_name = _storage_filename( remote.external_id, remote.filename, content, detected_suffix, ) if image is None: image = ProductImage( source=connection.source, external_id=remote.external_id, ) old_names = { "file": image.file.name if image.file else "", "thumbnail": image.thumbnail.name if image.thumbnail else "", } image.product = product image.filename = remote.filename image.title = remote.title image.source_size = remote.size image.source_updated_at = source_updated_at image.position = position image.is_primary = position == 0 image.file.save(storage_name, ContentFile(content), save=False) image.thumbnail.save( f"{remote.external_id}-{hashlib.sha256(content).hexdigest()[:16]}.webp", ContentFile(thumbnail), save=False, ) with transaction.atomic(): image.save() transaction.on_commit( lambda current=image, previous=old_names: _delete_replaced_files( current, previous, ) ) retained_ids.append(remote.external_id) product.images.filter(source=connection.source).exclude( external_id__in=retained_ids ).delete() return len(retained_ids)