190 lines
6.4 KiB
Python
190 lines
6.4 KiB
Python
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)
|