Corp/backend/commerce/media.py

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)