from typing import Any, Optional, cast from django.db import models from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.conf import settings from PIL import Image, ExifTags from datetime import datetime from .storage import MediaStorage from apps.rides.models import Ride from django.utils import timezone from apps.core.history import TrackedModel import pghistory def photo_upload_path(instance: models.Model, filename: str) -> str: """Generate upload path for photos using normalized filenames""" # Get the content type and object photo = cast(Photo, instance) content_type = photo.content_type.model obj = photo.content_object if obj is None: raise ValueError("Content object cannot be None") # Get object identifier (slug or id) identifier = getattr(obj, "slug", None) if identifier is None: identifier = obj.pk # Use pk instead of id as it's guaranteed to exist # Create normalized filename - always use .jpg extension base_filename = f"{identifier}.jpg" # If it's a ride photo, store it under the park's directory if content_type == "ride": ride = cast(Ride, obj) return f"park/{ride.park.slug}/{identifier}/{base_filename}" # For park photos, store directly in park directory return f"park/{identifier}/{base_filename}" @pghistory.track() class Photo(TrackedModel): """Generic photo model that can be attached to any model""" image = models.ImageField( upload_to=photo_upload_path, # type: ignore[arg-type] max_length=255, storage=MediaStorage(), ) caption = models.CharField(max_length=255, blank=True) alt_text = models.CharField(max_length=255, blank=True) is_primary = models.BooleanField(default=False) is_approved = models.BooleanField(default=False) # New field for approval status created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) date_taken = models.DateTimeField(null=True, blank=True) uploaded_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="uploaded_photos", ) # Generic foreign key fields content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() content_object = GenericForeignKey("content_type", "object_id") class Meta: ordering = ["-is_primary", "-created_at"] indexes = [ models.Index(fields=["content_type", "object_id"]), ] def __str__(self) -> str: return f"{self.content_type} - {self.content_object} - {self.caption or 'No caption'}" def extract_exif_date(self) -> Optional[datetime]: """Extract the date taken from image EXIF data""" try: with Image.open(self.image) as img: exif = img.getexif() if exif: # Find the DateTime tag ID for tag_id in ExifTags.TAGS: if ExifTags.TAGS[tag_id] == "DateTimeOriginal": if tag_id in exif: # EXIF dates are typically in format: # '2024:02:15 14:30:00' date_str = exif[tag_id] return datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S") return None except Exception: return None def save(self, *args: Any, **kwargs: Any) -> None: # Extract EXIF date if this is a new photo if not self.pk and not self.date_taken: self.date_taken = self.extract_exif_date() # Set default caption if not provided if not self.caption and self.uploaded_by: current_time = timezone.now() self.caption = f"Uploaded by { self.uploaded_by.username} on { current_time.strftime('%B %d, %Y at %I:%M %p')}" # If this is marked as primary, unmark other primary photos if self.is_primary: Photo.objects.filter( content_type=self.content_type, object_id=self.object_id, is_primary=True, ).exclude(pk=self.pk).update( is_primary=False ) # Use pk instead of id super().save(*args, **kwargs)