F(backend): add public media derivatives pipeline
This commit is contained in:
@@ -5,6 +5,7 @@ from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from apps.blog.models import Category, Tag, Comment
|
||||
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
|
||||
|
||||
|
||||
class CategorySchema(ModelSchema):
|
||||
@@ -23,6 +24,8 @@ class AuthorSchema(Schema):
|
||||
first_name: str
|
||||
last_name: str
|
||||
profile_picture: Optional[str] = None
|
||||
profile_picture_thumbnail_url: Optional[str] = None
|
||||
profile_picture_preview_url: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_picture(obj, context):
|
||||
@@ -31,6 +34,18 @@ class AuthorSchema(Schema):
|
||||
return request.build_absolute_uri(obj.profile_picture.url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_picture_thumbnail_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.profile_picture, THUMBNAIL_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_picture_preview_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.profile_picture, PREVIEW_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
class PostListSchema(Schema):
|
||||
id: int
|
||||
title: str
|
||||
@@ -38,6 +53,9 @@ class PostListSchema(Schema):
|
||||
excerpt: str
|
||||
author: AuthorSchema
|
||||
featured_image: Optional[str] = None
|
||||
absolute_featured_image_url: Optional[str] = None
|
||||
absolute_featured_image_thumbnail_url: Optional[str] = None
|
||||
absolute_featured_image_preview_url: Optional[str] = None
|
||||
status: str
|
||||
published_at: Optional[datetime] = None
|
||||
category: Optional[CategorySchema] = None
|
||||
@@ -46,6 +64,25 @@ class PostListSchema(Schema):
|
||||
created_at: datetime
|
||||
reading_time: int
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_url(obj, context):
|
||||
request = context["request"]
|
||||
if obj.featured_image and hasattr(obj.featured_image, "url"):
|
||||
return request.build_absolute_uri(obj.featured_image.url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_thumbnail_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.featured_image, THUMBNAIL_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_preview_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.featured_image, PREVIEW_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
class PostDetailSchema(PostListSchema):
|
||||
content: str
|
||||
content_html: str
|
||||
|
||||
@@ -5,6 +5,11 @@ from django.utils import timezone
|
||||
|
||||
import markdown
|
||||
|
||||
from core.media import (
|
||||
delete_image_derivatives_by_name,
|
||||
get_image_previous_name,
|
||||
safe_process_public_image,
|
||||
)
|
||||
from core.models import BaseModel
|
||||
|
||||
class Category(BaseModel):
|
||||
@@ -67,6 +72,9 @@ class Post(BaseModel):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
previous_image_name = get_image_previous_name(self, "featured_image")
|
||||
current_image_name = self.featured_image.name if self.featured_image else None
|
||||
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.title)
|
||||
|
||||
@@ -84,6 +92,17 @@ class Post(BaseModel):
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if previous_image_name != current_image_name and previous_image_name:
|
||||
delete_image_derivatives_by_name(
|
||||
self.featured_image.storage if self.featured_image else None,
|
||||
previous_image_name,
|
||||
"blog_featured",
|
||||
delete_original=True,
|
||||
)
|
||||
|
||||
if previous_image_name != current_image_name and self.featured_image:
|
||||
safe_process_public_image(self.featured_image, "blog_featured")
|
||||
|
||||
@property
|
||||
def content_html(self):
|
||||
"""Convert markdown content to HTML"""
|
||||
|
||||
@@ -10,6 +10,7 @@ from apps.blog.api.schemas import AuthorSchema
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.gallery.models import Gallery
|
||||
from apps.payments.models import Payment
|
||||
from core.media import BLUR_VARIANT, PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
|
||||
|
||||
|
||||
class EventGallerySchema(ModelSchema):
|
||||
@@ -18,6 +19,8 @@ class EventGallerySchema(ModelSchema):
|
||||
file_size_mb: float
|
||||
markdown_url: str
|
||||
absolute_image_url: Optional[str] = None
|
||||
absolute_image_preview_url: Optional[str] = None
|
||||
absolute_image_blur_url: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
model = Gallery
|
||||
@@ -31,12 +34,26 @@ class EventGallerySchema(ModelSchema):
|
||||
return request.build_absolute_uri(obj.image.url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_image_preview_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.image, PREVIEW_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_image_blur_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.image, BLUR_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
class EventSchema(ModelSchema):
|
||||
"""Schema providing full event details for API responses."""
|
||||
gallery_images: List[EventGallerySchema]
|
||||
description_html: str
|
||||
registration_count: int
|
||||
absolute_featured_image_url: Optional[str] = None
|
||||
absolute_featured_image_thumbnail_url: Optional[str] = None
|
||||
absolute_featured_image_preview_url: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
model = Event
|
||||
@@ -54,6 +71,18 @@ class EventSchema(ModelSchema):
|
||||
return request.build_absolute_uri(obj.featured_image.url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_thumbnail_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.featured_image, THUMBNAIL_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_preview_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.featured_image, PREVIEW_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_registration_count(obj):
|
||||
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||
@@ -70,6 +99,8 @@ class EventListSchema(Schema):
|
||||
slug: str
|
||||
featured_image: Optional[str] = None
|
||||
absolute_featured_image_url: Optional[str] = None
|
||||
absolute_featured_image_thumbnail_url: Optional[str] = None
|
||||
absolute_featured_image_preview_url: Optional[str] = None
|
||||
event_type: str
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
@@ -88,6 +119,18 @@ class EventListSchema(Schema):
|
||||
return request.build_absolute_uri(obj.featured_image.url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_thumbnail_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.featured_image, THUMBNAIL_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_preview_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.featured_image, PREVIEW_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_registration_count(obj):
|
||||
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||
|
||||
@@ -10,6 +10,11 @@ import uuid
|
||||
import markdown
|
||||
from location_field.models.plain import PlainLocationField as LocationField
|
||||
|
||||
from core.media import (
|
||||
delete_image_derivatives_by_name,
|
||||
get_image_previous_name,
|
||||
safe_process_public_image,
|
||||
)
|
||||
from core.models import BaseModel
|
||||
|
||||
|
||||
@@ -68,10 +73,24 @@ class Event(BaseModel):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
previous_image_name = get_image_previous_name(self, "featured_image")
|
||||
current_image_name = self.featured_image.name if self.featured_image else None
|
||||
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.title)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if previous_image_name != current_image_name and previous_image_name:
|
||||
delete_image_derivatives_by_name(
|
||||
self.featured_image.storage if self.featured_image else None,
|
||||
previous_image_name,
|
||||
"event_featured",
|
||||
delete_original=True,
|
||||
)
|
||||
|
||||
if previous_image_name != current_image_name and self.featured_image:
|
||||
safe_process_public_image(self.featured_image, "event_featured")
|
||||
|
||||
@property
|
||||
def description_html(self):
|
||||
"""Convert markdown description to HTML"""
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional
|
||||
|
||||
from apps.blog.api.schemas import AuthorSchema
|
||||
from apps.gallery.models import Gallery
|
||||
from core.media import BLUR_VARIANT, PREVIEW_VARIANT, derivative_url
|
||||
|
||||
|
||||
class GallerySchema(ModelSchema):
|
||||
@@ -12,12 +13,34 @@ class GallerySchema(ModelSchema):
|
||||
uploaded_by: AuthorSchema
|
||||
file_size_mb: float
|
||||
markdown_url: str
|
||||
absolute_image_url: Optional[str] = None
|
||||
absolute_image_preview_url: Optional[str] = None
|
||||
absolute_image_blur_url: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
model = Gallery
|
||||
model_fields = ['id', 'title', 'description', 'image', 'alt_text',
|
||||
'width', 'height', 'is_public', 'created_at']
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_image_url(obj, context):
|
||||
request = context["request"]
|
||||
if obj.image and hasattr(obj.image, "url"):
|
||||
return request.build_absolute_uri(obj.image.url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_image_preview_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.image, PREVIEW_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_image_blur_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.image, BLUR_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
|
||||
class GalleryCreateSchema(Schema):
|
||||
"""Payload for creating a gallery entry."""
|
||||
|
||||
@@ -63,6 +63,7 @@ def upload_image(request, file: UploadedFile = File(...), data: GalleryCreateSch
|
||||
alt_text=data.alt_text if data else "",
|
||||
is_public=data.is_public if data else True
|
||||
)
|
||||
gallery_item._defer_image_processing = True
|
||||
|
||||
# Save image
|
||||
filename = f"gallery/{uuid.uuid4().hex}.{file.name.split('.')[-1]}"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from core.models import BaseModel
|
||||
from core.media import (
|
||||
delete_image_derivatives_by_name,
|
||||
get_image_previous_name,
|
||||
safe_process_public_image,
|
||||
)
|
||||
|
||||
|
||||
MAX_IMAGE_FILE_SIZE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
class Gallery(BaseModel):
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
@@ -27,47 +28,39 @@ class Gallery(BaseModel):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
previous_image_name = get_image_previous_name(self, "image")
|
||||
current_image_name = self.image.name if self.image else None
|
||||
image_changed = previous_image_name != current_image_name
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self.image:
|
||||
# Get file size
|
||||
self.file_size = self.image.size
|
||||
|
||||
# Get image dimensions
|
||||
with Image.open(self.image.path) as img:
|
||||
self.width, self.height = img.size
|
||||
|
||||
# Compress image if it's too large
|
||||
self.compress_image()
|
||||
|
||||
# Update fields without triggering save again
|
||||
Gallery.objects.filter(pk=self.pk).update(
|
||||
file_size=self.file_size,
|
||||
width=self.width,
|
||||
height=self.height
|
||||
|
||||
if image_changed and previous_image_name:
|
||||
delete_image_derivatives_by_name(
|
||||
self.image.storage if self.image else None,
|
||||
previous_image_name,
|
||||
"gallery",
|
||||
delete_original=True,
|
||||
)
|
||||
|
||||
def compress_image(self):
|
||||
"""Compress image if it's larger than 2MB or dimensions are too large"""
|
||||
if not self.image:
|
||||
if image_changed:
|
||||
Gallery.objects.filter(pk=self.pk).update(file_size=None, width=None, height=None)
|
||||
return
|
||||
|
||||
with Image.open(self.image.path) as img:
|
||||
# Convert to RGB if necessary
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Resize if too large
|
||||
max_size = (1920, 1080)
|
||||
if img.size[0] > max_size[0] or img.size[1] > max_size[1]:
|
||||
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
|
||||
# Compress if file size is too large
|
||||
quality = 85
|
||||
if self.file_size and self.file_size > MAX_IMAGE_FILE_SIZE_BYTES:
|
||||
quality = 70
|
||||
|
||||
img.save(self.image.path, "JPEG", quality=quality, optimize=True)
|
||||
|
||||
if getattr(self, "_defer_image_processing", False):
|
||||
return
|
||||
|
||||
if image_changed:
|
||||
result = safe_process_public_image(self.image, "gallery")
|
||||
if result:
|
||||
Gallery.objects.filter(pk=self.pk).update(
|
||||
file_size=result.file_size,
|
||||
width=result.width,
|
||||
height=result.height,
|
||||
)
|
||||
self.file_size = result.file_size
|
||||
self.width = result.width
|
||||
self.height = result.height
|
||||
|
||||
@property
|
||||
def file_size_mb(self):
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
from celery import shared_task
|
||||
from PIL import Image
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from core.media import safe_process_public_image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@shared_task
|
||||
def process_uploaded_image(gallery_id):
|
||||
"""Process uploaded image: compress, resize, extract metadata"""
|
||||
"""Process gallery image derivatives and refresh metadata."""
|
||||
try:
|
||||
from .models import Gallery
|
||||
gallery_item = Gallery.objects.get(id=gallery_id)
|
||||
|
||||
if gallery_item.image:
|
||||
# This will trigger the compression and metadata extraction
|
||||
gallery_item.compress_image()
|
||||
|
||||
result = safe_process_public_image(gallery_item.image, "gallery", force=True)
|
||||
if result:
|
||||
Gallery.objects.filter(pk=gallery_item.pk).update(
|
||||
file_size=result.file_size,
|
||||
width=result.width,
|
||||
height=result.height,
|
||||
)
|
||||
logger.info(f"Processed image: {gallery_item.title}")
|
||||
return f"Processed image: {gallery_item.title}"
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from ninja import Schema, ModelSchema
|
||||
from typing import Optional
|
||||
|
||||
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
@@ -23,6 +24,8 @@ class UserLoginSchema(Schema):
|
||||
|
||||
class UserProfileSchema(ModelSchema):
|
||||
profile_picture: Optional[str] = None
|
||||
profile_picture_thumbnail_url: Optional[str] = None
|
||||
profile_picture_preview_url: Optional[str] = None
|
||||
student_id: Optional[str] = None
|
||||
major: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
@@ -68,6 +71,18 @@ class UserProfileSchema(ModelSchema):
|
||||
return request.build_absolute_uri(obj.profile_picture.url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_picture_thumbnail_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.profile_picture, THUMBNAIL_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_picture_preview_url(obj, context):
|
||||
request = context["request"]
|
||||
url = derivative_url(obj.profile_picture, PREVIEW_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
|
||||
class UserListSchema(ModelSchema):
|
||||
major: Optional[str] = None
|
||||
|
||||
@@ -5,13 +5,13 @@ from django.contrib.auth import authenticate
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import uuid
|
||||
import jwt
|
||||
from ninja import Query, Router
|
||||
|
||||
from core.media import delete_image_derivatives
|
||||
from apps.users.models import User, Major, University
|
||||
from apps.users.tasks import send_verification_email, send_password_reset_email
|
||||
from apps.users.api.schemas import (
|
||||
@@ -254,10 +254,6 @@ def upload_profile_picture(request):
|
||||
|
||||
user = request.auth
|
||||
|
||||
# Delete old profile picture if exists
|
||||
if user.profile_picture:
|
||||
default_storage.delete(user.profile_picture.name)
|
||||
|
||||
# Save new profile picture
|
||||
filename = f"profile_pictures/{user.id}_{uuid.uuid4().hex}.{file.name.split('.')[-1]}"
|
||||
user.profile_picture.save(filename, ContentFile(file.read()))
|
||||
@@ -270,7 +266,7 @@ def delete_profile_picture(request):
|
||||
user = request.auth
|
||||
|
||||
if user.profile_picture:
|
||||
default_storage.delete(user.profile_picture.name)
|
||||
delete_image_derivatives(user.profile_picture, "profile_picture", delete_original=True)
|
||||
user.profile_picture = None
|
||||
user.save(update_fields=['profile_picture'])
|
||||
|
||||
@@ -400,4 +396,3 @@ def check_username_availability(request, username: str):
|
||||
"""Check if a username is available for registration"""
|
||||
exists = User.objects.filter(username=username).exists()
|
||||
return {"exists": exists}
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ from django.db import models
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from core.media import (
|
||||
delete_image_derivatives_by_name,
|
||||
get_image_previous_name,
|
||||
safe_process_public_image,
|
||||
)
|
||||
from core.models import BaseModel
|
||||
|
||||
|
||||
@@ -95,6 +100,8 @@ class User(AbstractUser, BaseModel):
|
||||
self.save(update_fields=['password_reset_token', 'password_reset_token_expires_at'])
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
previous_image_name = get_image_previous_name(self, "profile_picture")
|
||||
current_image_name = self.profile_picture.name if self.profile_picture else None
|
||||
send_verified_success = False
|
||||
|
||||
if self.pk is not None:
|
||||
@@ -104,6 +111,17 @@ class User(AbstractUser, BaseModel):
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if previous_image_name != current_image_name and previous_image_name:
|
||||
delete_image_derivatives_by_name(
|
||||
self.profile_picture.storage if self.profile_picture else None,
|
||||
previous_image_name,
|
||||
"profile_picture",
|
||||
delete_original=True,
|
||||
)
|
||||
|
||||
if previous_image_name != current_image_name and self.profile_picture:
|
||||
safe_process_public_image(self.profile_picture, "profile_picture")
|
||||
|
||||
if send_verified_success:
|
||||
try:
|
||||
from apps.users.tasks import send_email_verified_success
|
||||
|
||||
Reference in New Issue
Block a user