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