mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 19:11:08 -05:00
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:
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
336
backend/apps/rides/migrations/0025_convert_ride_status_to_fsm.py
Normal file
336
backend/apps/rides/migrations/0025_convert_ride_status_to_fsm.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
309
backend/apps/rides/services.py
Normal file
309
backend/apps/rides/services.py
Normal 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
|
||||
211
backend/apps/rides/services/status_service.py
Normal file
211
backend/apps/rides/services/status_service.py
Normal 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
123
backend/apps/rides/tasks.py
Normal 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
|
||||
Reference in New Issue
Block a user