mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:11:11 -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:
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}
|
||||
Reference in New Issue
Block a user