Files
thrillwiki_django_no_react/backend/apps/rides/models/media.py
pacnpal 67db0aa46e feat(rides): populate slugs for existing RideModel records and ensure uniqueness
- Added migration 0011 to populate unique slugs for existing RideModel records based on manufacturer and model names.
- Implemented logic to ensure slug uniqueness during population.
- Added reverse migration to clear slugs if needed.

feat(rides): enforce unique slugs for RideModel

- Created migration 0012 to alter the slug field in RideModel to be unique.
- Updated the slug field to include help text and a maximum length of 255 characters.

docs: integrate Cloudflare Images into rides and parks models

- Updated RidePhoto and ParkPhoto models to use CloudflareImagesField for image storage.
- Enhanced API serializers for rides and parks to support Cloudflare Images, including new fields for image URLs and variants.
- Provided comprehensive OpenAPI schema metadata for new fields.
- Documented database migrations for the integration.
- Detailed configuration settings for Cloudflare Images.
- Updated API response formats to include Cloudflare Images URLs and variants.
- Added examples for uploading photos via API and outlined testing procedures.
2025-08-28 15:12:39 -04:00

142 lines
4.3 KiB
Python

"""
Ride-specific media models for ThrillWiki.
This module contains media models specific to rides domain.
"""
from typing import Any, Optional, List, cast
from django.db import models
from django.conf import settings
from apps.core.history import TrackedModel
from apps.core.services.media_service import MediaService
from cloudflare_images.field import CloudflareImagesField
import pghistory
def ride_photo_upload_path(instance: models.Model, filename: str) -> str:
"""Generate upload path for ride photos."""
photo = cast("RidePhoto", instance)
ride = photo.ride
if ride is None:
raise ValueError("Ride cannot be None")
return MediaService.generate_upload_path(
domain="park",
identifier=ride.slug,
filename=filename,
subdirectory=ride.park.slug,
)
@pghistory.track()
class RidePhoto(TrackedModel):
"""Photo model specific to rides."""
ride = models.ForeignKey(
"rides.Ride", on_delete=models.CASCADE, related_name="photos"
)
image = CloudflareImagesField(
variant="public",
help_text="Ride photo stored on Cloudflare Images"
)
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)
# Ride-specific metadata
photo_type = models.CharField(
max_length=50,
choices=[
("exterior", "Exterior View"),
("queue", "Queue Area"),
("station", "Station"),
("onride", "On-Ride"),
("construction", "Construction"),
("other", "Other"),
],
default="exterior",
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
date_taken = models.DateTimeField(null=True, blank=True)
# User who uploaded the photo
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="uploaded_ride_photos",
)
class Meta(TrackedModel.Meta):
app_label = "rides"
ordering = ["-is_primary", "-created_at"]
indexes = [
models.Index(fields=["ride", "is_primary"]),
models.Index(fields=["ride", "is_approved"]),
models.Index(fields=["ride", "photo_type"]),
models.Index(fields=["created_at"]),
]
constraints = [
# Only one primary photo per ride
models.UniqueConstraint(
fields=["ride"],
condition=models.Q(is_primary=True),
name="unique_primary_ride_photo",
)
]
def __str__(self) -> str:
return f"Photo of {self.ride.name} - {self.caption or 'No caption'}"
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 and self.image:
self.date_taken = MediaService.extract_exif_date(self.image)
# Set default caption if not provided
if not self.caption and self.uploaded_by:
self.caption = MediaService.generate_default_caption(
self.uploaded_by.username
)
# If this is marked as primary, unmark other primary photos for this ride
if self.is_primary:
RidePhoto.objects.filter(
ride=self.ride,
is_primary=True,
).exclude(pk=self.pk).update(is_primary=False)
super().save(*args, **kwargs)
@property
def file_size(self) -> Optional[int]:
"""Get file size in bytes."""
try:
return self.image.size
except (ValueError, OSError):
return None
@property
def dimensions(self) -> Optional[List[int]]:
"""Get image dimensions as [width, height]."""
try:
return [self.image.width, self.image.height]
except (ValueError, OSError):
return None
def get_absolute_url(self) -> str:
"""Get absolute URL for this photo."""
return f"/parks/{self.ride.park.slug}/rides/{self.ride.slug}/photos/{self.pk}/"
@property
def park(self):
"""Get the park this ride belongs to."""
return self.ride.park