import logging from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from django.utils import timezone from apps.core.utils import capture_and_log from .models import Ride, RideSubType logger = logging.getLogger(__name__) # ============================================================================= # Computed Field Maintenance # ============================================================================= def update_ride_search_text(ride): """ Update ride's search_text computed field. This is called when related objects (park, manufacturer, ride_model) change and might affect the ride's search text. """ if ride is None: return try: ride._populate_computed_fields() ride.save(update_fields=["search_text"]) logger.debug(f"Updated search_text for ride {ride.pk}") except Exception as e: logger.exception(f"Failed to update search_text for ride {ride.pk}: {e}") @receiver(pre_save, sender=Ride) def handle_ride_status(sender, instance, **kwargs): """ Handle ride status changes based on closing date. Integrates with FSM transitions by using transition methods when available. """ if not instance.closing_date: return today = timezone.now().date() # If we've reached the closing date and status is "CLOSING" if today >= instance.closing_date and instance.status == "CLOSING": target_status = instance.post_closing_status or "SBNO" logger.info(f"Ride {instance.pk} closing date reached, " f"transitioning to {target_status}") # Try to use FSM transition method if available transition_method_name = f"transition_to_{target_status.lower()}" if hasattr(instance, transition_method_name): # Check if transition is allowed before attempting if hasattr(instance, "can_proceed"): can_proceed = getattr(instance, f"can_transition_to_{target_status.lower()}", None) if can_proceed and callable(can_proceed) and not can_proceed(): logger.warning(f"FSM transition to {target_status} not allowed " f"for ride {instance.pk}") # Fall back to direct status change instance.status = target_status instance.status_since = instance.closing_date return try: method = getattr(instance, transition_method_name) method() instance.status_since = instance.closing_date logger.info(f"Applied FSM transition to {target_status} for ride {instance.pk}") except Exception as e: logger.exception(f"Failed to apply FSM transition for ride {instance.pk}: {e}") # Fall back to direct status change instance.status = target_status instance.status_since = instance.closing_date else: # No FSM transition method, use direct assignment instance.status = target_status instance.status_since = instance.closing_date @receiver(pre_save, sender=Ride) def validate_closing_status(sender, instance, **kwargs): """ Validate that post_closing_status is set when entering CLOSING state. """ # Only validate if this is an existing ride being updated if not instance.pk: return # Check if we're transitioning to CLOSING if instance.status == "CLOSING": # Ensure post_closing_status is set if not instance.post_closing_status: logger.warning(f"Ride {instance.pk} entering CLOSING without post_closing_status set") # Default to SBNO if not set instance.post_closing_status = "SBNO" # Ensure closing_date is set if not instance.closing_date: logger.warning(f"Ride {instance.pk} entering CLOSING without closing_date set") # Default to today's date instance.closing_date = timezone.now().date() # FSM transition signal handlers def handle_ride_transition_to_closing(instance, source, target, user, **kwargs): """ Validate transition to CLOSING status. This function is called by the FSM callback system before a ride transitions to CLOSING status. Args: instance: The Ride instance. source: The source state. target: The target state. user: The user who initiated the transition. Returns: True if transition should proceed, False to abort. """ if target != "CLOSING": return True if not instance.post_closing_status: # Capture to dashboard as a validation error capture_and_log( ValueError('post_closing_status not set for CLOSING transition'), f'Ride transition to CLOSING for ride {instance.pk}', source='signal', severity='medium', ) return False if not instance.closing_date: logger.warning(f"Ride {instance.pk} transitioning to CLOSING without closing_date") return True def apply_post_closing_status(instance, user=None): """ Apply the post_closing_status to a ride in CLOSING state. This function can be called by the FSM callback system or directly when a ride's closing date is reached. Args: instance: The Ride instance in CLOSING state. user: The user initiating the change (optional). Returns: True if status was applied, False otherwise. """ if instance.status != "CLOSING": logger.debug(f"Ride {instance.pk} not in CLOSING state, skipping") return False target_status = instance.post_closing_status if not target_status: logger.warning(f"Ride {instance.pk} in CLOSING but no post_closing_status set") return False # Try to use FSM transition transition_method_name = f"transition_to_{target_status.lower()}" if hasattr(instance, transition_method_name): try: method = getattr(instance, transition_method_name) method(user=user) instance.post_closing_status = None instance.save(update_fields=["post_closing_status"]) logger.info(f"Applied post_closing_status {target_status} to ride {instance.pk}") return True except Exception as e: logger.exception(f"Failed to apply post_closing_status for ride {instance.pk}: {e}") return False else: # Direct status change instance.status = target_status instance.post_closing_status = None instance.status_since = timezone.now().date() instance.save(update_fields=["status", "post_closing_status", "status_since"]) logger.info(f"Applied post_closing_status {target_status} to ride {instance.pk} (direct)") return True # ============================================================================= # Computed Field Maintenance Signal Handlers # ============================================================================= @receiver(post_save, sender="parks.Park") def update_ride_search_text_on_park_change(sender, instance, **kwargs): """ Update ride search_text when park name or location changes. When a park's name changes, all rides at that park need their search_text regenerated. """ try: for ride in instance.rides.all(): update_ride_search_text(ride) except Exception as e: logger.exception(f"Failed to update ride search_text on park change: {e}") @receiver(post_save, sender="rides.Company") def update_ride_search_text_on_company_change(sender, instance, **kwargs): """ Update ride search_text when manufacturer/designer name changes. When a company's name changes, all rides manufactured or designed by that company need their search_text regenerated. """ try: # Update all rides manufactured by this company for ride in instance.manufactured_rides.all(): update_ride_search_text(ride) # Update all rides designed by this company for ride in instance.designed_rides.all(): update_ride_search_text(ride) except Exception as e: logger.exception(f"Failed to update ride search_text on company change: {e}") @receiver(post_save, sender="rides.RideModel") def update_ride_search_text_on_ride_model_change(sender, instance, **kwargs): """ Update ride search_text when ride model name changes. When a ride model's name changes, all rides using that model need their search_text regenerated. """ try: for ride in instance.rides.all(): update_ride_search_text(ride) except Exception as e: logger.exception(f"Failed to update ride search_text on ride model change: {e}") # ============================================================================= # Automatic Name History Tracking # ============================================================================= @receiver(pre_save, sender=Ride) def track_ride_name_changes(sender, instance, **kwargs): """ Automatically create RideNameHistory when a ride's name changes. This ensures versioning is automatic - when a ride is renamed, the previous name is preserved in the name history. """ if not instance.pk: return # Skip new rides try: old_instance = Ride.objects.get(pk=instance.pk) if old_instance.name != instance.name: from .models import RideNameHistory current_year = timezone.now().year # Create history entry for the old name RideNameHistory.objects.create( ride=instance, former_name=old_instance.name, to_year=current_year, reason="Name changed", ) logger.info( f"Ride {instance.pk} name changed from '{old_instance.name}' " f"to '{instance.name}' - history entry created" ) except Ride.DoesNotExist: pass # New ride, no history to track except Exception as e: logger.exception(f"Failed to track name change for ride {instance.pk}: {e}") # ============================================================================= # Auto-Create Ride Sub-Types on Ride Save # ============================================================================= @receiver(post_save, sender=Ride) def auto_create_ride_sub_type(sender, instance, created, **kwargs): """ Automatically create a RideSubType entry when a ride is saved with a new sub-type value. This integrates with the submission pipeline - when a ride submission with a new ride_sub_type value is approved, the sub-type automatically gets added to the lookup table for future autocomplete suggestions. Args: sender: The Ride model class. instance: The Ride instance that was saved. created: Whether this is a new ride (not used, we check sub-types for all saves). """ # Skip if no ride_sub_type is set if not instance.ride_sub_type or not instance.ride_sub_type.strip(): return # Skip if no category is set (can't categorize the sub-type) if not instance.category: return ride_sub_type_value = instance.ride_sub_type.strip() try: # Check if this sub-type already exists for this category existing = RideSubType.objects.filter( name__iexact=ride_sub_type_value, category=instance.category ).exists() if not existing: # Create the new sub-type entry RideSubType.objects.create( name=ride_sub_type_value, category=instance.category, description=f"Auto-created from ride: {instance.name}", created_by=getattr(instance, 'created_by', None), ) logger.info( f"Auto-created RideSubType '{ride_sub_type_value}' for category " f"'{instance.category}' from ride '{instance.name}'" ) except Exception as e: # Non-critical error - log but don't fail the ride save logger.warning( f"Failed to auto-create RideSubType for ride {instance.pk}: {e}" )