mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 08:25:18 -05:00
feat: add passkey authentication and enhance user preferences - Add passkey login security event type with fingerprint icon - Include request and site context in email confirmation for backend - Add user_id exact match filter to prevent incorrect user lookups - Enable PATCH method for updating user preferences via API - Add moderation_preferences support to user settings - Optimize ticket queries with select_related and prefetch_related This commit introduces passkey authentication tracking, improves user profile filtering accuracy, and extends the preferences API to support updates. Query optimizations reduce database hits for ticket listings.
345 lines
12 KiB
Python
345 lines
12 KiB
Python
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}"
|
|
)
|
|
|
|
|