F(backend): add public media derivatives pipeline
This commit is contained in:
1
core/management/__init__.py
Normal file
1
core/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
core/management/commands/__init__.py
Normal file
1
core/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
71
core/management/commands/backfill_media_derivatives.py
Normal file
71
core/management/commands/backfill_media_derivatives.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.blog.models import Post
|
||||
from apps.events.models import Event
|
||||
from apps.gallery.models import Gallery
|
||||
from apps.users.models import User
|
||||
from core.media import safe_process_public_image
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackfillTarget:
|
||||
label: str
|
||||
queryset: object
|
||||
field_name: str
|
||||
family: str
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate missing public image derivatives for existing media."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Regenerate derivatives even when sidecar files already exist.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
force = options["force"]
|
||||
processed = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
targets = (
|
||||
BackfillTarget("events", Event.objects.exclude(featured_image="").exclude(featured_image__isnull=True), "featured_image", "event_featured"),
|
||||
BackfillTarget("gallery", Gallery.objects.exclude(image="").exclude(image__isnull=True), "image", "gallery"),
|
||||
BackfillTarget("blog", Post.objects.exclude(featured_image="").exclude(featured_image__isnull=True), "featured_image", "blog_featured"),
|
||||
BackfillTarget("users", User.objects.exclude(profile_picture="").exclude(profile_picture__isnull=True), "profile_picture", "profile_picture"),
|
||||
)
|
||||
|
||||
for target in targets:
|
||||
self.stdout.write(self.style.NOTICE(f"Backfilling {target.label}..."))
|
||||
for instance in target.queryset.iterator():
|
||||
file_field = getattr(instance, target.field_name)
|
||||
result = safe_process_public_image(file_field, target.family, force=force)
|
||||
if result is None:
|
||||
failed += 1
|
||||
self.stdout.write(self.style.WARNING(f"failed: {target.label}#{instance.pk}"))
|
||||
continue
|
||||
|
||||
if hasattr(instance, "file_size") and hasattr(instance, "width") and hasattr(instance, "height"):
|
||||
type(instance).objects.filter(pk=instance.pk).update(
|
||||
file_size=result.file_size,
|
||||
width=result.width,
|
||||
height=result.height,
|
||||
)
|
||||
|
||||
if result.processed:
|
||||
processed += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Media derivative backfill finished. processed={processed} skipped={skipped} failed={failed}"
|
||||
)
|
||||
)
|
||||
239
core/media.py
Normal file
239
core/media.py
Normal file
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from django.core.files.storage import Storage, default_storage
|
||||
|
||||
from PIL import Image, ImageFilter, ImageOps, UnidentifiedImageError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BLUR_VARIANT = "blur"
|
||||
PREVIEW_VARIANT = "preview"
|
||||
THUMBNAIL_VARIANT = "thumbnail"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageVariantSpec:
|
||||
key: str
|
||||
max_size: tuple[int, int]
|
||||
quality: int = 80
|
||||
blur_radius: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageFamilySpec:
|
||||
key: str
|
||||
original_max_size: tuple[int, int]
|
||||
original_quality: int
|
||||
variants: tuple[ImageVariantSpec, ...]
|
||||
|
||||
|
||||
IMAGE_FAMILIES: dict[str, ImageFamilySpec] = {
|
||||
"event_featured": ImageFamilySpec(
|
||||
key="event_featured",
|
||||
original_max_size=(2560, 2560),
|
||||
original_quality=84,
|
||||
variants=(
|
||||
ImageVariantSpec(THUMBNAIL_VARIANT, (640, 640), quality=72),
|
||||
ImageVariantSpec(PREVIEW_VARIANT, (1600, 1600), quality=82),
|
||||
ImageVariantSpec(BLUR_VARIANT, (48, 48), quality=36, blur_radius=10.0),
|
||||
),
|
||||
),
|
||||
"gallery": ImageFamilySpec(
|
||||
key="gallery",
|
||||
original_max_size=(2560, 2560),
|
||||
original_quality=84,
|
||||
variants=(
|
||||
ImageVariantSpec(THUMBNAIL_VARIANT, (480, 480), quality=68),
|
||||
ImageVariantSpec(PREVIEW_VARIANT, (1280, 1280), quality=78),
|
||||
ImageVariantSpec(BLUR_VARIANT, (48, 48), quality=34, blur_radius=12.0),
|
||||
),
|
||||
),
|
||||
"blog_featured": ImageFamilySpec(
|
||||
key="blog_featured",
|
||||
original_max_size=(2560, 2560),
|
||||
original_quality=84,
|
||||
variants=(
|
||||
ImageVariantSpec(THUMBNAIL_VARIANT, (640, 640), quality=72),
|
||||
ImageVariantSpec(PREVIEW_VARIANT, (1600, 1600), quality=82),
|
||||
ImageVariantSpec(BLUR_VARIANT, (48, 48), quality=36, blur_radius=10.0),
|
||||
),
|
||||
),
|
||||
"profile_picture": ImageFamilySpec(
|
||||
key="profile_picture",
|
||||
original_max_size=(1200, 1200),
|
||||
original_quality=82,
|
||||
variants=(
|
||||
ImageVariantSpec(THUMBNAIL_VARIANT, (160, 160), quality=72),
|
||||
ImageVariantSpec(PREVIEW_VARIANT, (640, 640), quality=82),
|
||||
ImageVariantSpec(BLUR_VARIANT, (40, 40), quality=32, blur_radius=10.0),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessedImageResult:
|
||||
width: int
|
||||
height: int
|
||||
file_size: int
|
||||
processed: bool
|
||||
|
||||
|
||||
def get_image_family_spec(family: str) -> ImageFamilySpec:
|
||||
try:
|
||||
return IMAGE_FAMILIES[family]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown image family: {family}") from exc
|
||||
|
||||
|
||||
def get_image_previous_name(instance, field_name: str) -> str | None:
|
||||
if not getattr(instance, "pk", None):
|
||||
return None
|
||||
|
||||
manager = getattr(instance.__class__, "all_objects", instance.__class__._default_manager)
|
||||
return manager.filter(pk=instance.pk).values_list(field_name, flat=True).first()
|
||||
|
||||
|
||||
def derivative_name(original_name: str, variant_key: str) -> str:
|
||||
original_path = Path(original_name)
|
||||
return str(original_path.with_name(f"{original_path.stem}.{variant_key}.webp")).replace("\\", "/")
|
||||
|
||||
|
||||
def iter_derivative_names(original_name: str, family: str) -> Iterable[str]:
|
||||
for variant in get_image_family_spec(family).variants:
|
||||
yield derivative_name(original_name, variant.key)
|
||||
|
||||
|
||||
def derivative_url(file_field, variant_key: str) -> str | None:
|
||||
if not file_field or not getattr(file_field, "name", None):
|
||||
return None
|
||||
|
||||
name = derivative_name(file_field.name, variant_key)
|
||||
if not file_field.storage.exists(name):
|
||||
return None
|
||||
|
||||
return file_field.storage.url(name)
|
||||
|
||||
|
||||
def delete_image_derivatives_by_name(
|
||||
storage: Storage | None,
|
||||
original_name: str | None,
|
||||
family: str,
|
||||
*,
|
||||
delete_original: bool = False,
|
||||
) -> None:
|
||||
if not original_name:
|
||||
return
|
||||
|
||||
storage = storage or default_storage
|
||||
for name in iter_derivative_names(original_name, family):
|
||||
if storage.exists(name):
|
||||
storage.delete(name)
|
||||
|
||||
if delete_original and storage.exists(original_name):
|
||||
storage.delete(original_name)
|
||||
|
||||
|
||||
def delete_image_derivatives(file_field, family: str, *, delete_original: bool = False) -> None:
|
||||
if not file_field or not getattr(file_field, "name", None):
|
||||
return
|
||||
delete_image_derivatives_by_name(
|
||||
getattr(file_field, "storage", default_storage),
|
||||
file_field.name,
|
||||
family,
|
||||
delete_original=delete_original,
|
||||
)
|
||||
|
||||
|
||||
def process_public_image(file_field, family: str, *, force: bool = False) -> ProcessedImageResult:
|
||||
if not file_field or not getattr(file_field, "path", None):
|
||||
raise ValueError("Image field must point to a local file path.")
|
||||
|
||||
spec = get_image_family_spec(family)
|
||||
missing_derivatives = [
|
||||
name
|
||||
for name in iter_derivative_names(file_field.name, family)
|
||||
if not file_field.storage.exists(name)
|
||||
]
|
||||
should_process = force or bool(missing_derivatives)
|
||||
|
||||
if should_process:
|
||||
with Image.open(file_field.path) as opened:
|
||||
source = ImageOps.exif_transpose(opened)
|
||||
optimized = _resize_for_original(source, spec.original_max_size)
|
||||
_save_original(optimized, file_field.path, quality=spec.original_quality)
|
||||
|
||||
for variant in spec.variants:
|
||||
derivative = _build_variant_image(optimized, variant)
|
||||
target_name = derivative_name(file_field.name, variant.key)
|
||||
target_path = file_field.storage.path(target_name)
|
||||
Path(target_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
derivative.save(target_path, format="WEBP", quality=variant.quality, method=6)
|
||||
|
||||
with Image.open(file_field.path) as current:
|
||||
width, height = current.size
|
||||
|
||||
file_size = file_field.storage.size(file_field.name)
|
||||
return ProcessedImageResult(
|
||||
width=width,
|
||||
height=height,
|
||||
file_size=file_size,
|
||||
processed=should_process,
|
||||
)
|
||||
|
||||
|
||||
def _resize_for_original(image: Image.Image, max_size: tuple[int, int]) -> Image.Image:
|
||||
prepared = image.copy()
|
||||
prepared.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
return prepared
|
||||
|
||||
|
||||
def _save_original(image: Image.Image, path: str, *, quality: int) -> None:
|
||||
suffix = Path(path).suffix.lower()
|
||||
has_alpha = "A" in image.getbands()
|
||||
|
||||
if suffix in {".jpg", ".jpeg"}:
|
||||
image.convert("RGB").save(
|
||||
path,
|
||||
format="JPEG",
|
||||
quality=quality,
|
||||
optimize=True,
|
||||
progressive=True,
|
||||
)
|
||||
return
|
||||
|
||||
if suffix == ".png":
|
||||
target = image if has_alpha else image.convert("RGB")
|
||||
target.save(path, format="PNG", optimize=True, compress_level=9)
|
||||
return
|
||||
|
||||
if suffix == ".webp":
|
||||
image.save(path, format="WEBP", quality=quality, method=6)
|
||||
return
|
||||
|
||||
image.save(path)
|
||||
|
||||
|
||||
def _build_variant_image(image: Image.Image, variant: ImageVariantSpec) -> Image.Image:
|
||||
target = image.copy()
|
||||
if variant.blur_radius > 0:
|
||||
target.thumbnail(variant.max_size, Image.Resampling.LANCZOS)
|
||||
target = target.filter(ImageFilter.GaussianBlur(radius=variant.blur_radius))
|
||||
return target
|
||||
|
||||
target.thumbnail(variant.max_size, Image.Resampling.LANCZOS)
|
||||
return target
|
||||
|
||||
|
||||
def safe_process_public_image(file_field, family: str, *, force: bool = False) -> ProcessedImageResult | None:
|
||||
try:
|
||||
return process_public_image(file_field, family, force=force)
|
||||
except (FileNotFoundError, OSError, UnidentifiedImageError, ValueError) as exc:
|
||||
logger.warning("Failed to process image '%s': %s", getattr(file_field, "name", None), exc)
|
||||
return None
|
||||
1
core/tests/__init__.py
Normal file
1
core/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
core/tests/unit/__init__.py
Normal file
1
core/tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
174
core/tests/unit/test_media.py
Normal file
174
core/tests/unit/test_media.py
Normal file
@@ -0,0 +1,174 @@
|
||||
import io
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
from PIL import Image
|
||||
from django.core.management import call_command
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import RequestFactory
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.blog.api.schemas import AuthorSchema, PostListSchema
|
||||
from apps.blog.models import Post
|
||||
from apps.events.api.schemas import EventGallerySchema, EventListSchema
|
||||
from apps.events.models import Event
|
||||
from apps.gallery.api.schemas import GallerySchema
|
||||
from apps.gallery.models import Gallery
|
||||
from apps.users.api.schemas import UserProfileSchema
|
||||
from apps.users.models import User
|
||||
from core.media import (
|
||||
BLUR_VARIANT,
|
||||
PREVIEW_VARIANT,
|
||||
THUMBNAIL_VARIANT,
|
||||
delete_image_derivatives,
|
||||
derivative_name,
|
||||
process_public_image,
|
||||
)
|
||||
|
||||
|
||||
class MediaProcessingTests(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.media_root = tempfile.mkdtemp()
|
||||
self.addCleanup(lambda: shutil.rmtree(self.media_root, ignore_errors=True))
|
||||
self.override = override_settings(MEDIA_ROOT=self.media_root, MEDIA_URL="/media/")
|
||||
self.override.enable()
|
||||
self.addCleanup(self.override.disable)
|
||||
self.user = User.objects.create_user(
|
||||
username="media-user",
|
||||
email="media@example.com",
|
||||
password="TestPass123!",
|
||||
)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def _upload(self, name: str, *, mode: str = "RGB", fmt: str = "JPEG") -> SimpleUploadedFile:
|
||||
buffer = io.BytesIO()
|
||||
Image.new(mode, (2400, 1800), color=(20, 80, 200)).save(buffer, format=fmt)
|
||||
buffer.seek(0)
|
||||
return SimpleUploadedFile(name, buffer.read(), content_type=f"image/{fmt.lower()}")
|
||||
|
||||
def test_process_public_image_generates_derivatives_for_jpeg(self):
|
||||
gallery = Gallery.objects.create(
|
||||
title="JPEG image",
|
||||
uploaded_by=self.user,
|
||||
image=self._upload("photo.jpg", fmt="JPEG"),
|
||||
)
|
||||
|
||||
result = process_public_image(gallery.image, "gallery", force=True)
|
||||
|
||||
self.assertTrue(result.processed)
|
||||
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT)))
|
||||
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT)))
|
||||
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT)))
|
||||
|
||||
def test_process_public_image_generates_derivatives_for_png(self):
|
||||
gallery = Gallery.objects.create(
|
||||
title="PNG image",
|
||||
uploaded_by=self.user,
|
||||
image=self._upload("photo.png", mode="RGBA", fmt="PNG"),
|
||||
)
|
||||
|
||||
result = process_public_image(gallery.image, "gallery", force=True)
|
||||
|
||||
self.assertEqual(result.width, 2400)
|
||||
self.assertEqual(result.height, 1800)
|
||||
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT)))
|
||||
|
||||
def test_delete_image_derivatives_removes_sidecars(self):
|
||||
gallery = Gallery.objects.create(
|
||||
title="Delete derivatives",
|
||||
uploaded_by=self.user,
|
||||
image=self._upload("cleanup.jpg", fmt="JPEG"),
|
||||
)
|
||||
process_public_image(gallery.image, "gallery", force=True)
|
||||
|
||||
delete_image_derivatives(gallery.image, "gallery")
|
||||
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT)))
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT)))
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT)))
|
||||
|
||||
def test_replacing_image_removes_previous_sidecars(self):
|
||||
gallery = Gallery.objects.create(
|
||||
title="Replace derivatives",
|
||||
uploaded_by=self.user,
|
||||
image=self._upload("replace-first.jpg", fmt="JPEG"),
|
||||
)
|
||||
first_name = gallery.image.name
|
||||
|
||||
gallery.image = self._upload("replace-second.jpg", fmt="JPEG")
|
||||
gallery.save()
|
||||
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, THUMBNAIL_VARIANT)))
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, PREVIEW_VARIANT)))
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, BLUR_VARIANT)))
|
||||
|
||||
def test_backfill_command_is_idempotent(self):
|
||||
gallery = Gallery(title="Backfill image", uploaded_by=self.user)
|
||||
gallery._defer_image_processing = True
|
||||
gallery.image = self._upload("backfill.jpg", fmt="JPEG")
|
||||
gallery.save()
|
||||
|
||||
first = io.StringIO()
|
||||
second = io.StringIO()
|
||||
call_command("backfill_media_derivatives", stdout=first)
|
||||
call_command("backfill_media_derivatives", stdout=second)
|
||||
|
||||
self.assertIn("processed=1", first.getvalue())
|
||||
self.assertIn("skipped=1", second.getvalue())
|
||||
|
||||
def test_schema_resolvers_expose_derivative_urls(self):
|
||||
self.user.profile_picture.save("profile.jpg", self._upload("profile.jpg", fmt="JPEG"))
|
||||
|
||||
gallery = Gallery.objects.create(
|
||||
title="Schema image",
|
||||
uploaded_by=self.user,
|
||||
image=self._upload("schema-gallery.jpg", fmt="JPEG"),
|
||||
)
|
||||
event = Event.objects.create(
|
||||
title="Schema event",
|
||||
description="Event description",
|
||||
start_time=timezone.now(),
|
||||
end_time=timezone.now() + timedelta(hours=2),
|
||||
featured_image=self._upload("schema-event.jpg", fmt="JPEG"),
|
||||
)
|
||||
post = Post.objects.create(
|
||||
title="Schema post",
|
||||
content="Post body",
|
||||
author=self.user,
|
||||
featured_image=self._upload("schema-post.jpg", fmt="JPEG"),
|
||||
status=Post.StatusChoices.PUBLISHED,
|
||||
)
|
||||
|
||||
context = {"request": self.factory.get("/")}
|
||||
|
||||
self.assertIn(
|
||||
derivative_name(event.featured_image.name, THUMBNAIL_VARIANT),
|
||||
EventListSchema.resolve_absolute_featured_image_thumbnail_url(event, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(event.featured_image.name, PREVIEW_VARIANT),
|
||||
EventListSchema.resolve_absolute_featured_image_preview_url(event, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(gallery.image.name, PREVIEW_VARIANT),
|
||||
EventGallerySchema.resolve_absolute_image_preview_url(gallery, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(gallery.image.name, BLUR_VARIANT),
|
||||
GallerySchema.resolve_absolute_image_blur_url(gallery, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(post.featured_image.name, THUMBNAIL_VARIANT),
|
||||
PostListSchema.resolve_absolute_featured_image_thumbnail_url(post, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(self.user.profile_picture.name, THUMBNAIL_VARIANT),
|
||||
AuthorSchema.resolve_profile_picture_thumbnail_url(self.user, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(self.user.profile_picture.name, PREVIEW_VARIANT),
|
||||
UserProfileSchema.resolve_profile_picture_preview_url(self.user, context),
|
||||
)
|
||||
Reference in New Issue
Block a user