mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:31:13 -05:00
- 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.
493 lines
15 KiB
Python
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}
|