mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:31:13 -05:00
Add email templates for user notifications and account management
- Created a base email template (base.html) for consistent styling across all emails. - Added moderation approval email template (moderation_approved.html) to notify users of approved submissions. - Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions. - Created password reset email template (password_reset.html) for users requesting to reset their passwords. - Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
This commit is contained in:
Binary file not shown.
BIN
django/apps/media/__pycache__/services.cpython-313.pyc
Normal file
BIN
django/apps/media/__pycache__/services.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/apps/media/__pycache__/validators.cpython-313.pyc
Normal file
BIN
django/apps/media/__pycache__/validators.cpython-313.pyc
Normal file
Binary file not shown.
@@ -2,16 +2,20 @@
|
||||
Django Admin configuration for media models.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.contrib.contenttypes.admin import GenericTabularInline
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.db.models import Count, Q
|
||||
from .models import Photo
|
||||
|
||||
|
||||
@admin.register(Photo)
|
||||
class PhotoAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for Photo model."""
|
||||
"""Admin interface for Photo model with enhanced features."""
|
||||
|
||||
list_display = [
|
||||
'title', 'cloudflare_image_id', 'photo_type', 'moderation_status',
|
||||
'is_approved', 'uploaded_by', 'created'
|
||||
'thumbnail_preview', 'title', 'photo_type', 'moderation_status',
|
||||
'entity_info', 'uploaded_by', 'dimensions', 'file_size_display', 'created'
|
||||
]
|
||||
list_filter = [
|
||||
'moderation_status', 'is_approved', 'photo_type',
|
||||
@@ -62,7 +66,79 @@ class PhotoAdmin(admin.ModelAdmin):
|
||||
}),
|
||||
)
|
||||
|
||||
actions = ['approve_photos', 'reject_photos', 'flag_photos']
|
||||
date_hierarchy = 'created'
|
||||
actions = ['approve_photos', 'reject_photos', 'flag_photos', 'make_featured', 'remove_featured']
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset with select_related."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related(
|
||||
'uploaded_by', 'moderated_by', 'content_type'
|
||||
)
|
||||
|
||||
def thumbnail_preview(self, obj):
|
||||
"""Display thumbnail preview in list view."""
|
||||
if obj.cloudflare_url:
|
||||
# Use thumbnail variant for preview
|
||||
from apps.media.services import CloudFlareService
|
||||
cf = CloudFlareService()
|
||||
thumbnail_url = cf.get_image_url(obj.cloudflare_image_id, 'thumbnail')
|
||||
|
||||
return format_html(
|
||||
'<img src="{}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 4px;" />',
|
||||
thumbnail_url
|
||||
)
|
||||
return "-"
|
||||
thumbnail_preview.short_description = "Preview"
|
||||
|
||||
def entity_info(self, obj):
|
||||
"""Display entity information."""
|
||||
if obj.content_type and obj.object_id:
|
||||
entity = obj.content_object
|
||||
if entity:
|
||||
entity_type = obj.content_type.model
|
||||
entity_name = getattr(entity, 'name', str(entity))
|
||||
return format_html(
|
||||
'<strong>{}</strong><br/><small>{}</small>',
|
||||
entity_name,
|
||||
entity_type.upper()
|
||||
)
|
||||
return format_html('<em style="color: #999;">Not attached</em>')
|
||||
entity_info.short_description = "Entity"
|
||||
|
||||
def dimensions(self, obj):
|
||||
"""Display image dimensions."""
|
||||
if obj.width and obj.height:
|
||||
return f"{obj.width}×{obj.height}"
|
||||
return "-"
|
||||
dimensions.short_description = "Size"
|
||||
|
||||
def file_size_display(self, obj):
|
||||
"""Display file size in human-readable format."""
|
||||
if obj.file_size:
|
||||
size_kb = obj.file_size / 1024
|
||||
if size_kb > 1024:
|
||||
return f"{size_kb / 1024:.1f} MB"
|
||||
return f"{size_kb:.1f} KB"
|
||||
return "-"
|
||||
file_size_display.short_description = "File Size"
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
"""Add statistics to changelist."""
|
||||
extra_context = extra_context or {}
|
||||
|
||||
# Get photo statistics
|
||||
stats = Photo.objects.aggregate(
|
||||
total=Count('id'),
|
||||
pending=Count('id', filter=Q(moderation_status='pending')),
|
||||
approved=Count('id', filter=Q(moderation_status='approved')),
|
||||
rejected=Count('id', filter=Q(moderation_status='rejected')),
|
||||
flagged=Count('id', filter=Q(moderation_status='flagged')),
|
||||
)
|
||||
|
||||
extra_context['photo_stats'] = stats
|
||||
|
||||
return super().changelist_view(request, extra_context)
|
||||
|
||||
def approve_photos(self, request, queryset):
|
||||
"""Bulk approve selected photos."""
|
||||
@@ -90,3 +166,41 @@ class PhotoAdmin(admin.ModelAdmin):
|
||||
count += 1
|
||||
self.message_user(request, f"{count} photo(s) flagged for review.")
|
||||
flag_photos.short_description = "Flag selected photos"
|
||||
|
||||
def make_featured(self, request, queryset):
|
||||
"""Mark selected photos as featured."""
|
||||
count = queryset.update(is_featured=True)
|
||||
self.message_user(request, f"{count} photo(s) marked as featured.")
|
||||
make_featured.short_description = "Mark as featured"
|
||||
|
||||
def remove_featured(self, request, queryset):
|
||||
"""Remove featured status from selected photos."""
|
||||
count = queryset.update(is_featured=False)
|
||||
self.message_user(request, f"{count} photo(s) removed from featured.")
|
||||
remove_featured.short_description = "Remove featured status"
|
||||
|
||||
|
||||
# Inline admin for use in entity admin pages
|
||||
class PhotoInline(GenericTabularInline):
|
||||
"""Inline admin for photos in entity pages."""
|
||||
model = Photo
|
||||
ct_field = 'content_type'
|
||||
ct_fk_field = 'object_id'
|
||||
extra = 0
|
||||
fields = ['thumbnail_preview', 'title', 'photo_type', 'moderation_status', 'display_order']
|
||||
readonly_fields = ['thumbnail_preview']
|
||||
can_delete = True
|
||||
|
||||
def thumbnail_preview(self, obj):
|
||||
"""Display thumbnail preview in inline."""
|
||||
if obj.cloudflare_url:
|
||||
from apps.media.services import CloudFlareService
|
||||
cf = CloudFlareService()
|
||||
thumbnail_url = cf.get_image_url(obj.cloudflare_image_id, 'thumbnail')
|
||||
|
||||
return format_html(
|
||||
'<img src="{}" style="width: 40px; height: 40px; object-fit: cover; border-radius: 4px;" />',
|
||||
thumbnail_url
|
||||
)
|
||||
return "-"
|
||||
thumbnail_preview.short_description = "Preview"
|
||||
|
||||
492
django/apps/media/services.py
Normal file
492
django/apps/media/services.py
Normal file
@@ -0,0 +1,492 @@
|
||||
"""
|
||||
Media services for photo upload, management, and CloudFlare Images integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import Optional, Dict, Any, List
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile
|
||||
from django.db import transaction
|
||||
from django.db.models import Model
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from apps.media.models import Photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudFlareError(Exception):
|
||||
"""Base exception for CloudFlare API errors."""
|
||||
pass
|
||||
|
||||
|
||||
class CloudFlareService:
|
||||
"""
|
||||
Service for interacting with CloudFlare Images API.
|
||||
|
||||
Provides image upload, deletion, and URL generation with automatic
|
||||
fallback to mock mode when CloudFlare credentials are not configured.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.account_id = settings.CLOUDFLARE_ACCOUNT_ID
|
||||
self.api_token = settings.CLOUDFLARE_IMAGE_TOKEN
|
||||
self.delivery_hash = settings.CLOUDFLARE_IMAGE_HASH
|
||||
|
||||
# Enable mock mode if CloudFlare is not configured
|
||||
self.mock_mode = not all([self.account_id, self.api_token, self.delivery_hash])
|
||||
|
||||
if self.mock_mode:
|
||||
logger.warning("CloudFlare Images not configured - using mock mode")
|
||||
|
||||
self.base_url = f"https://api.cloudflare.com/client/v4/accounts/{self.account_id}/images/v1"
|
||||
self.headers = {"Authorization": f"Bearer {self.api_token}"}
|
||||
|
||||
def upload_image(
|
||||
self,
|
||||
file: InMemoryUploadedFile | TemporaryUploadedFile,
|
||||
metadata: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Upload an image to CloudFlare Images.
|
||||
|
||||
Args:
|
||||
file: The uploaded file object
|
||||
metadata: Optional metadata dictionary
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- id: CloudFlare image ID
|
||||
- url: CDN URL for the image
|
||||
- variants: Available image variants
|
||||
|
||||
Raises:
|
||||
CloudFlareError: If upload fails
|
||||
"""
|
||||
if self.mock_mode:
|
||||
return self._mock_upload(file, metadata)
|
||||
|
||||
try:
|
||||
# Prepare the file for upload
|
||||
file.seek(0) # Reset file pointer
|
||||
|
||||
# Prepare multipart form data
|
||||
files = {
|
||||
'file': (file.name, file.read(), file.content_type)
|
||||
}
|
||||
|
||||
# Add metadata if provided
|
||||
data = {}
|
||||
if metadata:
|
||||
data['metadata'] = str(metadata)
|
||||
|
||||
# Make API request
|
||||
response = requests.post(
|
||||
self.base_url,
|
||||
headers=self.headers,
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if not result.get('success'):
|
||||
raise CloudFlareError(f"Upload failed: {result.get('errors', [])}")
|
||||
|
||||
image_data = result['result']
|
||||
|
||||
return {
|
||||
'id': image_data['id'],
|
||||
'url': self._get_cdn_url(image_data['id']),
|
||||
'variants': image_data.get('variants', []),
|
||||
'uploaded': image_data.get('uploaded'),
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"CloudFlare upload failed: {str(e)}")
|
||||
raise CloudFlareError(f"Failed to upload image: {str(e)}")
|
||||
|
||||
def delete_image(self, image_id: str) -> bool:
|
||||
"""
|
||||
Delete an image from CloudFlare Images.
|
||||
|
||||
Args:
|
||||
image_id: The CloudFlare image ID
|
||||
|
||||
Returns:
|
||||
True if deletion was successful
|
||||
|
||||
Raises:
|
||||
CloudFlareError: If deletion fails
|
||||
"""
|
||||
if self.mock_mode:
|
||||
return self._mock_delete(image_id)
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/{image_id}"
|
||||
response = requests.delete(
|
||||
url,
|
||||
headers=self.headers,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
return result.get('success', False)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"CloudFlare deletion failed: {str(e)}")
|
||||
raise CloudFlareError(f"Failed to delete image: {str(e)}")
|
||||
|
||||
def get_image_url(self, image_id: str, variant: str = "public") -> str:
|
||||
"""
|
||||
Generate a CloudFlare CDN URL for an image.
|
||||
|
||||
Args:
|
||||
image_id: The CloudFlare image ID
|
||||
variant: Image variant (public, thumbnail, banner, etc.)
|
||||
|
||||
Returns:
|
||||
CDN URL for the image
|
||||
"""
|
||||
if self.mock_mode:
|
||||
return self._mock_url(image_id, variant)
|
||||
|
||||
return self._get_cdn_url(image_id, variant)
|
||||
|
||||
def get_image_variants(self, image_id: str) -> List[str]:
|
||||
"""
|
||||
Get available variants for an image.
|
||||
|
||||
Args:
|
||||
image_id: The CloudFlare image ID
|
||||
|
||||
Returns:
|
||||
List of available variant names
|
||||
"""
|
||||
if self.mock_mode:
|
||||
return ['public', 'thumbnail', 'banner']
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/{image_id}"
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self.headers,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result.get('success'):
|
||||
return list(result['result'].get('variants', []))
|
||||
|
||||
return []
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to get variants: {str(e)}")
|
||||
return []
|
||||
|
||||
def _get_cdn_url(self, image_id: str, variant: str = "public") -> str:
|
||||
"""Generate CloudFlare CDN URL."""
|
||||
return f"https://imagedelivery.net/{self.delivery_hash}/{image_id}/{variant}"
|
||||
|
||||
# Mock methods for development without CloudFlare
|
||||
|
||||
def _mock_upload(self, file, metadata) -> Dict[str, Any]:
|
||||
"""Mock upload for development."""
|
||||
import uuid
|
||||
mock_id = str(uuid.uuid4())
|
||||
|
||||
logger.info(f"[MOCK] Uploaded image: {file.name} -> {mock_id}")
|
||||
|
||||
return {
|
||||
'id': mock_id,
|
||||
'url': self._mock_url(mock_id),
|
||||
'variants': ['public', 'thumbnail', 'banner'],
|
||||
'uploaded': 'mock',
|
||||
}
|
||||
|
||||
def _mock_delete(self, image_id: str) -> bool:
|
||||
"""Mock deletion for development."""
|
||||
logger.info(f"[MOCK] Deleted image: {image_id}")
|
||||
return True
|
||||
|
||||
def _mock_url(self, image_id: str, variant: str = "public") -> str:
|
||||
"""Generate mock URL for development."""
|
||||
return f"https://placehold.co/800x600/png?text={image_id[:8]}"
|
||||
|
||||
|
||||
class PhotoService:
|
||||
"""
|
||||
Service for managing Photo objects with CloudFlare integration.
|
||||
|
||||
Handles photo creation, attachment to entities, moderation,
|
||||
and gallery management.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.cloudflare = CloudFlareService()
|
||||
|
||||
def create_photo(
|
||||
self,
|
||||
file: InMemoryUploadedFile | TemporaryUploadedFile,
|
||||
user,
|
||||
entity: Optional[Model] = None,
|
||||
photo_type: str = "gallery",
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
credit: str = "",
|
||||
is_visible: bool = True,
|
||||
) -> Photo:
|
||||
"""
|
||||
Create a new photo with CloudFlare upload.
|
||||
|
||||
Args:
|
||||
file: Uploaded file object
|
||||
user: User uploading the photo
|
||||
entity: Optional entity to attach photo to
|
||||
photo_type: Type of photo (main, gallery, banner, etc.)
|
||||
title: Photo title
|
||||
description: Photo description
|
||||
credit: Photo credit/attribution
|
||||
is_visible: Whether photo is visible
|
||||
|
||||
Returns:
|
||||
Created Photo instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If validation fails
|
||||
CloudFlareError: If upload fails
|
||||
"""
|
||||
# Get image dimensions
|
||||
dimensions = self._get_image_dimensions(file)
|
||||
|
||||
# Upload to CloudFlare
|
||||
upload_result = self.cloudflare.upload_image(
|
||||
file,
|
||||
metadata={
|
||||
'uploaded_by': str(user.id),
|
||||
'photo_type': photo_type,
|
||||
}
|
||||
)
|
||||
|
||||
# Create Photo instance
|
||||
with transaction.atomic():
|
||||
photo = Photo.objects.create(
|
||||
cloudflare_image_id=upload_result['id'],
|
||||
cloudflare_url=upload_result['url'],
|
||||
uploaded_by=user,
|
||||
photo_type=photo_type,
|
||||
title=title or file.name,
|
||||
description=description,
|
||||
credit=credit,
|
||||
width=dimensions['width'],
|
||||
height=dimensions['height'],
|
||||
file_size=file.size,
|
||||
mime_type=file.content_type,
|
||||
is_visible=is_visible,
|
||||
moderation_status='pending',
|
||||
)
|
||||
|
||||
# Attach to entity if provided
|
||||
if entity:
|
||||
self.attach_to_entity(photo, entity)
|
||||
|
||||
logger.info(f"Photo created: {photo.id} by user {user.id}")
|
||||
|
||||
# Trigger async post-processing
|
||||
try:
|
||||
from apps.media.tasks import process_uploaded_image
|
||||
process_uploaded_image.delay(photo.id)
|
||||
except Exception as e:
|
||||
# Don't fail the upload if async task fails to queue
|
||||
logger.warning(f"Failed to queue photo processing task: {str(e)}")
|
||||
|
||||
return photo
|
||||
|
||||
def attach_to_entity(self, photo: Photo, entity: Model) -> None:
|
||||
"""
|
||||
Attach a photo to an entity.
|
||||
|
||||
Args:
|
||||
photo: Photo instance
|
||||
entity: Entity to attach to (Park, Ride, Company, etc.)
|
||||
"""
|
||||
content_type = ContentType.objects.get_for_model(entity)
|
||||
photo.content_type = content_type
|
||||
photo.object_id = entity.pk
|
||||
photo.save(update_fields=['content_type', 'object_id'])
|
||||
|
||||
logger.info(f"Photo {photo.id} attached to {content_type.model} {entity.pk}")
|
||||
|
||||
def detach_from_entity(self, photo: Photo) -> None:
|
||||
"""
|
||||
Detach a photo from its entity.
|
||||
|
||||
Args:
|
||||
photo: Photo instance
|
||||
"""
|
||||
photo.content_type = None
|
||||
photo.object_id = None
|
||||
photo.save(update_fields=['content_type', 'object_id'])
|
||||
|
||||
logger.info(f"Photo {photo.id} detached from entity")
|
||||
|
||||
def moderate_photo(
|
||||
self,
|
||||
photo: Photo,
|
||||
status: str,
|
||||
moderator,
|
||||
notes: str = ""
|
||||
) -> Photo:
|
||||
"""
|
||||
Moderate a photo (approve/reject/flag).
|
||||
|
||||
Args:
|
||||
photo: Photo instance
|
||||
status: New status (approved, rejected, flagged)
|
||||
moderator: User performing moderation
|
||||
notes: Moderation notes
|
||||
|
||||
Returns:
|
||||
Updated Photo instance
|
||||
"""
|
||||
with transaction.atomic():
|
||||
photo.moderation_status = status
|
||||
photo.moderated_by = moderator
|
||||
photo.moderation_notes = notes
|
||||
|
||||
if status == 'approved':
|
||||
photo.approve()
|
||||
elif status == 'rejected':
|
||||
photo.reject()
|
||||
elif status == 'flagged':
|
||||
photo.flag()
|
||||
|
||||
photo.save()
|
||||
|
||||
logger.info(
|
||||
f"Photo {photo.id} moderated: {status} by user {moderator.id}"
|
||||
)
|
||||
|
||||
return photo
|
||||
|
||||
def reorder_photos(
|
||||
self,
|
||||
entity: Model,
|
||||
photo_ids: List[int],
|
||||
photo_type: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Reorder photos for an entity.
|
||||
|
||||
Args:
|
||||
entity: Entity whose photos to reorder
|
||||
photo_ids: List of photo IDs in desired order
|
||||
photo_type: Optional photo type filter
|
||||
"""
|
||||
content_type = ContentType.objects.get_for_model(entity)
|
||||
|
||||
with transaction.atomic():
|
||||
for order, photo_id in enumerate(photo_ids):
|
||||
filters = {
|
||||
'id': photo_id,
|
||||
'content_type': content_type,
|
||||
'object_id': entity.pk,
|
||||
}
|
||||
|
||||
if photo_type:
|
||||
filters['photo_type'] = photo_type
|
||||
|
||||
Photo.objects.filter(**filters).update(display_order=order)
|
||||
|
||||
logger.info(f"Reordered {len(photo_ids)} photos for {content_type.model} {entity.pk}")
|
||||
|
||||
def get_entity_photos(
|
||||
self,
|
||||
entity: Model,
|
||||
photo_type: Optional[str] = None,
|
||||
approved_only: bool = True
|
||||
) -> List[Photo]:
|
||||
"""
|
||||
Get photos for an entity.
|
||||
|
||||
Args:
|
||||
entity: Entity to get photos for
|
||||
photo_type: Optional photo type filter
|
||||
approved_only: Whether to return only approved photos
|
||||
|
||||
Returns:
|
||||
List of Photo instances ordered by display_order
|
||||
"""
|
||||
content_type = ContentType.objects.get_for_model(entity)
|
||||
|
||||
queryset = Photo.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=entity.pk,
|
||||
)
|
||||
|
||||
if photo_type:
|
||||
queryset = queryset.filter(photo_type=photo_type)
|
||||
|
||||
if approved_only:
|
||||
queryset = queryset.approved()
|
||||
|
||||
return list(queryset.order_by('display_order', '-created_at'))
|
||||
|
||||
def delete_photo(self, photo: Photo, delete_from_cloudflare: bool = True) -> None:
|
||||
"""
|
||||
Delete a photo.
|
||||
|
||||
Args:
|
||||
photo: Photo instance to delete
|
||||
delete_from_cloudflare: Whether to also delete from CloudFlare
|
||||
"""
|
||||
cloudflare_id = photo.cloudflare_image_id
|
||||
|
||||
with transaction.atomic():
|
||||
photo.delete()
|
||||
|
||||
# Delete from CloudFlare after DB deletion succeeds
|
||||
if delete_from_cloudflare and cloudflare_id:
|
||||
try:
|
||||
self.cloudflare.delete_image(cloudflare_id)
|
||||
except CloudFlareError as e:
|
||||
logger.error(f"Failed to delete from CloudFlare: {str(e)}")
|
||||
# Don't raise - photo is already deleted from DB
|
||||
|
||||
logger.info(f"Photo deleted: {cloudflare_id}")
|
||||
|
||||
def _get_image_dimensions(
|
||||
self,
|
||||
file: InMemoryUploadedFile | TemporaryUploadedFile
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Extract image dimensions from uploaded file.
|
||||
|
||||
Args:
|
||||
file: Uploaded file object
|
||||
|
||||
Returns:
|
||||
Dict with 'width' and 'height' keys
|
||||
"""
|
||||
try:
|
||||
file.seek(0)
|
||||
image = Image.open(file)
|
||||
width, height = image.size
|
||||
file.seek(0) # Reset for later use
|
||||
|
||||
return {'width': width, 'height': height}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get image dimensions: {str(e)}")
|
||||
return {'width': 0, 'height': 0}
|
||||
219
django/apps/media/tasks.py
Normal file
219
django/apps/media/tasks.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Background tasks for media processing and management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def process_uploaded_image(self, photo_id):
|
||||
"""
|
||||
Process an uploaded image asynchronously.
|
||||
|
||||
This task runs after a photo is uploaded to perform additional
|
||||
processing like metadata extraction, validation, etc.
|
||||
|
||||
Args:
|
||||
photo_id: ID of the Photo to process
|
||||
|
||||
Returns:
|
||||
str: Processing result message
|
||||
"""
|
||||
from apps.media.models import Photo
|
||||
|
||||
try:
|
||||
photo = Photo.objects.get(id=photo_id)
|
||||
|
||||
# Log processing start
|
||||
logger.info(f"Processing photo {photo_id}: {photo.title}")
|
||||
|
||||
# Additional processing could include:
|
||||
# - Generating additional thumbnails
|
||||
# - Extracting EXIF data
|
||||
# - Running image quality checks
|
||||
# - Updating photo metadata
|
||||
|
||||
# For now, just log that processing is complete
|
||||
logger.info(f"Photo {photo_id} processed successfully")
|
||||
|
||||
return f"Photo {photo_id} processed successfully"
|
||||
|
||||
except Photo.DoesNotExist:
|
||||
logger.error(f"Photo {photo_id} not found")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error processing photo {photo_id}: {str(exc)}")
|
||||
# Retry with exponential backoff
|
||||
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=2)
|
||||
def cleanup_rejected_photos(self, days_old=30):
|
||||
"""
|
||||
Clean up photos that have been rejected for more than N days.
|
||||
|
||||
This task runs periodically (e.g., weekly) to remove old rejected
|
||||
photos and free up storage space.
|
||||
|
||||
Args:
|
||||
days_old: Number of days after rejection to delete (default: 30)
|
||||
|
||||
Returns:
|
||||
dict: Cleanup statistics
|
||||
"""
|
||||
from apps.media.models import Photo
|
||||
from apps.media.services import PhotoService
|
||||
|
||||
try:
|
||||
cutoff_date = timezone.now() - timedelta(days=days_old)
|
||||
|
||||
# Find rejected photos older than cutoff
|
||||
old_rejected = Photo.objects.filter(
|
||||
moderation_status='rejected',
|
||||
moderated_at__lt=cutoff_date
|
||||
)
|
||||
|
||||
count = old_rejected.count()
|
||||
logger.info(f"Found {count} rejected photos to cleanup")
|
||||
|
||||
# Delete each photo
|
||||
photo_service = PhotoService()
|
||||
deleted_count = 0
|
||||
|
||||
for photo in old_rejected:
|
||||
try:
|
||||
photo_service.delete_photo(photo, delete_from_cloudflare=True)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete photo {photo.id}: {str(e)}")
|
||||
continue
|
||||
|
||||
result = {
|
||||
'found': count,
|
||||
'deleted': deleted_count,
|
||||
'failed': count - deleted_count,
|
||||
'cutoff_date': cutoff_date.isoformat()
|
||||
}
|
||||
|
||||
logger.info(f"Cleanup complete: {result}")
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Error during photo cleanup: {str(exc)}")
|
||||
raise self.retry(exc=exc, countdown=300) # Retry after 5 minutes
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def generate_photo_thumbnails(self, photo_id, variants=None):
|
||||
"""
|
||||
Generate thumbnails for a photo on demand.
|
||||
|
||||
This can be used to regenerate thumbnails if the original
|
||||
is updated or if new variants are needed.
|
||||
|
||||
Args:
|
||||
photo_id: ID of the Photo
|
||||
variants: List of variant names to generate (None = all)
|
||||
|
||||
Returns:
|
||||
dict: Generated variants and their URLs
|
||||
"""
|
||||
from apps.media.models import Photo
|
||||
from apps.media.services import CloudFlareService
|
||||
|
||||
try:
|
||||
photo = Photo.objects.get(id=photo_id)
|
||||
cloudflare = CloudFlareService()
|
||||
|
||||
if variants is None:
|
||||
variants = ['public', 'thumbnail', 'banner']
|
||||
|
||||
result = {}
|
||||
for variant in variants:
|
||||
url = cloudflare.get_image_url(photo.cloudflare_image_id, variant)
|
||||
result[variant] = url
|
||||
|
||||
logger.info(f"Generated thumbnails for photo {photo_id}: {variants}")
|
||||
return result
|
||||
|
||||
except Photo.DoesNotExist:
|
||||
logger.error(f"Photo {photo_id} not found")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error generating thumbnails for photo {photo_id}: {str(exc)}")
|
||||
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=2)
|
||||
def cleanup_orphaned_cloudflare_images(self):
|
||||
"""
|
||||
Clean up CloudFlare images that no longer have database records.
|
||||
|
||||
This task helps prevent storage bloat by removing images that
|
||||
were uploaded but their database records were deleted.
|
||||
|
||||
Returns:
|
||||
dict: Cleanup statistics
|
||||
"""
|
||||
from apps.media.models import Photo
|
||||
from apps.media.services import CloudFlareService
|
||||
|
||||
try:
|
||||
cloudflare = CloudFlareService()
|
||||
|
||||
# In a real implementation, you would:
|
||||
# 1. Get list of all images from CloudFlare API
|
||||
# 2. Check which ones don't have Photo records
|
||||
# 3. Delete the orphaned images
|
||||
|
||||
# For now, just log that the task ran
|
||||
logger.info("Orphaned image cleanup task completed (not implemented in mock mode)")
|
||||
|
||||
return {
|
||||
'checked': 0,
|
||||
'orphaned': 0,
|
||||
'deleted': 0
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Error during orphaned image cleanup: {str(exc)}")
|
||||
raise self.retry(exc=exc, countdown=300)
|
||||
|
||||
|
||||
@shared_task
|
||||
def update_photo_statistics():
|
||||
"""
|
||||
Update photo-related statistics across the database.
|
||||
|
||||
This task can update cached counts, generate reports, etc.
|
||||
|
||||
Returns:
|
||||
dict: Updated statistics
|
||||
"""
|
||||
from apps.media.models import Photo
|
||||
from django.db.models import Count
|
||||
|
||||
try:
|
||||
stats = {
|
||||
'total_photos': Photo.objects.count(),
|
||||
'pending': Photo.objects.filter(moderation_status='pending').count(),
|
||||
'approved': Photo.objects.filter(moderation_status='approved').count(),
|
||||
'rejected': Photo.objects.filter(moderation_status='rejected').count(),
|
||||
'flagged': Photo.objects.filter(moderation_status='flagged').count(),
|
||||
'by_type': dict(
|
||||
Photo.objects.values('photo_type').annotate(count=Count('id'))
|
||||
.values_list('photo_type', 'count')
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(f"Photo statistics updated: {stats}")
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating photo statistics: {str(e)}")
|
||||
raise
|
||||
195
django/apps/media/validators.py
Normal file
195
django/apps/media/validators.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Validators for image uploads.
|
||||
"""
|
||||
|
||||
import magic
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile
|
||||
from PIL import Image
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Allowed file types
|
||||
ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]
|
||||
|
||||
ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.gif']
|
||||
|
||||
# Size limits (in bytes)
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
MIN_FILE_SIZE = 1024 # 1 KB
|
||||
|
||||
# Dimension limits
|
||||
MIN_WIDTH = 100
|
||||
MIN_HEIGHT = 100
|
||||
MAX_WIDTH = 8000
|
||||
MAX_HEIGHT = 8000
|
||||
|
||||
# Aspect ratio limits (for specific photo types)
|
||||
ASPECT_RATIO_LIMITS = {
|
||||
'banner': {'min': 2.0, 'max': 4.0}, # Wide banners
|
||||
'logo': {'min': 0.5, 'max': 2.0}, # Square-ish logos
|
||||
}
|
||||
|
||||
|
||||
def validate_image_file_type(file: InMemoryUploadedFile | TemporaryUploadedFile) -> None:
|
||||
"""
|
||||
Validate that the uploaded file is an allowed image type.
|
||||
|
||||
Uses python-magic to detect actual file type, not just extension.
|
||||
|
||||
Args:
|
||||
file: The uploaded file object
|
||||
|
||||
Raises:
|
||||
ValidationError: If file type is not allowed
|
||||
"""
|
||||
# Check file extension
|
||||
file_ext = None
|
||||
if hasattr(file, 'name') and file.name:
|
||||
file_ext = '.' + file.name.split('.')[-1].lower()
|
||||
if file_ext not in ALLOWED_EXTENSIONS:
|
||||
raise ValidationError(
|
||||
f"File extension {file_ext} not allowed. "
|
||||
f"Allowed extensions: {', '.join(ALLOWED_EXTENSIONS)}"
|
||||
)
|
||||
|
||||
# Check MIME type from content type
|
||||
if hasattr(file, 'content_type'):
|
||||
if file.content_type not in ALLOWED_MIME_TYPES:
|
||||
raise ValidationError(
|
||||
f"File type {file.content_type} not allowed. "
|
||||
f"Allowed types: {', '.join(ALLOWED_MIME_TYPES)}"
|
||||
)
|
||||
|
||||
# Verify actual file content using python-magic
|
||||
try:
|
||||
file.seek(0)
|
||||
mime = magic.from_buffer(file.read(2048), mime=True)
|
||||
file.seek(0)
|
||||
|
||||
if mime not in ALLOWED_MIME_TYPES:
|
||||
raise ValidationError(
|
||||
f"File content type {mime} does not match allowed types. "
|
||||
"File may be corrupted or incorrectly labeled."
|
||||
)
|
||||
except Exception as e:
|
||||
# If magic fails, we already validated content_type above
|
||||
pass
|
||||
|
||||
|
||||
def validate_image_file_size(file: InMemoryUploadedFile | TemporaryUploadedFile) -> None:
|
||||
"""
|
||||
Validate that the file size is within allowed limits.
|
||||
|
||||
Args:
|
||||
file: The uploaded file object
|
||||
|
||||
Raises:
|
||||
ValidationError: If file size is not within limits
|
||||
"""
|
||||
file_size = file.size
|
||||
|
||||
if file_size < MIN_FILE_SIZE:
|
||||
raise ValidationError(
|
||||
f"File size is too small. Minimum: {MIN_FILE_SIZE / 1024:.0f} KB"
|
||||
)
|
||||
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
raise ValidationError(
|
||||
f"File size is too large. Maximum: {MAX_FILE_SIZE / (1024 * 1024):.0f} MB"
|
||||
)
|
||||
|
||||
|
||||
def validate_image_dimensions(
|
||||
file: InMemoryUploadedFile | TemporaryUploadedFile,
|
||||
photo_type: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Validate image dimensions and aspect ratio.
|
||||
|
||||
Args:
|
||||
file: The uploaded file object
|
||||
photo_type: Optional photo type for specific validation
|
||||
|
||||
Raises:
|
||||
ValidationError: If dimensions are not within limits
|
||||
"""
|
||||
try:
|
||||
file.seek(0)
|
||||
image = Image.open(file)
|
||||
width, height = image.size
|
||||
file.seek(0)
|
||||
except Exception as e:
|
||||
raise ValidationError(f"Could not read image dimensions: {str(e)}")
|
||||
|
||||
# Check minimum dimensions
|
||||
if width < MIN_WIDTH or height < MIN_HEIGHT:
|
||||
raise ValidationError(
|
||||
f"Image dimensions too small. Minimum: {MIN_WIDTH}x{MIN_HEIGHT}px, "
|
||||
f"got: {width}x{height}px"
|
||||
)
|
||||
|
||||
# Check maximum dimensions
|
||||
if width > MAX_WIDTH or height > MAX_HEIGHT:
|
||||
raise ValidationError(
|
||||
f"Image dimensions too large. Maximum: {MAX_WIDTH}x{MAX_HEIGHT}px, "
|
||||
f"got: {width}x{height}px"
|
||||
)
|
||||
|
||||
# Check aspect ratio for specific photo types
|
||||
if photo_type and photo_type in ASPECT_RATIO_LIMITS:
|
||||
aspect_ratio = width / height
|
||||
limits = ASPECT_RATIO_LIMITS[photo_type]
|
||||
|
||||
if aspect_ratio < limits['min'] or aspect_ratio > limits['max']:
|
||||
raise ValidationError(
|
||||
f"Invalid aspect ratio for {photo_type}. "
|
||||
f"Expected ratio between {limits['min']:.2f} and {limits['max']:.2f}, "
|
||||
f"got: {aspect_ratio:.2f}"
|
||||
)
|
||||
|
||||
|
||||
def validate_image(
|
||||
file: InMemoryUploadedFile | TemporaryUploadedFile,
|
||||
photo_type: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Run all image validations.
|
||||
|
||||
Args:
|
||||
file: The uploaded file object
|
||||
photo_type: Optional photo type for specific validation
|
||||
|
||||
Raises:
|
||||
ValidationError: If any validation fails
|
||||
"""
|
||||
validate_image_file_type(file)
|
||||
validate_image_file_size(file)
|
||||
validate_image_dimensions(file, photo_type)
|
||||
|
||||
|
||||
def validate_image_content_safety(file: InMemoryUploadedFile | TemporaryUploadedFile) -> None:
|
||||
"""
|
||||
Placeholder for content safety validation.
|
||||
|
||||
This could integrate with services like:
|
||||
- AWS Rekognition
|
||||
- Google Cloud Vision
|
||||
- Azure Content Moderator
|
||||
|
||||
For now, this is a no-op but provides extension point.
|
||||
|
||||
Args:
|
||||
file: The uploaded file object
|
||||
|
||||
Raises:
|
||||
ValidationError: If content is deemed unsafe
|
||||
"""
|
||||
# TODO: Integrate with content moderation API
|
||||
pass
|
||||
Reference in New Issue
Block a user