feat: Implement Phase 1.5 entity models (Park, Ride, Company, RideModel, Photo)

- Created Company model with location tracking, date precision, and CloudFlare Images
- Created RideModel model for manufacturer's ride models with specifications
- Created Park model with location, dates, operator, and cached statistics
- Created Ride model with comprehensive stats, manufacturer, and park relationship
- Created Photo model with CloudFlare Images integration and generic relations
- Added lifecycle hooks for auto-slug generation and count updates
- Created migrations and applied to database
- Registered all models in Django admin with detailed fieldsets
- Fixed admin autocomplete_fields to use raw_id_fields where needed
- All models inherit from VersionedModel for automatic version tracking
- Models include date precision tracking for opening/closing dates
- Added comprehensive indexes for query performance

Phase 1.5 complete - Entity models ready for API development
This commit is contained in:
pacnpal
2025-11-08 11:43:27 -05:00
parent 543d7bc9dc
commit 9c46ef8b03
17 changed files with 2326 additions and 0 deletions

Binary file not shown.

View File

@@ -0,0 +1,168 @@
"""
Django Admin configuration for entity models.
"""
from django.contrib import admin
from .models import Company, RideModel, Park, Ride
@admin.register(Company)
class CompanyAdmin(admin.ModelAdmin):
"""Admin interface for Company model."""
list_display = ['name', 'slug', 'location', 'park_count', 'ride_count', 'created', 'modified']
list_filter = ['company_types', 'founded_date']
search_fields = ['name', 'slug', 'description']
readonly_fields = ['id', 'created', 'modified', 'park_count', 'ride_count']
prepopulated_fields = {'slug': ('name',)}
fieldsets = (
('Basic Information', {
'fields': ('name', 'slug', 'description', 'company_types')
}),
('Location', {
'fields': ('location',)
}),
('Dates', {
'fields': (
'founded_date', 'founded_date_precision',
'closed_date', 'closed_date_precision'
)
}),
('Media', {
'fields': ('logo_image_id', 'logo_image_url', 'website')
}),
('Statistics', {
'fields': ('park_count', 'ride_count'),
'classes': ('collapse',)
}),
('System', {
'fields': ('id', 'created', 'modified'),
'classes': ('collapse',)
}),
)
@admin.register(RideModel)
class RideModelAdmin(admin.ModelAdmin):
"""Admin interface for RideModel model."""
list_display = ['name', 'manufacturer', 'model_type', 'installation_count', 'created', 'modified']
list_filter = ['model_type', 'manufacturer']
search_fields = ['name', 'slug', 'description', 'manufacturer__name']
readonly_fields = ['id', 'created', 'modified', 'installation_count']
prepopulated_fields = {'slug': ('name',)}
autocomplete_fields = ['manufacturer']
fieldsets = (
('Basic Information', {
'fields': ('name', 'slug', 'description', 'manufacturer', 'model_type')
}),
('Typical Specifications', {
'fields': ('typical_height', 'typical_speed', 'typical_capacity')
}),
('Media', {
'fields': ('image_id', 'image_url')
}),
('Statistics', {
'fields': ('installation_count',),
'classes': ('collapse',)
}),
('System', {
'fields': ('id', 'created', 'modified'),
'classes': ('collapse',)
}),
)
@admin.register(Park)
class ParkAdmin(admin.ModelAdmin):
"""Admin interface for Park model."""
list_display = ['name', 'location', 'park_type', 'status', 'ride_count', 'coaster_count', 'opening_date']
list_filter = ['park_type', 'status', 'operator', 'opening_date']
search_fields = ['name', 'slug', 'description', 'location__name']
readonly_fields = ['id', 'created', 'modified', 'ride_count', 'coaster_count']
prepopulated_fields = {'slug': ('name',)}
autocomplete_fields = ['operator']
raw_id_fields = ['location']
fieldsets = (
('Basic Information', {
'fields': ('name', 'slug', 'description', 'park_type', 'status')
}),
('Location', {
'fields': ('location', 'latitude', 'longitude')
}),
('Dates', {
'fields': (
'opening_date', 'opening_date_precision',
'closing_date', 'closing_date_precision'
)
}),
('Operator', {
'fields': ('operator',)
}),
('Media', {
'fields': (
'banner_image_id', 'banner_image_url',
'logo_image_id', 'logo_image_url',
'website'
)
}),
('Statistics', {
'fields': ('ride_count', 'coaster_count'),
'classes': ('collapse',)
}),
('Custom Data', {
'fields': ('custom_fields',),
'classes': ('collapse',)
}),
('System', {
'fields': ('id', 'created', 'modified'),
'classes': ('collapse',)
}),
)
@admin.register(Ride)
class RideAdmin(admin.ModelAdmin):
"""Admin interface for Ride model."""
list_display = ['name', 'park', 'ride_category', 'status', 'is_coaster', 'manufacturer', 'opening_date']
list_filter = ['ride_category', 'status', 'is_coaster', 'park', 'manufacturer', 'opening_date']
search_fields = ['name', 'slug', 'description', 'park__name', 'manufacturer__name']
readonly_fields = ['id', 'created', 'modified', 'is_coaster']
prepopulated_fields = {'slug': ('name',)}
autocomplete_fields = ['park', 'manufacturer', 'model']
fieldsets = (
('Basic Information', {
'fields': ('name', 'slug', 'description', 'park')
}),
('Classification', {
'fields': ('ride_category', 'ride_type', 'is_coaster', 'status')
}),
('Dates', {
'fields': (
'opening_date', 'opening_date_precision',
'closing_date', 'closing_date_precision'
)
}),
('Manufacturer', {
'fields': ('manufacturer', 'model')
}),
('Statistics', {
'fields': ('height', 'speed', 'length', 'duration', 'inversions', 'capacity')
}),
('Media', {
'fields': ('image_id', 'image_url')
}),
('Custom Data', {
'fields': ('custom_fields',),
'classes': ('collapse',)
}),
('System', {
'fields': ('id', 'created', 'modified'),
'classes': ('collapse',)
}),
)

View File

@@ -0,0 +1,846 @@
# Generated by Django 4.2.8 on 2025-11-08 16:41
import dirtyfields.dirtyfields
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import django_lifecycle.mixins
import model_utils.fields
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Company",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"name",
models.CharField(
db_index=True,
help_text="Official company name",
max_length=255,
unique=True,
),
),
(
"slug",
models.SlugField(
help_text="URL-friendly identifier", max_length=255, unique=True
),
),
(
"description",
models.TextField(
blank=True, help_text="Company description and history"
),
),
(
"company_types",
models.JSONField(
default=list,
help_text="List of company types (manufacturer, operator, etc.)",
),
),
(
"founded_date",
models.DateField(
blank=True, help_text="Company founding date", null=True
),
),
(
"founded_date_precision",
models.CharField(
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
default="day",
help_text="Precision of founded date",
max_length=20,
),
),
(
"closed_date",
models.DateField(
blank=True,
help_text="Company closure date (if applicable)",
null=True,
),
),
(
"closed_date_precision",
models.CharField(
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
default="day",
help_text="Precision of closed date",
max_length=20,
),
),
(
"website",
models.URLField(blank=True, help_text="Official company website"),
),
(
"logo_image_id",
models.CharField(
blank=True,
help_text="CloudFlare image ID for company logo",
max_length=255,
),
),
(
"logo_image_url",
models.URLField(
blank=True, help_text="CloudFlare image URL for company logo"
),
),
(
"park_count",
models.IntegerField(
default=0, help_text="Number of parks operated (for operators)"
),
),
(
"ride_count",
models.IntegerField(
default=0,
help_text="Number of rides manufactured (for manufacturers)",
),
),
(
"location",
models.ForeignKey(
blank=True,
help_text="Company headquarters location",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="companies",
to="core.locality",
),
),
],
options={
"verbose_name": "Company",
"verbose_name_plural": "Companies",
"ordering": ["name"],
},
bases=(
dirtyfields.dirtyfields.DirtyFieldsMixin,
django_lifecycle.mixins.LifecycleModelMixin,
models.Model,
),
),
migrations.CreateModel(
name="Park",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"name",
models.CharField(
db_index=True, help_text="Official park name", max_length=255
),
),
(
"slug",
models.SlugField(
help_text="URL-friendly identifier", max_length=255, unique=True
),
),
(
"description",
models.TextField(
blank=True, help_text="Park description and history"
),
),
(
"park_type",
models.CharField(
choices=[
("theme_park", "Theme Park"),
("amusement_park", "Amusement Park"),
("water_park", "Water Park"),
(
"family_entertainment_center",
"Family Entertainment Center",
),
("traveling_park", "Traveling Park"),
("zoo", "Zoo"),
("aquarium", "Aquarium"),
],
db_index=True,
help_text="Type of park",
max_length=50,
),
),
(
"status",
models.CharField(
choices=[
("operating", "Operating"),
("closed", "Closed"),
("sbno", "Standing But Not Operating"),
("under_construction", "Under Construction"),
("planned", "Planned"),
],
db_index=True,
default="operating",
help_text="Current operational status",
max_length=50,
),
),
(
"opening_date",
models.DateField(
blank=True,
db_index=True,
help_text="Park opening date",
null=True,
),
),
(
"opening_date_precision",
models.CharField(
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
default="day",
help_text="Precision of opening date",
max_length=20,
),
),
(
"closing_date",
models.DateField(
blank=True, help_text="Park closing date (if closed)", null=True
),
),
(
"closing_date_precision",
models.CharField(
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
default="day",
help_text="Precision of closing date",
max_length=20,
),
),
(
"latitude",
models.DecimalField(
blank=True,
decimal_places=7,
help_text="Latitude coordinate",
max_digits=10,
null=True,
),
),
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=7,
help_text="Longitude coordinate",
max_digits=10,
null=True,
),
),
(
"website",
models.URLField(blank=True, help_text="Official park website"),
),
(
"banner_image_id",
models.CharField(
blank=True,
help_text="CloudFlare image ID for park banner",
max_length=255,
),
),
(
"banner_image_url",
models.URLField(
blank=True, help_text="CloudFlare image URL for park banner"
),
),
(
"logo_image_id",
models.CharField(
blank=True,
help_text="CloudFlare image ID for park logo",
max_length=255,
),
),
(
"logo_image_url",
models.URLField(
blank=True, help_text="CloudFlare image URL for park logo"
),
),
(
"ride_count",
models.IntegerField(default=0, help_text="Total number of rides"),
),
(
"coaster_count",
models.IntegerField(
default=0, help_text="Number of roller coasters"
),
),
(
"custom_fields",
models.JSONField(
blank=True,
default=dict,
help_text="Additional park-specific data",
),
),
(
"location",
models.ForeignKey(
blank=True,
help_text="Park location",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="parks",
to="core.locality",
),
),
(
"operator",
models.ForeignKey(
blank=True,
help_text="Current park operator",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="operated_parks",
to="entities.company",
),
),
],
options={
"verbose_name": "Park",
"verbose_name_plural": "Parks",
"ordering": ["name"],
},
bases=(
dirtyfields.dirtyfields.DirtyFieldsMixin,
django_lifecycle.mixins.LifecycleModelMixin,
models.Model,
),
),
migrations.CreateModel(
name="RideModel",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"name",
models.CharField(
db_index=True,
help_text="Model name (e.g., 'Inverted Coaster', 'Boomerang')",
max_length=255,
),
),
(
"slug",
models.SlugField(
help_text="URL-friendly identifier", max_length=255, unique=True
),
),
(
"description",
models.TextField(
blank=True, help_text="Model description and technical details"
),
),
(
"model_type",
models.CharField(
choices=[
("coaster_model", "Roller Coaster Model"),
("flat_ride_model", "Flat Ride Model"),
("water_ride_model", "Water Ride Model"),
("dark_ride_model", "Dark Ride Model"),
("transport_ride_model", "Transport Ride Model"),
],
db_index=True,
help_text="Type of ride model",
max_length=50,
),
),
(
"typical_height",
models.DecimalField(
blank=True,
decimal_places=1,
help_text="Typical height in feet",
max_digits=6,
null=True,
),
),
(
"typical_speed",
models.DecimalField(
blank=True,
decimal_places=1,
help_text="Typical speed in mph",
max_digits=6,
null=True,
),
),
(
"typical_capacity",
models.IntegerField(
blank=True, help_text="Typical hourly capacity", null=True
),
),
(
"image_id",
models.CharField(
blank=True, help_text="CloudFlare image ID", max_length=255
),
),
(
"image_url",
models.URLField(blank=True, help_text="CloudFlare image URL"),
),
(
"installation_count",
models.IntegerField(
default=0, help_text="Number of installations worldwide"
),
),
(
"manufacturer",
models.ForeignKey(
help_text="Manufacturer of this ride model",
on_delete=django.db.models.deletion.CASCADE,
related_name="ride_models",
to="entities.company",
),
),
],
options={
"verbose_name": "Ride Model",
"verbose_name_plural": "Ride Models",
"ordering": ["manufacturer__name", "name"],
},
bases=(
dirtyfields.dirtyfields.DirtyFieldsMixin,
django_lifecycle.mixins.LifecycleModelMixin,
models.Model,
),
),
migrations.CreateModel(
name="Ride",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"name",
models.CharField(
db_index=True, help_text="Ride name", max_length=255
),
),
(
"slug",
models.SlugField(
help_text="URL-friendly identifier", max_length=255, unique=True
),
),
(
"description",
models.TextField(
blank=True, help_text="Ride description and history"
),
),
(
"ride_category",
models.CharField(
choices=[
("roller_coaster", "Roller Coaster"),
("flat_ride", "Flat Ride"),
("water_ride", "Water Ride"),
("dark_ride", "Dark Ride"),
("transport_ride", "Transport Ride"),
("other", "Other"),
],
db_index=True,
help_text="Broad ride category",
max_length=50,
),
),
(
"ride_type",
models.CharField(
blank=True,
db_index=True,
help_text="Specific ride type (e.g., 'Inverted Coaster', 'Drop Tower')",
max_length=100,
),
),
(
"is_coaster",
models.BooleanField(
db_index=True,
default=False,
help_text="Is this ride a roller coaster?",
),
),
(
"status",
models.CharField(
choices=[
("operating", "Operating"),
("closed", "Closed"),
("sbno", "Standing But Not Operating"),
("relocated", "Relocated"),
("under_construction", "Under Construction"),
("planned", "Planned"),
],
db_index=True,
default="operating",
help_text="Current operational status",
max_length=50,
),
),
(
"opening_date",
models.DateField(
blank=True,
db_index=True,
help_text="Ride opening date",
null=True,
),
),
(
"opening_date_precision",
models.CharField(
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
default="day",
help_text="Precision of opening date",
max_length=20,
),
),
(
"closing_date",
models.DateField(
blank=True, help_text="Ride closing date (if closed)", null=True
),
),
(
"closing_date_precision",
models.CharField(
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
default="day",
help_text="Precision of closing date",
max_length=20,
),
),
(
"height",
models.DecimalField(
blank=True,
decimal_places=1,
help_text="Height in feet",
max_digits=6,
null=True,
),
),
(
"speed",
models.DecimalField(
blank=True,
decimal_places=1,
help_text="Top speed in mph",
max_digits=6,
null=True,
),
),
(
"length",
models.DecimalField(
blank=True,
decimal_places=1,
help_text="Track/ride length in feet",
max_digits=8,
null=True,
),
),
(
"duration",
models.IntegerField(
blank=True, help_text="Ride duration in seconds", null=True
),
),
(
"inversions",
models.IntegerField(
blank=True,
help_text="Number of inversions (for coasters)",
null=True,
),
),
(
"capacity",
models.IntegerField(
blank=True,
help_text="Hourly capacity (riders per hour)",
null=True,
),
),
(
"image_id",
models.CharField(
blank=True,
help_text="CloudFlare image ID for main photo",
max_length=255,
),
),
(
"image_url",
models.URLField(
blank=True, help_text="CloudFlare image URL for main photo"
),
),
(
"custom_fields",
models.JSONField(
blank=True,
default=dict,
help_text="Additional ride-specific data",
),
),
(
"manufacturer",
models.ForeignKey(
blank=True,
help_text="Ride manufacturer",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="manufactured_rides",
to="entities.company",
),
),
(
"model",
models.ForeignKey(
blank=True,
help_text="Specific ride model",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides",
to="entities.ridemodel",
),
),
(
"park",
models.ForeignKey(
help_text="Park where ride is located",
on_delete=django.db.models.deletion.CASCADE,
related_name="rides",
to="entities.park",
),
),
],
options={
"verbose_name": "Ride",
"verbose_name_plural": "Rides",
"ordering": ["park__name", "name"],
},
bases=(
dirtyfields.dirtyfields.DirtyFieldsMixin,
django_lifecycle.mixins.LifecycleModelMixin,
models.Model,
),
),
migrations.AddIndex(
model_name="ridemodel",
index=models.Index(
fields=["manufacturer", "name"], name="entities_ri_manufac_1fe3c1_idx"
),
),
migrations.AddIndex(
model_name="ridemodel",
index=models.Index(
fields=["model_type"], name="entities_ri_model_t_610d23_idx"
),
),
migrations.AlterUniqueTogether(
name="ridemodel",
unique_together={("manufacturer", "name")},
),
migrations.AddIndex(
model_name="ride",
index=models.Index(
fields=["park", "name"], name="entities_ri_park_id_e73e3b_idx"
),
),
migrations.AddIndex(
model_name="ride",
index=models.Index(fields=["slug"], name="entities_ri_slug_d2d6bb_idx"),
),
migrations.AddIndex(
model_name="ride",
index=models.Index(fields=["status"], name="entities_ri_status_b69114_idx"),
),
migrations.AddIndex(
model_name="ride",
index=models.Index(
fields=["is_coaster"], name="entities_ri_is_coas_912a4d_idx"
),
),
migrations.AddIndex(
model_name="ride",
index=models.Index(
fields=["ride_category"], name="entities_ri_ride_ca_bc4554_idx"
),
),
migrations.AddIndex(
model_name="ride",
index=models.Index(
fields=["opening_date"], name="entities_ri_opening_c4fc53_idx"
),
),
migrations.AddIndex(
model_name="ride",
index=models.Index(
fields=["manufacturer"], name="entities_ri_manufac_0d9a25_idx"
),
),
migrations.AddIndex(
model_name="park",
index=models.Index(fields=["name"], name="entities_pa_name_f8a746_idx"),
),
migrations.AddIndex(
model_name="park",
index=models.Index(fields=["slug"], name="entities_pa_slug_a21c73_idx"),
),
migrations.AddIndex(
model_name="park",
index=models.Index(fields=["status"], name="entities_pa_status_805296_idx"),
),
migrations.AddIndex(
model_name="park",
index=models.Index(
fields=["park_type"], name="entities_pa_park_ty_8eba41_idx"
),
),
migrations.AddIndex(
model_name="park",
index=models.Index(
fields=["opening_date"], name="entities_pa_opening_102a60_idx"
),
),
migrations.AddIndex(
model_name="park",
index=models.Index(
fields=["location"], name="entities_pa_locatio_20a884_idx"
),
),
migrations.AddIndex(
model_name="company",
index=models.Index(fields=["name"], name="entities_co_name_d061e8_idx"),
),
migrations.AddIndex(
model_name="company",
index=models.Index(fields=["slug"], name="entities_co_slug_00ae5c_idx"),
),
]

View File

@@ -0,0 +1,701 @@
"""
Entity models for ThrillWiki Django backend.
This module contains the core entity models:
- Company: Manufacturers, operators, designers
- RideModel: Specific ride models from manufacturers
- Park: Theme parks, amusement parks, water parks, FECs
- Ride: Individual rides and roller coasters
"""
from django.db import models
from django.utils.text import slugify
from django_lifecycle import hook, AFTER_CREATE, AFTER_UPDATE, BEFORE_SAVE
from apps.core.models import VersionedModel, BaseModel
class Company(VersionedModel):
"""
Represents a company in the amusement industry.
Can be a manufacturer, operator, designer, or combination.
"""
COMPANY_TYPE_CHOICES = [
('manufacturer', 'Manufacturer'),
('operator', 'Operator'),
('designer', 'Designer'),
('supplier', 'Supplier'),
('contractor', 'Contractor'),
]
# Basic Info
name = models.CharField(
max_length=255,
unique=True,
db_index=True,
help_text="Official company name"
)
slug = models.SlugField(
max_length=255,
unique=True,
db_index=True,
help_text="URL-friendly identifier"
)
description = models.TextField(
blank=True,
help_text="Company description and history"
)
# Company Types (can be multiple)
company_types = models.JSONField(
default=list,
help_text="List of company types (manufacturer, operator, etc.)"
)
# Location
location = models.ForeignKey(
'core.Locality',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='companies',
help_text="Company headquarters location"
)
# Dates with precision tracking
founded_date = models.DateField(
null=True,
blank=True,
help_text="Company founding date"
)
founded_date_precision = models.CharField(
max_length=20,
default='day',
choices=[
('year', 'Year'),
('month', 'Month'),
('day', 'Day'),
],
help_text="Precision of founded date"
)
closed_date = models.DateField(
null=True,
blank=True,
help_text="Company closure date (if applicable)"
)
closed_date_precision = models.CharField(
max_length=20,
default='day',
choices=[
('year', 'Year'),
('month', 'Month'),
('day', 'Day'),
],
help_text="Precision of closed date"
)
# External Links
website = models.URLField(
blank=True,
help_text="Official company website"
)
# CloudFlare Images
logo_image_id = models.CharField(
max_length=255,
blank=True,
help_text="CloudFlare image ID for company logo"
)
logo_image_url = models.URLField(
blank=True,
help_text="CloudFlare image URL for company logo"
)
# Cached statistics
park_count = models.IntegerField(
default=0,
help_text="Number of parks operated (for operators)"
)
ride_count = models.IntegerField(
default=0,
help_text="Number of rides manufactured (for manufacturers)"
)
class Meta:
verbose_name = 'Company'
verbose_name_plural = 'Companies'
ordering = ['name']
indexes = [
models.Index(fields=['name']),
models.Index(fields=['slug']),
]
def __str__(self):
return self.name
@hook(BEFORE_SAVE, when='slug', is_now=None)
def auto_generate_slug(self):
"""Auto-generate slug from name if not provided."""
if not self.slug and self.name:
base_slug = slugify(self.name)
slug = base_slug
counter = 1
while Company.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
def update_counts(self):
"""Update cached park and ride counts."""
self.park_count = self.operated_parks.count()
self.ride_count = self.manufactured_rides.count()
self.save(update_fields=['park_count', 'ride_count'])
class RideModel(VersionedModel):
"""
Represents a specific ride model from a manufacturer.
E.g., "B&M Inverted Coaster", "Vekoma Boomerang", "Zamperla Family Gravity Coaster"
"""
MODEL_TYPE_CHOICES = [
('coaster_model', 'Roller Coaster Model'),
('flat_ride_model', 'Flat Ride Model'),
('water_ride_model', 'Water Ride Model'),
('dark_ride_model', 'Dark Ride Model'),
('transport_ride_model', 'Transport Ride Model'),
]
# Basic Info
name = models.CharField(
max_length=255,
db_index=True,
help_text="Model name (e.g., 'Inverted Coaster', 'Boomerang')"
)
slug = models.SlugField(
max_length=255,
unique=True,
db_index=True,
help_text="URL-friendly identifier"
)
description = models.TextField(
blank=True,
help_text="Model description and technical details"
)
# Manufacturer
manufacturer = models.ForeignKey(
'Company',
on_delete=models.CASCADE,
related_name='ride_models',
help_text="Manufacturer of this ride model"
)
# Model Type
model_type = models.CharField(
max_length=50,
choices=MODEL_TYPE_CHOICES,
db_index=True,
help_text="Type of ride model"
)
# Technical Specifications (common to most instances)
typical_height = models.DecimalField(
max_digits=6,
decimal_places=1,
null=True,
blank=True,
help_text="Typical height in feet"
)
typical_speed = models.DecimalField(
max_digits=6,
decimal_places=1,
null=True,
blank=True,
help_text="Typical speed in mph"
)
typical_capacity = models.IntegerField(
null=True,
blank=True,
help_text="Typical hourly capacity"
)
# CloudFlare Images
image_id = models.CharField(
max_length=255,
blank=True,
help_text="CloudFlare image ID"
)
image_url = models.URLField(
blank=True,
help_text="CloudFlare image URL"
)
# Cached statistics
installation_count = models.IntegerField(
default=0,
help_text="Number of installations worldwide"
)
class Meta:
verbose_name = 'Ride Model'
verbose_name_plural = 'Ride Models'
ordering = ['manufacturer__name', 'name']
unique_together = [['manufacturer', 'name']]
indexes = [
models.Index(fields=['manufacturer', 'name']),
models.Index(fields=['model_type']),
]
def __str__(self):
return f"{self.manufacturer.name} {self.name}"
@hook(BEFORE_SAVE, when='slug', is_now=None)
def auto_generate_slug(self):
"""Auto-generate slug from manufacturer and name if not provided."""
if not self.slug and self.manufacturer and self.name:
base_slug = slugify(f"{self.manufacturer.name} {self.name}")
slug = base_slug
counter = 1
while RideModel.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
def update_installation_count(self):
"""Update cached installation count."""
self.installation_count = self.rides.count()
self.save(update_fields=['installation_count'])
class Park(VersionedModel):
"""
Represents an amusement park, theme park, water park, or FEC.
"""
PARK_TYPE_CHOICES = [
('theme_park', 'Theme Park'),
('amusement_park', 'Amusement Park'),
('water_park', 'Water Park'),
('family_entertainment_center', 'Family Entertainment Center'),
('traveling_park', 'Traveling Park'),
('zoo', 'Zoo'),
('aquarium', 'Aquarium'),
]
STATUS_CHOICES = [
('operating', 'Operating'),
('closed', 'Closed'),
('sbno', 'Standing But Not Operating'),
('under_construction', 'Under Construction'),
('planned', 'Planned'),
]
# Basic Info
name = models.CharField(
max_length=255,
db_index=True,
help_text="Official park name"
)
slug = models.SlugField(
max_length=255,
unique=True,
db_index=True,
help_text="URL-friendly identifier"
)
description = models.TextField(
blank=True,
help_text="Park description and history"
)
# Type & Status
park_type = models.CharField(
max_length=50,
choices=PARK_TYPE_CHOICES,
db_index=True,
help_text="Type of park"
)
status = models.CharField(
max_length=50,
choices=STATUS_CHOICES,
default='operating',
db_index=True,
help_text="Current operational status"
)
# Dates with precision tracking
opening_date = models.DateField(
null=True,
blank=True,
db_index=True,
help_text="Park opening date"
)
opening_date_precision = models.CharField(
max_length=20,
default='day',
choices=[
('year', 'Year'),
('month', 'Month'),
('day', 'Day'),
],
help_text="Precision of opening date"
)
closing_date = models.DateField(
null=True,
blank=True,
help_text="Park closing date (if closed)"
)
closing_date_precision = models.CharField(
max_length=20,
default='day',
choices=[
('year', 'Year'),
('month', 'Month'),
('day', 'Day'),
],
help_text="Precision of closing date"
)
# Location
location = models.ForeignKey(
'core.Locality',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='parks',
help_text="Park location"
)
# Precise coordinates for mapping
latitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
help_text="Latitude coordinate"
)
longitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
help_text="Longitude coordinate"
)
# Relationships
operator = models.ForeignKey(
'Company',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='operated_parks',
help_text="Current park operator"
)
# External Links
website = models.URLField(
blank=True,
help_text="Official park website"
)
# CloudFlare Images
banner_image_id = models.CharField(
max_length=255,
blank=True,
help_text="CloudFlare image ID for park banner"
)
banner_image_url = models.URLField(
blank=True,
help_text="CloudFlare image URL for park banner"
)
logo_image_id = models.CharField(
max_length=255,
blank=True,
help_text="CloudFlare image ID for park logo"
)
logo_image_url = models.URLField(
blank=True,
help_text="CloudFlare image URL for park logo"
)
# Cached statistics (for performance)
ride_count = models.IntegerField(
default=0,
help_text="Total number of rides"
)
coaster_count = models.IntegerField(
default=0,
help_text="Number of roller coasters"
)
# Custom fields for flexible data
custom_fields = models.JSONField(
default=dict,
blank=True,
help_text="Additional park-specific data"
)
class Meta:
verbose_name = 'Park'
verbose_name_plural = 'Parks'
ordering = ['name']
indexes = [
models.Index(fields=['name']),
models.Index(fields=['slug']),
models.Index(fields=['status']),
models.Index(fields=['park_type']),
models.Index(fields=['opening_date']),
models.Index(fields=['location']),
]
def __str__(self):
return self.name
@hook(BEFORE_SAVE, when='slug', is_now=None)
def auto_generate_slug(self):
"""Auto-generate slug from name if not provided."""
if not self.slug and self.name:
base_slug = slugify(self.name)
slug = base_slug
counter = 1
while Park.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
def update_counts(self):
"""Update cached ride counts."""
self.ride_count = self.rides.count()
self.coaster_count = self.rides.filter(is_coaster=True).count()
self.save(update_fields=['ride_count', 'coaster_count'])
class Ride(VersionedModel):
"""
Represents an individual ride or roller coaster.
"""
RIDE_CATEGORY_CHOICES = [
('roller_coaster', 'Roller Coaster'),
('flat_ride', 'Flat Ride'),
('water_ride', 'Water Ride'),
('dark_ride', 'Dark Ride'),
('transport_ride', 'Transport Ride'),
('other', 'Other'),
]
STATUS_CHOICES = [
('operating', 'Operating'),
('closed', 'Closed'),
('sbno', 'Standing But Not Operating'),
('relocated', 'Relocated'),
('under_construction', 'Under Construction'),
('planned', 'Planned'),
]
# Basic Info
name = models.CharField(
max_length=255,
db_index=True,
help_text="Ride name"
)
slug = models.SlugField(
max_length=255,
unique=True,
db_index=True,
help_text="URL-friendly identifier"
)
description = models.TextField(
blank=True,
help_text="Ride description and history"
)
# Park Relationship
park = models.ForeignKey(
'Park',
on_delete=models.CASCADE,
related_name='rides',
db_index=True,
help_text="Park where ride is located"
)
# Ride Classification
ride_category = models.CharField(
max_length=50,
choices=RIDE_CATEGORY_CHOICES,
db_index=True,
help_text="Broad ride category"
)
ride_type = models.CharField(
max_length=100,
blank=True,
db_index=True,
help_text="Specific ride type (e.g., 'Inverted Coaster', 'Drop Tower')"
)
# Quick coaster identification
is_coaster = models.BooleanField(
default=False,
db_index=True,
help_text="Is this ride a roller coaster?"
)
# Status
status = models.CharField(
max_length=50,
choices=STATUS_CHOICES,
default='operating',
db_index=True,
help_text="Current operational status"
)
# Dates with precision tracking
opening_date = models.DateField(
null=True,
blank=True,
db_index=True,
help_text="Ride opening date"
)
opening_date_precision = models.CharField(
max_length=20,
default='day',
choices=[
('year', 'Year'),
('month', 'Month'),
('day', 'Day'),
],
help_text="Precision of opening date"
)
closing_date = models.DateField(
null=True,
blank=True,
help_text="Ride closing date (if closed)"
)
closing_date_precision = models.CharField(
max_length=20,
default='day',
choices=[
('year', 'Year'),
('month', 'Month'),
('day', 'Day'),
],
help_text="Precision of closing date"
)
# Manufacturer & Model
manufacturer = models.ForeignKey(
'Company',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='manufactured_rides',
help_text="Ride manufacturer"
)
model = models.ForeignKey(
'RideModel',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='rides',
help_text="Specific ride model"
)
# Statistics
height = models.DecimalField(
max_digits=6,
decimal_places=1,
null=True,
blank=True,
help_text="Height in feet"
)
speed = models.DecimalField(
max_digits=6,
decimal_places=1,
null=True,
blank=True,
help_text="Top speed in mph"
)
length = models.DecimalField(
max_digits=8,
decimal_places=1,
null=True,
blank=True,
help_text="Track/ride length in feet"
)
duration = models.IntegerField(
null=True,
blank=True,
help_text="Ride duration in seconds"
)
inversions = models.IntegerField(
null=True,
blank=True,
help_text="Number of inversions (for coasters)"
)
capacity = models.IntegerField(
null=True,
blank=True,
help_text="Hourly capacity (riders per hour)"
)
# CloudFlare Images
image_id = models.CharField(
max_length=255,
blank=True,
help_text="CloudFlare image ID for main photo"
)
image_url = models.URLField(
blank=True,
help_text="CloudFlare image URL for main photo"
)
# Custom fields for flexible data
custom_fields = models.JSONField(
default=dict,
blank=True,
help_text="Additional ride-specific data"
)
class Meta:
verbose_name = 'Ride'
verbose_name_plural = 'Rides'
ordering = ['park__name', 'name']
indexes = [
models.Index(fields=['park', 'name']),
models.Index(fields=['slug']),
models.Index(fields=['status']),
models.Index(fields=['is_coaster']),
models.Index(fields=['ride_category']),
models.Index(fields=['opening_date']),
models.Index(fields=['manufacturer']),
]
def __str__(self):
return f"{self.name} ({self.park.name})"
@hook(BEFORE_SAVE, when='slug', is_now=None)
def auto_generate_slug(self):
"""Auto-generate slug from park and name if not provided."""
if not self.slug and self.park and self.name:
base_slug = slugify(f"{self.park.name} {self.name}")
slug = base_slug
counter = 1
while Ride.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
@hook(BEFORE_SAVE)
def set_is_coaster_flag(self):
"""Auto-set is_coaster flag based on ride_category."""
self.is_coaster = (self.ride_category == 'roller_coaster')
@hook(AFTER_CREATE)
@hook(AFTER_UPDATE, when='park', has_changed=True)
def update_park_counts(self):
"""Update parent park's ride counts when ride is created or moved."""
if self.park:
self.park.update_counts()