chore: fix pghistory migration deps and improve htmx utilities

- Update pghistory dependency from 0007 to 0006 in account migrations
- Add docstrings and remove unused imports in htmx_forms.py
- Add DJANGO_SETTINGS_MODULE bash commands to Claude settings
- Add state transition definitions for ride statuses
This commit is contained in:
pacnpal
2025-12-21 17:33:24 -05:00
parent b9063ff4f8
commit 7ba0004c93
74 changed files with 11134 additions and 198 deletions

View File

@@ -6,4 +6,12 @@ class RidesConfig(AppConfig):
name = "apps.rides"
def ready(self):
pass
import apps.rides.choices # noqa: F401 - Register choices
import apps.rides.tasks # noqa: F401 - Register Celery tasks
from apps.core.state_machine import apply_state_machine
from apps.rides.models import Ride
# Register FSM transitions for Ride
apply_state_machine(
Ride, field_name="status", choice_group="statuses", domain="rides"
)

View File

@@ -95,7 +95,15 @@ RIDE_STATUSES = [
'color': 'green',
'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800',
'sort_order': 1
'sort_order': 1,
'can_transition_to': [
'CLOSED_TEMP',
'SBNO',
'CLOSING',
],
'requires_moderator': False,
'is_final': False,
'is_initial': True,
},
category=ChoiceCategory.STATUS
),
@@ -107,7 +115,13 @@ RIDE_STATUSES = [
'color': 'yellow',
'icon': 'pause-circle',
'css_class': 'bg-yellow-100 text-yellow-800',
'sort_order': 2
'sort_order': 2,
'can_transition_to': [
'SBNO',
'CLOSING',
],
'requires_moderator': False,
'is_final': False,
},
category=ChoiceCategory.STATUS
),
@@ -119,7 +133,14 @@ RIDE_STATUSES = [
'color': 'orange',
'icon': 'stop-circle',
'css_class': 'bg-orange-100 text-orange-800',
'sort_order': 3
'sort_order': 3,
'can_transition_to': [
'CLOSED_PERM',
'DEMOLISHED',
'RELOCATED',
],
'requires_moderator': True,
'is_final': False,
},
category=ChoiceCategory.STATUS
),
@@ -131,7 +152,13 @@ RIDE_STATUSES = [
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 4
'sort_order': 4,
'can_transition_to': [
'CLOSED_PERM',
'SBNO',
],
'requires_moderator': True,
'is_final': False,
},
category=ChoiceCategory.STATUS
),
@@ -143,7 +170,13 @@ RIDE_STATUSES = [
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800',
'sort_order': 5
'sort_order': 5,
'can_transition_to': [
'DEMOLISHED',
'RELOCATED',
],
'requires_moderator': True,
'is_final': False,
},
category=ChoiceCategory.STATUS
),
@@ -155,7 +188,12 @@ RIDE_STATUSES = [
'color': 'blue',
'icon': 'tool',
'css_class': 'bg-blue-100 text-blue-800',
'sort_order': 6
'sort_order': 6,
'can_transition_to': [
'OPERATING',
],
'requires_moderator': False,
'is_final': False,
},
category=ChoiceCategory.STATUS
),
@@ -167,7 +205,10 @@ RIDE_STATUSES = [
'color': 'gray',
'icon': 'trash',
'css_class': 'bg-gray-100 text-gray-800',
'sort_order': 7
'sort_order': 7,
'can_transition_to': [],
'requires_moderator': True,
'is_final': True,
},
category=ChoiceCategory.STATUS
),
@@ -179,7 +220,10 @@ RIDE_STATUSES = [
'color': 'purple',
'icon': 'arrow-right',
'css_class': 'bg-purple-100 text-purple-800',
'sort_order': 8
'sort_order': 8,
'can_transition_to': [],
'requires_moderator': True,
'is_final': True,
},
category=ChoiceCategory.STATUS
),

View File

@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("pghistory", "0006_delete_aggregateevent"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

View File

@@ -9,7 +9,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0006_remove_company_insert_insert_and_more"),
("pghistory", "0007_auto_20250421_0444"),
("pghistory", "0006_delete_aggregateevent"),
("rides", "0003_remove_company_insert_insert_and_more"),
]

View File

@@ -9,7 +9,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("pghistory", "0006_delete_aggregateevent"),
("rides", "0004_rideevent_ridemodelevent_rollercoasterstatsevent_and_more"),
]

View File

@@ -10,7 +10,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("pghistory", "0006_delete_aggregateevent"),
("rides", "0005_ridelocationevent_ridelocation_insert_insert_and_more"),
]

View File

@@ -10,7 +10,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("pghistory", "0006_delete_aggregateevent"),
("rides", "0006_add_ride_rankings"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

View File

@@ -10,7 +10,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("pghistory", "0006_delete_aggregateevent"),
("rides", "0009_add_banner_card_image_fields"),
]

View File

@@ -0,0 +1,336 @@
# Generated by Django 5.1.3 on 2025-12-21 03:20
import apps.core.state_machine.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0006_delete_aggregateevent"),
("rides", "0024_rename_launch_type_to_propulsion_system"),
]
operations = [
migrations.AlterField(
model_name="companyevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="companyevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.company",
),
),
migrations.AlterField(
model_name="ride",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="statuses",
choices=[
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("SBNO", "Standing But Not Operating"),
("CLOSING", "Closing"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
],
default="OPERATING",
domain="rides",
help_text="Current operational status of the ride",
max_length=20,
),
),
migrations.AlterField(
model_name="rideevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="rideevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.ride",
),
),
migrations.AlterField(
model_name="rideevent",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="statuses",
choices=[
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("SBNO", "Standing But Not Operating"),
("CLOSING", "Closing"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
],
default="OPERATING",
domain="rides",
help_text="Current operational status of the ride",
max_length=20,
),
),
migrations.AlterField(
model_name="ridelocationevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="ridelocationevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.ridelocation",
),
),
migrations.AlterField(
model_name="ridemodelevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="ridemodelevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.ridemodel",
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.ridemodelphoto",
),
),
migrations.AlterField(
model_name="ridemodeltechnicalspecevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="ridemodeltechnicalspecevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.ridemodeltechnicalspec",
),
),
migrations.AlterField(
model_name="ridemodelvariantevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="ridemodelvariantevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.ridemodelvariant",
),
),
migrations.AlterField(
model_name="ridepaircomparisonevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="ridepaircomparisonevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.ridepaircomparison",
),
),
migrations.AlterField(
model_name="ridephotoevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="ridephotoevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.ridephoto",
),
),
migrations.AlterField(
model_name="riderankingevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="riderankingevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.rideranking",
),
),
migrations.AlterField(
model_name="ridereviewevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="ridereviewevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.ridereview",
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="pghistory.context",
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="rides.rollercoasterstats",
),
),
]

View File

@@ -3,9 +3,11 @@ from django.utils.text import slugify
from config.django import base as settings
from apps.core.models import TrackedModel
from apps.core.choices import RichChoiceField
from apps.core.state_machine import RichFSMField, StateMachineMixin
from .company import Company
import pghistory
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
from django.contrib.auth.models import AbstractBaseUser
if TYPE_CHECKING:
from .rides import RollerCoasterStats
@@ -430,7 +432,7 @@ class RideModelTechnicalSpec(TrackedModel):
@pghistory.track()
class Ride(TrackedModel):
class Ride(StateMachineMixin, TrackedModel):
"""Model for individual ride installations at parks
Note: The average_rating field is denormalized and refreshed by background
@@ -440,6 +442,8 @@ class Ride(TrackedModel):
if TYPE_CHECKING:
coaster_stats: 'RollerCoasterStats'
state_field_name = "status"
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255)
description = models.TextField(blank=True)
@@ -485,7 +489,7 @@ class Ride(TrackedModel):
blank=True,
help_text="The specific model/type of this ride",
)
status = RichChoiceField(
status = RichFSMField(
choice_group="statuses",
domain="rides",
max_length=20,
@@ -602,6 +606,87 @@ class Ride(TrackedModel):
def __str__(self) -> str:
return f"{self.name} at {self.park.name}"
# FSM Transition Wrapper Methods
def open(self, *, user: Optional[AbstractBaseUser] = None) -> None:
"""Transition ride to OPERATING status."""
self.transition_to_operating(user=user)
self.save()
def close_temporarily(self, *, user: Optional[AbstractBaseUser] = None) -> None:
"""Transition ride to CLOSED_TEMP status."""
self.transition_to_closed_temp(user=user)
self.save()
def mark_sbno(self, *, user: Optional[AbstractBaseUser] = None) -> None:
"""Transition ride to SBNO (Standing But Not Operating) status."""
self.transition_to_sbno(user=user)
self.save()
def mark_closing(
self,
*,
closing_date,
post_closing_status: str,
user: Optional[AbstractBaseUser] = None,
) -> None:
"""Transition ride to CLOSING status with closing date and target status."""
from django.core.exceptions import ValidationError
if not post_closing_status:
raise ValidationError(
"post_closing_status must be set when entering CLOSING status"
)
self.transition_to_closing(user=user)
self.closing_date = closing_date
self.post_closing_status = post_closing_status
self.save()
def close_permanently(self, *, user: Optional[AbstractBaseUser] = None) -> None:
"""Transition ride to CLOSED_PERM status."""
self.transition_to_closed_perm(user=user)
self.save()
def demolish(self, *, user: Optional[AbstractBaseUser] = None) -> None:
"""Transition ride to DEMOLISHED status."""
self.transition_to_demolished(user=user)
self.save()
def relocate(self, *, user: Optional[AbstractBaseUser] = None) -> None:
"""Transition ride to RELOCATED status."""
self.transition_to_relocated(user=user)
self.save()
def apply_post_closing_status(self, *, user: Optional[AbstractBaseUser] = None) -> None:
"""Apply post_closing_status if closing_date has been reached."""
from django.utils import timezone
from django.core.exceptions import ValidationError
if self.status != "CLOSING":
raise ValidationError("Ride must be in CLOSING status")
if not self.closing_date:
raise ValidationError("closing_date must be set")
if not self.post_closing_status:
raise ValidationError("post_closing_status must be set")
if timezone.now().date() < self.closing_date:
return # Not yet time to transition
# Transition to the target status
if self.post_closing_status == "SBNO":
self.transition_to_sbno(user=user)
elif self.post_closing_status == "CLOSED_PERM":
self.transition_to_closed_perm(user=user)
elif self.post_closing_status == "DEMOLISHED":
self.transition_to_demolished(user=user)
elif self.post_closing_status == "RELOCATED":
self.transition_to_relocated(user=user)
else:
raise ValidationError(f"Invalid post_closing_status: {self.post_closing_status}")
self.save()
def save(self, *args, **kwargs) -> None:
# Handle slug generation and conflicts
if not self.slug:

View File

@@ -0,0 +1,309 @@
"""
Services for ride-related business logic.
Following Django styleguide pattern for business logic encapsulation.
"""
from typing import Optional, Dict, Any
from django.db import transaction
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from apps.rides.models import Ride
# Use AbstractBaseUser for type hinting
UserType = AbstractBaseUser
User = get_user_model()
class RideService:
"""Service for managing ride operations."""
@staticmethod
def create_ride(
*,
name: str,
park_id: int,
description: str = "",
status: str = "OPERATING",
category: str = "",
manufacturer_id: Optional[int] = None,
designer_id: Optional[int] = None,
ride_model_id: Optional[int] = None,
park_area_id: Optional[int] = None,
opening_date: Optional[str] = None,
closing_date: Optional[str] = None,
created_by: Optional[UserType] = None,
) -> Ride:
"""
Create a new ride with validation.
Args:
name: Ride name
park_id: ID of the park
description: Ride description
status: Operating status
category: Ride category
manufacturer_id: ID of manufacturer company
designer_id: ID of designer company
ride_model_id: ID of ride model
park_area_id: ID of park area
opening_date: Opening date
closing_date: Closing date
created_by: User creating the ride
Returns:
Created Ride instance
Raises:
ValidationError: If ride data is invalid
"""
with transaction.atomic():
from apps.parks.models import Park
# Get park
park = Park.objects.get(id=park_id)
# Create ride instance
ride = Ride(
name=name,
park=park,
description=description,
status=status,
category=category,
opening_date=opening_date,
closing_date=closing_date,
)
# Set foreign key relationships if provided
if park_area_id:
from apps.parks.models import ParkArea
ride.park_area = ParkArea.objects.get(id=park_area_id)
if manufacturer_id:
from apps.rides.models import Company
ride.manufacturer = Company.objects.get(id=manufacturer_id)
if designer_id:
from apps.rides.models import Company
ride.designer = Company.objects.get(id=designer_id)
if ride_model_id:
from apps.rides.models import RideModel
ride.ride_model = RideModel.objects.get(id=ride_model_id)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
ride.full_clean()
ride.save()
return ride
@staticmethod
def update_ride(
*,
ride_id: int,
updates: Dict[str, Any],
updated_by: Optional[UserType] = None,
) -> Ride:
"""
Update an existing ride with validation.
Args:
ride_id: ID of ride to update
updates: Dictionary of field updates
updated_by: User performing the update
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
ValidationError: If update data is invalid
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
# Apply updates
for field, value in updates.items():
if hasattr(ride, field):
setattr(ride, field, value)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
ride.full_clean()
ride.save()
return ride
@staticmethod
def close_ride_temporarily(
*, ride_id: int, user: Optional[UserType] = None
) -> Ride:
"""
Temporarily close a ride.
Args:
ride_id: ID of ride to close temporarily
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.close_temporarily(user=user)
return ride
@staticmethod
def mark_ride_sbno(
*, ride_id: int, user: Optional[UserType] = None
) -> Ride:
"""
Mark a ride as SBNO (Standing But Not Operating).
Args:
ride_id: ID of ride to mark as SBNO
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.mark_sbno(user=user)
return ride
@staticmethod
def schedule_ride_closing(
*,
ride_id: int,
closing_date,
post_closing_status: str,
user: Optional[UserType] = None,
) -> Ride:
"""
Schedule a ride to close on a specific date with a post-closing status.
Args:
ride_id: ID of ride to schedule for closing
closing_date: Date when ride will close
post_closing_status: Status to transition to after closing
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
ValidationError: If post_closing_status is not set
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.mark_closing(
closing_date=closing_date,
post_closing_status=post_closing_status,
user=user,
)
return ride
@staticmethod
def close_ride_permanently(
*, ride_id: int, user: Optional[UserType] = None
) -> Ride:
"""
Permanently close a ride.
Args:
ride_id: ID of ride to close permanently
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.close_permanently(user=user)
return ride
@staticmethod
def demolish_ride(*, ride_id: int, user: Optional[UserType] = None) -> Ride:
"""
Mark a ride as demolished.
Args:
ride_id: ID of ride to demolish
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.demolish(user=user)
return ride
@staticmethod
def relocate_ride(
*, ride_id: int, new_park_id: int, user: Optional[UserType] = None
) -> Ride:
"""
Relocate a ride to a new park.
Args:
ride_id: ID of ride to relocate
new_park_id: ID of the new park
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
from apps.parks.models import Park
ride = Ride.objects.select_for_update().get(id=ride_id)
new_park = Park.objects.get(id=new_park_id)
# Mark as relocated first
ride.relocate(user=user)
# Move to new park
ride.move_to_park(new_park, clear_park_area=True)
return ride
@staticmethod
def reopen_ride(*, ride_id: int, user: Optional[UserType] = None) -> Ride:
"""
Reopen a ride for operation.
Args:
ride_id: ID of ride to reopen
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.open(user=user)
return ride

View File

@@ -0,0 +1,211 @@
"""
Services for ride status transitions and management.
Following Django styleguide pattern for business logic encapsulation.
"""
from typing import Optional
from django.db import transaction
from django.contrib.auth.models import AbstractBaseUser
from apps.rides.models import Ride
class RideStatusService:
"""Service for managing ride status transitions using FSM."""
@staticmethod
def open_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride:
"""
Open a ride for operation.
Args:
ride_id: ID of ride to open
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.open(user=user)
return ride
@staticmethod
def close_ride_temporarily(
*, ride_id: int, user: Optional[AbstractBaseUser] = None
) -> Ride:
"""
Temporarily close a ride.
Args:
ride_id: ID of ride to close temporarily
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.close_temporarily(user=user)
return ride
@staticmethod
def mark_ride_sbno(
*, ride_id: int, user: Optional[AbstractBaseUser] = None
) -> Ride:
"""
Mark a ride as SBNO (Standing But Not Operating).
Args:
ride_id: ID of ride to mark as SBNO
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.mark_sbno(user=user)
return ride
@staticmethod
def mark_ride_closing(
*,
ride_id: int,
closing_date,
post_closing_status: str,
user: Optional[AbstractBaseUser] = None,
) -> Ride:
"""
Mark a ride as closing with a specific date and post-closing status.
Args:
ride_id: ID of ride to mark as closing
closing_date: Date when ride will close
post_closing_status: Status to transition to after closing
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
ValidationError: If post_closing_status is not set
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.mark_closing(
closing_date=closing_date,
post_closing_status=post_closing_status,
user=user,
)
return ride
@staticmethod
def close_ride_permanently(
*, ride_id: int, user: Optional[AbstractBaseUser] = None
) -> Ride:
"""
Permanently close a ride.
Args:
ride_id: ID of ride to close permanently
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.close_permanently(user=user)
return ride
@staticmethod
def demolish_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride:
"""
Mark a ride as demolished.
Args:
ride_id: ID of ride to demolish
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.demolish(user=user)
return ride
@staticmethod
def relocate_ride(*, ride_id: int, user: Optional[AbstractBaseUser] = None) -> Ride:
"""
Mark a ride as relocated.
Args:
ride_id: ID of ride to relocate
user: User performing the action
Returns:
Updated Ride instance
Raises:
Ride.DoesNotExist: If ride doesn't exist
"""
with transaction.atomic():
ride = Ride.objects.select_for_update().get(id=ride_id)
ride.relocate(user=user)
return ride
@staticmethod
def process_closing_rides() -> list[Ride]:
"""
Process all rides in CLOSING status and transition them to their
post_closing_status if the closing_date has been reached.
Returns:
List of rides that were transitioned
Note:
This method should be called by a scheduled task/cron job.
"""
from django.utils import timezone
transitioned_rides = []
closing_rides = Ride.objects.filter(
status="CLOSING",
closing_date__lte=timezone.now().date(),
).select_for_update()
for ride in closing_rides:
try:
with transaction.atomic():
ride.apply_post_closing_status()
transitioned_rides.append(ride)
except Exception as e:
# Log error but continue processing other rides
import logging
logger = logging.getLogger(__name__)
logger.error(
f"Failed to process closing ride {ride.id}: {e}",
exc_info=True,
)
continue
return transitioned_rides

123
backend/apps/rides/tasks.py Normal file
View File

@@ -0,0 +1,123 @@
"""
Celery tasks for rides app.
This module contains background tasks for ride management including:
- Automatic status transitions for closing rides
"""
import logging
from celery import shared_task
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
logger = logging.getLogger(__name__)
User = get_user_model()
@shared_task(name="rides.check_overdue_closings")
def check_overdue_closings() -> dict:
"""
Check for rides in CLOSING status that have reached their closing_date
and automatically transition them to their post_closing_status.
This task should be run daily via Celery Beat.
Returns:
dict: Summary with counts of processed, succeeded, and failed rides
"""
from apps.rides.models import Ride
logger.info("Starting overdue closings check")
# Get or create system user for automated transitions
system_user = _get_system_user()
# Query rides that need transition
today = timezone.now().date()
overdue_rides = Ride.objects.filter(
status="CLOSING", closing_date__lte=today
).select_for_update()
processed = 0
succeeded = 0
failed = 0
failures = []
for ride in overdue_rides:
processed += 1
try:
with transaction.atomic():
ride.apply_post_closing_status(user=system_user)
succeeded += 1
logger.info(
"Successfully transitioned ride %s (%s) from CLOSING to %s",
ride.id,
ride.name,
ride.status,
)
except Exception as e:
failed += 1
error_msg = f"Ride {ride.id} ({ride.name}): {str(e)}"
failures.append(error_msg)
logger.error(
"Failed to transition ride %s (%s): %s",
ride.id,
ride.name,
str(e),
exc_info=True,
)
result = {
"processed": processed,
"succeeded": succeeded,
"failed": failed,
"failures": failures,
"date": today.isoformat(),
}
logger.info(
"Completed overdue closings check: %s processed, %s succeeded, %s failed",
processed,
succeeded,
failed,
)
return result
def _get_system_user():
"""
Get or create a system user for automated transitions.
Returns:
User: System user instance
"""
try:
# Try to get existing system user
system_user = User.objects.get(username="system")
except User.DoesNotExist:
# Create system user if it doesn't exist
try:
system_user = User.objects.create_user(
username="system",
email="system@thrillwiki.com",
is_active=False,
is_staff=False,
)
logger.info("Created system user for automated tasks")
except Exception as e:
# If creation fails, try to get moderator or admin user
logger.warning(
"Failed to create system user, falling back to moderator: %s", str(e)
)
try:
system_user = User.objects.filter(is_staff=True).first()
if not system_user:
# Last resort: use any user
system_user = User.objects.first()
except Exception:
system_user = None
return system_user