Files
thrillwiki_django_no_react/backend/apps/rides/models/media.py
pacnpal cf54df0416 fix(fsm): Fix StateLog.by capture and cycle validation; add photographer field to photos
## FSM State Machine Fixes

### StateLog.by Field Capture
- Modified TransitionMethodFactory to pass 'user' as 'by' kwarg to enable
  django-fsm-log's @fsm_log_by decorator to correctly capture the user who
  performed the transition
- Applied fix to both escalate_transition and create_transition_method
- Uses exec() to dynamically create transition functions with correct __name__
  before decorators are applied, ensuring django-fsm's method registration works

### Cycle Validation Behavior
- Changed validate_no_cycles() to return ValidationWarning instead of ValidationError
- Cycles are now treated as warnings, not blocking errors, since cycles are often
  intentional in operational status FSMs (e.g., reopening after temporary closure)

### Ride Status Transitions
- Added TEMPORARY_CLOSURE -> OPERATING transition (reopen after temporary closure)
- Added SBNO -> OPERATING transition (revival - ride returns to operation)

## Field Parity

### Photo Models
- Added 'photographer' field to RidePhoto and ParkPhoto models
- Maps to frontend 'photographer_credit' field for full schema parity
- Includes corresponding migrations for both apps

### Serializers
- Added 'photographer' to RidePhotoSerializer and ParkPhotoSerializer read_only_fields
2026-01-09 08:04:44 -05:00

143 lines
4.4 KiB
Python

"""
Ride-specific media models for ThrillWiki.
This module contains media models specific to rides domain.
"""
from typing import Any, cast
import pghistory
from django.conf import settings
from django.db import models
from apps.core.choices import RichChoiceField
from apps.core.history import TrackedModel
from apps.core.services.media_service import MediaService
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 = models.ForeignKey(
"django_cloudflareimages_toolkit.CloudflareImage",
on_delete=models.CASCADE,
help_text="Ride photo stored on Cloudflare Images",
)
caption = models.CharField(max_length=255, blank=True)
photographer = models.CharField(
max_length=200,
blank=True,
help_text="Photographer credit (maps to frontend photographer_credit)"
)
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 = RichChoiceField(
choice_group="photo_types",
domain="rides",
max_length=50,
default="exterior",
help_text="Type of photo for categorization and display purposes",
)
# 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) -> int | None:
"""Get file size in bytes."""
try:
return self.image.size
except (ValueError, OSError):
return None
@property
def dimensions(self) -> list[int] | None:
"""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