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:
pacnpal
2025-11-08 15:34:04 -05:00
parent 9c46ef8b03
commit d6ff4cc3a3
335 changed files with 61926 additions and 73 deletions

Binary file not shown.

View File

@@ -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"

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

View 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