Files
thrilltrack-explorer/django/apps/media/services.py
pacnpal d6ff4cc3a3 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.
2025-11-08 15:34:04 -05:00

493 lines
15 KiB
Python

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