F(backend): add public media derivatives pipeline
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-20 14:26:51 +03:30
parent 88b793ed9f
commit b4903f7cb1
18 changed files with 710 additions and 53 deletions

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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
View 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
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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),
)