Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

View File

View File

@@ -0,0 +1,715 @@
"""
Django Admin configuration for entity models with Unfold theme.
"""
from django.contrib import admin
from django.contrib.gis import admin as gis_admin
from django.db.models import Count, Q
from django.utils.html import format_html
from django.urls import reverse
from django.conf import settings
from unfold.admin import ModelAdmin, TabularInline
from unfold.contrib.filters.admin import RangeDateFilter, RangeNumericFilter, RelatedDropdownFilter, ChoicesDropdownFilter
from unfold.contrib.import_export.forms import ImportForm, ExportForm
from import_export.admin import ImportExportModelAdmin
from import_export import resources, fields
from import_export.widgets import ForeignKeyWidget
from .models import Company, RideModel, Park, Ride, RideNameHistory
from apps.media.admin import PhotoInline
# ============================================================================
# IMPORT/EXPORT RESOURCES
# ============================================================================
class CompanyResource(resources.ModelResource):
"""Import/Export resource for Company model."""
class Meta:
model = Company
fields = (
'id', 'name', 'slug', 'description', 'location',
'company_types', 'founded_date', 'founded_date_precision',
'closed_date', 'closed_date_precision', 'website',
'logo_image_url', 'created', 'modified'
)
export_order = fields
class RideModelResource(resources.ModelResource):
"""Import/Export resource for RideModel model."""
manufacturer = fields.Field(
column_name='manufacturer',
attribute='manufacturer',
widget=ForeignKeyWidget(Company, 'name')
)
class Meta:
model = RideModel
fields = (
'id', 'name', 'slug', 'description', 'manufacturer',
'model_type', 'typical_height', 'typical_speed',
'typical_capacity', 'image_url', 'created', 'modified'
)
export_order = fields
class ParkResource(resources.ModelResource):
"""Import/Export resource for Park model."""
operator = fields.Field(
column_name='operator',
attribute='operator',
widget=ForeignKeyWidget(Company, 'name')
)
class Meta:
model = Park
fields = (
'id', 'name', 'slug', 'description', 'park_type', 'status',
'latitude', 'longitude', 'operator', 'opening_date',
'opening_date_precision', 'closing_date', 'closing_date_precision',
'website', 'banner_image_url', 'logo_image_url',
'created', 'modified'
)
export_order = fields
class RideResource(resources.ModelResource):
"""Import/Export resource for Ride model."""
park = fields.Field(
column_name='park',
attribute='park',
widget=ForeignKeyWidget(Park, 'name')
)
manufacturer = fields.Field(
column_name='manufacturer',
attribute='manufacturer',
widget=ForeignKeyWidget(Company, 'name')
)
model = fields.Field(
column_name='model',
attribute='model',
widget=ForeignKeyWidget(RideModel, 'name')
)
class Meta:
model = Ride
fields = (
'id', 'name', 'slug', 'description', 'park', 'ride_category',
'ride_type', 'status', 'manufacturer', 'model', 'height',
'speed', 'length', 'duration', 'inversions', 'capacity',
'opening_date', 'opening_date_precision', 'closing_date',
'closing_date_precision', 'image_url', 'created', 'modified'
)
export_order = fields
# ============================================================================
# INLINE ADMIN CLASSES
# ============================================================================
class RideInline(TabularInline):
"""Inline for Rides within a Park."""
model = Ride
extra = 0
fields = ['name', 'ride_category', 'status', 'manufacturer', 'opening_date']
readonly_fields = ['name']
show_change_link = True
classes = ['collapse']
def has_add_permission(self, request, obj=None):
return False
class CompanyParksInline(TabularInline):
"""Inline for Parks operated by a Company."""
model = Park
fk_name = 'operator'
extra = 0
fields = ['name', 'park_type', 'status', 'ride_count', 'opening_date']
readonly_fields = ['name', 'ride_count']
show_change_link = True
classes = ['collapse']
def has_add_permission(self, request, obj=None):
return False
class RideModelInstallationsInline(TabularInline):
"""Inline for Ride installations of a RideModel."""
model = Ride
fk_name = 'model'
extra = 0
fields = ['name', 'park', 'status', 'opening_date']
readonly_fields = ['name', 'park']
show_change_link = True
classes = ['collapse']
def has_add_permission(self, request, obj=None):
return False
class RideNameHistoryInline(TabularInline):
"""Inline for Ride Name History within a Ride."""
model = RideNameHistory
extra = 1
fields = ['former_name', 'from_year', 'to_year', 'date_changed', 'reason', 'order_index']
classes = ['collapse']
# ============================================================================
# MAIN ADMIN CLASSES
# ============================================================================
@admin.register(Company)
class CompanyAdmin(ModelAdmin, ImportExportModelAdmin):
"""Enhanced admin interface for Company model."""
resource_class = CompanyResource
import_form_class = ImportForm
export_form_class = ExportForm
list_display = [
'name_with_icon',
'location',
'company_types_display',
'park_count',
'ride_count',
'founded_date',
'status_indicator',
'created'
]
list_filter = [
('company_types', ChoicesDropdownFilter),
('founded_date', RangeDateFilter),
('closed_date', RangeDateFilter),
]
search_fields = ['name', 'slug', 'description', 'location']
readonly_fields = ['id', 'created', 'modified', 'park_count', 'ride_count', 'slug']
prepopulated_fields = {} # Slug is auto-generated via lifecycle hook
autocomplete_fields = []
inlines = [CompanyParksInline, PhotoInline]
list_per_page = 50
list_max_show_all = 200
fieldsets = (
('Basic Information', {
'fields': ('name', 'slug', 'description', 'company_types')
}),
('Location & Contact', {
'fields': ('location', 'website')
}),
('History', {
'fields': (
'founded_date', 'founded_date_precision',
'closed_date', 'closed_date_precision'
)
}),
('Media', {
'fields': ('logo_image_id', 'logo_image_url'),
'classes': ['collapse']
}),
('Statistics', {
'fields': ('park_count', 'ride_count'),
'classes': ['collapse']
}),
('System Information', {
'fields': ('id', 'created', 'modified'),
'classes': ['collapse']
}),
)
def name_with_icon(self, obj):
"""Display name with company type icon."""
icons = {
'manufacturer': '🏭',
'operator': '🎡',
'designer': '✏️',
}
icon = '🏢' # Default company icon
if obj.company_types:
for ctype in obj.company_types:
if ctype in icons:
icon = icons[ctype]
break
return format_html('{} {}', icon, obj.name)
name_with_icon.short_description = 'Company'
name_with_icon.admin_order_field = 'name'
def company_types_display(self, obj):
"""Display company types as badges."""
if not obj.company_types:
return '-'
badges = []
for ctype in obj.company_types:
color = {
'manufacturer': 'blue',
'operator': 'green',
'designer': 'purple',
}.get(ctype, 'gray')
badges.append(
f'<span style="background-color: {color}; color: white; '
f'padding: 2px 8px; border-radius: 4px; font-size: 11px; '
f'margin-right: 4px;">{ctype.upper()}</span>'
)
return format_html(' '.join(badges))
company_types_display.short_description = 'Types'
def status_indicator(self, obj):
"""Visual status indicator."""
if obj.closed_date:
return format_html(
'<span style="color: red;">●</span> Closed'
)
return format_html(
'<span style="color: green;">●</span> Active'
)
status_indicator.short_description = 'Status'
actions = ['export_admin_action']
@admin.register(RideModel)
class RideModelAdmin(ModelAdmin, ImportExportModelAdmin):
"""Enhanced admin interface for RideModel model."""
resource_class = RideModelResource
import_form_class = ImportForm
export_form_class = ExportForm
list_display = [
'name_with_type',
'manufacturer',
'model_type',
'typical_specs',
'installation_count',
'created'
]
list_filter = [
('model_type', ChoicesDropdownFilter),
('manufacturer', RelatedDropdownFilter),
('typical_height', RangeNumericFilter),
('typical_speed', RangeNumericFilter),
]
search_fields = ['name', 'slug', 'description', 'manufacturer__name']
readonly_fields = ['id', 'created', 'modified', 'installation_count', 'slug']
prepopulated_fields = {}
autocomplete_fields = ['manufacturer']
inlines = [RideModelInstallationsInline, PhotoInline]
list_per_page = 50
fieldsets = (
('Basic Information', {
'fields': ('name', 'slug', 'description', 'manufacturer', 'model_type')
}),
('Typical Specifications', {
'fields': (
'typical_height', 'typical_speed', 'typical_capacity'
),
'description': 'Standard specifications for this ride model'
}),
('Media', {
'fields': ('image_id', 'image_url'),
'classes': ['collapse']
}),
('Statistics', {
'fields': ('installation_count',),
'classes': ['collapse']
}),
('System Information', {
'fields': ('id', 'created', 'modified'),
'classes': ['collapse']
}),
)
def name_with_type(self, obj):
"""Display name with model type icon."""
icons = {
'roller_coaster': '🎢',
'water_ride': '🌊',
'flat_ride': '🎡',
'dark_ride': '🎭',
'transport': '🚂',
}
icon = icons.get(obj.model_type, '🎪')
return format_html('{} {}', icon, obj.name)
name_with_type.short_description = 'Model Name'
name_with_type.admin_order_field = 'name'
def typical_specs(self, obj):
"""Display typical specifications."""
specs = []
if obj.typical_height:
specs.append(f'H: {obj.typical_height}m')
if obj.typical_speed:
specs.append(f'S: {obj.typical_speed}km/h')
if obj.typical_capacity:
specs.append(f'C: {obj.typical_capacity}')
return ' | '.join(specs) if specs else '-'
typical_specs.short_description = 'Typical Specs'
actions = ['export_admin_action']
@admin.register(Park)
class ParkAdmin(ModelAdmin, ImportExportModelAdmin):
"""Enhanced admin interface for Park model with geographic features."""
resource_class = ParkResource
import_form_class = ImportForm
export_form_class = ExportForm
list_display = [
'name_with_icon',
'location_display',
'park_type',
'status_badge',
'ride_count',
'coaster_count',
'opening_date',
'operator'
]
list_filter = [
('park_type', ChoicesDropdownFilter),
('status', ChoicesDropdownFilter),
('operator', RelatedDropdownFilter),
('opening_date', RangeDateFilter),
('closing_date', RangeDateFilter),
]
search_fields = ['name', 'slug', 'description', 'location']
readonly_fields = [
'id', 'created', 'modified', 'ride_count', 'coaster_count',
'slug', 'coordinates_display'
]
prepopulated_fields = {}
autocomplete_fields = ['operator']
inlines = [RideInline, PhotoInline]
list_per_page = 50
# Use GeoDjango admin for PostGIS mode
if hasattr(settings, 'DATABASES') and 'postgis' in settings.DATABASES['default'].get('ENGINE', ''):
change_form_template = 'gis/admin/change_form.html'
fieldsets = (
('Basic Information', {
'fields': ('name', 'slug', 'description', 'park_type', 'status')
}),
('Geographic Location', {
'fields': ('location', 'latitude', 'longitude', 'coordinates_display'),
'description': 'Enter latitude and longitude for the park location'
}),
('Dates', {
'fields': (
'opening_date', 'opening_date_precision',
'closing_date', 'closing_date_precision'
)
}),
('Operator', {
'fields': ('operator',)
}),
('Media & Web', {
'fields': (
'banner_image_id', 'banner_image_url',
'logo_image_id', 'logo_image_url',
'website'
),
'classes': ['collapse']
}),
('Statistics', {
'fields': ('ride_count', 'coaster_count'),
'classes': ['collapse']
}),
('Custom Data', {
'fields': ('custom_fields',),
'classes': ['collapse'],
'description': 'Additional custom data in JSON format'
}),
('System Information', {
'fields': ('id', 'created', 'modified'),
'classes': ['collapse']
}),
)
def name_with_icon(self, obj):
"""Display name with park type icon."""
icons = {
'theme_park': '🎡',
'amusement_park': '🎢',
'water_park': '🌊',
'indoor_park': '🏢',
'fairground': '🎪',
}
icon = icons.get(obj.park_type, '🎠')
return format_html('{} {}', icon, obj.name)
name_with_icon.short_description = 'Park Name'
name_with_icon.admin_order_field = 'name'
def location_display(self, obj):
"""Display location with coordinates."""
if obj.location:
coords = obj.coordinates
if coords:
return format_html(
'{}<br><small style="color: gray;">({:.4f}, {:.4f})</small>',
obj.location, coords[0], coords[1]
)
return obj.location
return '-'
location_display.short_description = 'Location'
def coordinates_display(self, obj):
"""Read-only display of coordinates."""
coords = obj.coordinates
if coords:
return f"Longitude: {coords[0]:.6f}, Latitude: {coords[1]:.6f}"
return "No coordinates set"
coordinates_display.short_description = 'Current Coordinates'
def status_badge(self, obj):
"""Display status as colored badge."""
colors = {
'operating': 'green',
'closed_temporarily': 'orange',
'closed_permanently': 'red',
'under_construction': 'blue',
'planned': 'purple',
}
color = colors.get(obj.status, 'gray')
return format_html(
'<span style="background-color: {}; color: white; '
'padding: 3px 10px; border-radius: 12px; font-size: 11px;">'
'{}</span>',
color, obj.get_status_display()
)
status_badge.short_description = 'Status'
status_badge.admin_order_field = 'status'
actions = ['export_admin_action', 'activate_parks', 'close_parks']
def activate_parks(self, request, queryset):
"""Bulk action to activate parks."""
updated = queryset.update(status='operating')
self.message_user(request, f'{updated} park(s) marked as operating.')
activate_parks.short_description = 'Mark selected parks as operating'
def close_parks(self, request, queryset):
"""Bulk action to close parks temporarily."""
updated = queryset.update(status='closed_temporarily')
self.message_user(request, f'{updated} park(s) marked as temporarily closed.')
close_parks.short_description = 'Mark selected parks as temporarily closed'
@admin.register(Ride)
class RideAdmin(ModelAdmin, ImportExportModelAdmin):
"""Enhanced admin interface for Ride model."""
resource_class = RideResource
import_form_class = ImportForm
export_form_class = ExportForm
list_display = [
'name_with_icon',
'park',
'ride_category',
'status_badge',
'manufacturer',
'stats_display',
'opening_date',
'coaster_badge'
]
list_filter = [
('ride_category', ChoicesDropdownFilter),
('status', ChoicesDropdownFilter),
('is_coaster', admin.BooleanFieldListFilter),
('park', RelatedDropdownFilter),
('manufacturer', RelatedDropdownFilter),
('opening_date', RangeDateFilter),
('height', RangeNumericFilter),
('speed', RangeNumericFilter),
]
search_fields = [
'name', 'slug', 'description',
'park__name', 'manufacturer__name'
]
readonly_fields = ['id', 'created', 'modified', 'is_coaster', 'slug']
prepopulated_fields = {}
autocomplete_fields = ['park', 'manufacturer', 'model']
inlines = [RideNameHistoryInline, PhotoInline]
list_per_page = 50
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 & Model', {
'fields': ('manufacturer', 'model')
}),
('Ride Statistics', {
'fields': (
'height', 'speed', 'length',
'duration', 'inversions', 'capacity'
),
'description': 'Technical specifications and statistics'
}),
('Media', {
'fields': ('image_id', 'image_url'),
'classes': ['collapse']
}),
('Custom Data', {
'fields': ('custom_fields',),
'classes': ['collapse']
}),
('System Information', {
'fields': ('id', 'created', 'modified'),
'classes': ['collapse']
}),
)
def name_with_icon(self, obj):
"""Display name with category icon."""
icons = {
'roller_coaster': '🎢',
'water_ride': '🌊',
'dark_ride': '🎭',
'flat_ride': '🎡',
'transport': '🚂',
'show': '🎪',
}
icon = icons.get(obj.ride_category, '🎠')
return format_html('{} {}', icon, obj.name)
name_with_icon.short_description = 'Ride Name'
name_with_icon.admin_order_field = 'name'
def stats_display(self, obj):
"""Display key statistics."""
stats = []
if obj.height:
stats.append(f'H: {obj.height}m')
if obj.speed:
stats.append(f'S: {obj.speed}km/h')
if obj.inversions:
stats.append(f'🔄 {obj.inversions}')
return ' | '.join(stats) if stats else '-'
stats_display.short_description = 'Key Stats'
def coaster_badge(self, obj):
"""Display coaster indicator."""
if obj.is_coaster:
return format_html(
'<span style="background-color: #ff6b6b; color: white; '
'padding: 2px 8px; border-radius: 10px; font-size: 10px;">'
'🎢 COASTER</span>'
)
return ''
coaster_badge.short_description = 'Type'
def status_badge(self, obj):
"""Display status as colored badge."""
colors = {
'operating': 'green',
'closed_temporarily': 'orange',
'closed_permanently': 'red',
'under_construction': 'blue',
'sbno': 'gray',
}
color = colors.get(obj.status, 'gray')
return format_html(
'<span style="background-color: {}; color: white; '
'padding: 3px 10px; border-radius: 12px; font-size: 11px;">'
'{}</span>',
color, obj.get_status_display()
)
status_badge.short_description = 'Status'
status_badge.admin_order_field = 'status'
actions = ['export_admin_action', 'activate_rides', 'close_rides']
def activate_rides(self, request, queryset):
"""Bulk action to activate rides."""
updated = queryset.update(status='operating')
self.message_user(request, f'{updated} ride(s) marked as operating.')
activate_rides.short_description = 'Mark selected rides as operating'
def close_rides(self, request, queryset):
"""Bulk action to close rides temporarily."""
updated = queryset.update(status='closed_temporarily')
self.message_user(request, f'{updated} ride(s) marked as temporarily closed.')
close_rides.short_description = 'Mark selected rides as temporarily closed'
# ============================================================================
# DASHBOARD CALLBACK
# ============================================================================
def dashboard_callback(request, context):
"""
Callback function for Unfold dashboard.
Provides statistics and overview data.
"""
# Entity counts
total_parks = Park.objects.count()
total_rides = Ride.objects.count()
total_companies = Company.objects.count()
total_models = RideModel.objects.count()
# Operating counts
operating_parks = Park.objects.filter(status='operating').count()
operating_rides = Ride.objects.filter(status='operating').count()
# Coaster count
total_coasters = Ride.objects.filter(is_coaster=True).count()
# Recent additions (last 30 days)
from django.utils import timezone
from datetime import timedelta
thirty_days_ago = timezone.now() - timedelta(days=30)
recent_parks = Park.objects.filter(created__gte=thirty_days_ago).count()
recent_rides = Ride.objects.filter(created__gte=thirty_days_ago).count()
# Top manufacturers by ride count
top_manufacturers = Company.objects.filter(
company_types__contains=['manufacturer']
).annotate(
ride_count_actual=Count('manufactured_rides')
).order_by('-ride_count_actual')[:5]
# Parks by type
parks_by_type = Park.objects.values('park_type').annotate(
count=Count('id')
).order_by('-count')
context.update({
'total_parks': total_parks,
'total_rides': total_rides,
'total_companies': total_companies,
'total_models': total_models,
'operating_parks': operating_parks,
'operating_rides': operating_rides,
'total_coasters': total_coasters,
'recent_parks': recent_parks,
'recent_rides': recent_rides,
'top_manufacturers': top_manufacturers,
'parks_by_type': parks_by_type,
})
return context

View File

@@ -0,0 +1,15 @@
"""
Entities app configuration.
"""
from django.apps import AppConfig
class EntitiesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.entities'
verbose_name = 'Entities'
def ready(self):
"""Import signal handlers when app is ready."""
import apps.entities.signals # noqa

View File

@@ -0,0 +1,418 @@
"""
Filter classes for advanced entity filtering.
Provides reusable filter logic for complex queries.
"""
from typing import Optional, Any, Dict
from datetime import date
from django.db.models import QuerySet, Q
from django.conf import settings
# Check if using PostGIS for location-based filtering
_using_postgis = 'postgis' in settings.DATABASES['default']['ENGINE']
if _using_postgis:
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
class BaseEntityFilter:
"""Base filter class with common filtering methods."""
@staticmethod
def filter_by_date_range(
queryset: QuerySet,
field_name: str,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> QuerySet:
"""
Filter by date range.
Args:
queryset: Base queryset to filter
field_name: Name of the date field
start_date: Start of date range (inclusive)
end_date: End of date range (inclusive)
Returns:
Filtered queryset
"""
if start_date:
queryset = queryset.filter(**{f"{field_name}__gte": start_date})
if end_date:
queryset = queryset.filter(**{f"{field_name}__lte": end_date})
return queryset
@staticmethod
def filter_by_status(
queryset: QuerySet,
status: Optional[str] = None,
exclude_status: Optional[list] = None
) -> QuerySet:
"""
Filter by status.
Args:
queryset: Base queryset to filter
status: Single status to filter by
exclude_status: List of statuses to exclude
Returns:
Filtered queryset
"""
if status:
queryset = queryset.filter(status=status)
if exclude_status:
queryset = queryset.exclude(status__in=exclude_status)
return queryset
class CompanyFilter(BaseEntityFilter):
"""Filter class for Company entities."""
@staticmethod
def filter_by_types(
queryset: QuerySet,
company_types: Optional[list] = None
) -> QuerySet:
"""
Filter companies by type.
Args:
queryset: Base queryset to filter
company_types: List of company types to filter by
Returns:
Filtered queryset
"""
if company_types:
# Since company_types is a JSONField containing a list,
# we need to check if any of the requested types are in the field
q = Q()
for company_type in company_types:
q |= Q(company_types__contains=[company_type])
queryset = queryset.filter(q)
return queryset
@staticmethod
def apply_filters(
queryset: QuerySet,
filters: Dict[str, Any]
) -> QuerySet:
"""
Apply all company filters.
Args:
queryset: Base queryset to filter
filters: Dictionary of filter parameters
Returns:
Filtered queryset
"""
# Company types
if filters.get('company_types'):
queryset = CompanyFilter.filter_by_types(
queryset,
company_types=filters['company_types']
)
# Founded date range
queryset = CompanyFilter.filter_by_date_range(
queryset,
'founded_date',
start_date=filters.get('founded_after'),
end_date=filters.get('founded_before')
)
# Closed date range
queryset = CompanyFilter.filter_by_date_range(
queryset,
'closed_date',
start_date=filters.get('closed_after'),
end_date=filters.get('closed_before')
)
# Location
if filters.get('location_id'):
queryset = queryset.filter(location_id=filters['location_id'])
return queryset
class RideModelFilter(BaseEntityFilter):
"""Filter class for RideModel entities."""
@staticmethod
def apply_filters(
queryset: QuerySet,
filters: Dict[str, Any]
) -> QuerySet:
"""
Apply all ride model filters.
Args:
queryset: Base queryset to filter
filters: Dictionary of filter parameters
Returns:
Filtered queryset
"""
# Manufacturer
if filters.get('manufacturer_id'):
queryset = queryset.filter(manufacturer_id=filters['manufacturer_id'])
# Model type
if filters.get('model_type'):
queryset = queryset.filter(model_type=filters['model_type'])
# Height range
if filters.get('min_height'):
queryset = queryset.filter(typical_height__gte=filters['min_height'])
if filters.get('max_height'):
queryset = queryset.filter(typical_height__lte=filters['max_height'])
# Speed range
if filters.get('min_speed'):
queryset = queryset.filter(typical_speed__gte=filters['min_speed'])
if filters.get('max_speed'):
queryset = queryset.filter(typical_speed__lte=filters['max_speed'])
return queryset
class ParkFilter(BaseEntityFilter):
"""Filter class for Park entities."""
@staticmethod
def filter_by_location(
queryset: QuerySet,
longitude: float,
latitude: float,
radius_km: float
) -> QuerySet:
"""
Filter parks by proximity to a location (PostGIS only).
Args:
queryset: Base queryset to filter
longitude: Longitude coordinate
latitude: Latitude coordinate
radius_km: Search radius in kilometers
Returns:
Filtered queryset ordered by distance
"""
if not _using_postgis:
# Fallback: No spatial filtering in SQLite
return queryset
point = Point(longitude, latitude, srid=4326)
# Filter by distance and annotate with distance
queryset = queryset.filter(
location_point__distance_lte=(point, D(km=radius_km))
)
# This will be ordered by distance in the search service
return queryset
@staticmethod
def apply_filters(
queryset: QuerySet,
filters: Dict[str, Any]
) -> QuerySet:
"""
Apply all park filters.
Args:
queryset: Base queryset to filter
filters: Dictionary of filter parameters
Returns:
Filtered queryset
"""
# Status
queryset = ParkFilter.filter_by_status(
queryset,
status=filters.get('status'),
exclude_status=filters.get('exclude_status')
)
# Park type
if filters.get('park_type'):
queryset = queryset.filter(park_type=filters['park_type'])
# Operator
if filters.get('operator_id'):
queryset = queryset.filter(operator_id=filters['operator_id'])
# Opening date range
queryset = ParkFilter.filter_by_date_range(
queryset,
'opening_date',
start_date=filters.get('opening_after'),
end_date=filters.get('opening_before')
)
# Closing date range
queryset = ParkFilter.filter_by_date_range(
queryset,
'closing_date',
start_date=filters.get('closing_after'),
end_date=filters.get('closing_before')
)
# Location-based filtering (PostGIS only)
if _using_postgis and filters.get('location') and filters.get('radius'):
longitude, latitude = filters['location']
queryset = ParkFilter.filter_by_location(
queryset,
longitude=longitude,
latitude=latitude,
radius_km=filters['radius']
)
# Location (locality)
if filters.get('location_id'):
queryset = queryset.filter(location_id=filters['location_id'])
# Ride counts
if filters.get('min_ride_count'):
queryset = queryset.filter(ride_count__gte=filters['min_ride_count'])
if filters.get('min_coaster_count'):
queryset = queryset.filter(coaster_count__gte=filters['min_coaster_count'])
return queryset
class RideFilter(BaseEntityFilter):
"""Filter class for Ride entities."""
@staticmethod
def filter_by_statistics(
queryset: QuerySet,
filters: Dict[str, Any]
) -> QuerySet:
"""
Filter rides by statistical attributes (height, speed, length, etc.).
Args:
queryset: Base queryset to filter
filters: Dictionary of filter parameters
Returns:
Filtered queryset
"""
# Height range
if filters.get('min_height'):
queryset = queryset.filter(height__gte=filters['min_height'])
if filters.get('max_height'):
queryset = queryset.filter(height__lte=filters['max_height'])
# Speed range
if filters.get('min_speed'):
queryset = queryset.filter(speed__gte=filters['min_speed'])
if filters.get('max_speed'):
queryset = queryset.filter(speed__lte=filters['max_speed'])
# Length range
if filters.get('min_length'):
queryset = queryset.filter(length__gte=filters['min_length'])
if filters.get('max_length'):
queryset = queryset.filter(length__lte=filters['max_length'])
# Duration range
if filters.get('min_duration'):
queryset = queryset.filter(duration__gte=filters['min_duration'])
if filters.get('max_duration'):
queryset = queryset.filter(duration__lte=filters['max_duration'])
# Inversions
if filters.get('min_inversions') is not None:
queryset = queryset.filter(inversions__gte=filters['min_inversions'])
if filters.get('max_inversions') is not None:
queryset = queryset.filter(inversions__lte=filters['max_inversions'])
return queryset
@staticmethod
def apply_filters(
queryset: QuerySet,
filters: Dict[str, Any]
) -> QuerySet:
"""
Apply all ride filters.
Args:
queryset: Base queryset to filter
filters: Dictionary of filter parameters
Returns:
Filtered queryset
"""
# Park
if filters.get('park_id'):
queryset = queryset.filter(park_id=filters['park_id'])
# Manufacturer
if filters.get('manufacturer_id'):
queryset = queryset.filter(manufacturer_id=filters['manufacturer_id'])
# Model
if filters.get('model_id'):
queryset = queryset.filter(model_id=filters['model_id'])
# Status
queryset = RideFilter.filter_by_status(
queryset,
status=filters.get('status'),
exclude_status=filters.get('exclude_status')
)
# Ride category
if filters.get('ride_category'):
queryset = queryset.filter(ride_category=filters['ride_category'])
# Ride type
if filters.get('ride_type'):
queryset = queryset.filter(ride_type__icontains=filters['ride_type'])
# Is coaster
if filters.get('is_coaster') is not None:
queryset = queryset.filter(is_coaster=filters['is_coaster'])
# Opening date range
queryset = RideFilter.filter_by_date_range(
queryset,
'opening_date',
start_date=filters.get('opening_after'),
end_date=filters.get('opening_before')
)
# Closing date range
queryset = RideFilter.filter_by_date_range(
queryset,
'closing_date',
start_date=filters.get('closing_after'),
end_date=filters.get('closing_before')
)
# Statistical filters
queryset = RideFilter.filter_by_statistics(queryset, filters)
return queryset

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,35 @@
# Generated by Django 4.2.8 on 2025-11-08 17:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("entities", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="park",
name="latitude",
field=models.DecimalField(
blank=True,
decimal_places=7,
help_text="Latitude coordinate. Primary in local dev, use location_point in production.",
max_digits=10,
null=True,
),
),
migrations.AlterField(
model_name="park",
name="longitude",
field=models.DecimalField(
blank=True,
decimal_places=7,
help_text="Longitude coordinate. Primary in local dev, use location_point in production.",
max_digits=10,
null=True,
),
),
]

View File

@@ -0,0 +1,141 @@
# Generated migration for Phase 2 - GIN Index Optimization
from django.db import migrations, connection
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector
def is_postgresql():
"""Check if the database backend is PostgreSQL/PostGIS."""
return 'postgis' in connection.vendor or 'postgresql' in connection.vendor
def populate_search_vectors(apps, schema_editor):
"""Populate search_vector fields for all existing records."""
if not is_postgresql():
return
# Get models
Company = apps.get_model('entities', 'Company')
RideModel = apps.get_model('entities', 'RideModel')
Park = apps.get_model('entities', 'Park')
Ride = apps.get_model('entities', 'Ride')
# Update Company search vectors
Company.objects.update(
search_vector=(
SearchVector('name', weight='A') +
SearchVector('description', weight='B')
)
)
# Update RideModel search vectors
RideModel.objects.update(
search_vector=(
SearchVector('name', weight='A') +
SearchVector('manufacturer__name', weight='A') +
SearchVector('description', weight='B')
)
)
# Update Park search vectors
Park.objects.update(
search_vector=(
SearchVector('name', weight='A') +
SearchVector('description', weight='B')
)
)
# Update Ride search vectors
Ride.objects.update(
search_vector=(
SearchVector('name', weight='A') +
SearchVector('park__name', weight='A') +
SearchVector('manufacturer__name', weight='B') +
SearchVector('description', weight='B')
)
)
def reverse_search_vectors(apps, schema_editor):
"""Clear search_vector fields for all records."""
if not is_postgresql():
return
# Get models
Company = apps.get_model('entities', 'Company')
RideModel = apps.get_model('entities', 'RideModel')
Park = apps.get_model('entities', 'Park')
Ride = apps.get_model('entities', 'Ride')
# Clear all search vectors
Company.objects.update(search_vector=None)
RideModel.objects.update(search_vector=None)
Park.objects.update(search_vector=None)
Ride.objects.update(search_vector=None)
def add_gin_indexes(apps, schema_editor):
"""Add GIN indexes on search_vector fields (PostgreSQL only)."""
if not is_postgresql():
return
# Use raw SQL to add GIN indexes
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
CREATE INDEX IF NOT EXISTS entities_company_search_idx
ON entities_company USING gin(search_vector);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS entities_ridemodel_search_idx
ON entities_ridemodel USING gin(search_vector);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS entities_park_search_idx
ON entities_park USING gin(search_vector);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS entities_ride_search_idx
ON entities_ride USING gin(search_vector);
""")
def remove_gin_indexes(apps, schema_editor):
"""Remove GIN indexes (PostgreSQL only)."""
if not is_postgresql():
return
# Use raw SQL to drop GIN indexes
with schema_editor.connection.cursor() as cursor:
cursor.execute("DROP INDEX IF EXISTS entities_company_search_idx;")
cursor.execute("DROP INDEX IF EXISTS entities_ridemodel_search_idx;")
cursor.execute("DROP INDEX IF EXISTS entities_park_search_idx;")
cursor.execute("DROP INDEX IF EXISTS entities_ride_search_idx;")
class Migration(migrations.Migration):
"""
Phase 2 Migration: Add GIN indexes for search optimization.
This migration:
1. Adds GIN indexes on search_vector fields for optimal full-text search
2. Populates search vectors for all existing database records
3. Is PostgreSQL-specific and safe for SQLite environments
"""
dependencies = [
('entities', '0002_alter_park_latitude_alter_park_longitude'),
]
operations = [
# First, populate search vectors for existing records
migrations.RunPython(
populate_search_vectors,
reverse_search_vectors,
),
# Add GIN indexes for each model's search_vector field
migrations.RunPython(
add_gin_indexes,
remove_gin_indexes,
),
]

View File

@@ -0,0 +1,936 @@
# Generated by Django 4.2.8 on 2025-11-08 21:37
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import pgtrigger.compiler
import pgtrigger.migrations
import uuid
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
("pghistory", "0006_delete_aggregateevent"),
("entities", "0003_add_search_vector_gin_indexes"),
]
operations = [
migrations.CreateModel(
name="CompanyEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
(
"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, serialize=False
),
),
(
"name",
models.CharField(help_text="Official company name", max_length=255),
),
(
"slug",
models.SlugField(
db_index=False,
help_text="URL-friendly identifier",
max_length=255,
),
),
(
"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)",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="ParkEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
(
"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, serialize=False
),
),
(
"name",
models.CharField(help_text="Official park name", max_length=255),
),
(
"slug",
models.SlugField(
db_index=False,
help_text="URL-friendly identifier",
max_length=255,
),
),
(
"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"),
],
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"),
],
default="operating",
help_text="Current operational status",
max_length=50,
),
),
(
"opening_date",
models.DateField(
blank=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. Primary in local dev, use location_point in production.",
max_digits=10,
null=True,
),
),
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=7,
help_text="Longitude coordinate. Primary in local dev, use location_point in production.",
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",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RideEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
(
"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, serialize=False
),
),
("name", models.CharField(help_text="Ride name", max_length=255)),
(
"slug",
models.SlugField(
db_index=False,
help_text="URL-friendly identifier",
max_length=255,
),
),
(
"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"),
],
help_text="Broad ride category",
max_length=50,
),
),
(
"ride_type",
models.CharField(
blank=True,
help_text="Specific ride type (e.g., 'Inverted Coaster', 'Drop Tower')",
max_length=100,
),
),
(
"is_coaster",
models.BooleanField(
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"),
],
default="operating",
help_text="Current operational status",
max_length=50,
),
),
(
"opening_date",
models.DateField(
blank=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",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RideModelEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
(
"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, serialize=False
),
),
(
"name",
models.CharField(
help_text="Model name (e.g., 'Inverted Coaster', 'Boomerang')",
max_length=255,
),
),
(
"slug",
models.SlugField(
db_index=False,
help_text="URL-friendly identifier",
max_length=255,
),
),
(
"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"),
],
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"
),
),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "entities_companyevent" ("closed_date", "closed_date_precision", "company_types", "created", "description", "founded_date", "founded_date_precision", "id", "location_id", "logo_image_id", "logo_image_url", "modified", "name", "park_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "slug", "website") VALUES (NEW."closed_date", NEW."closed_date_precision", NEW."company_types", NEW."created", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."id", NEW."location_id", NEW."logo_image_id", NEW."logo_image_url", NEW."modified", NEW."name", NEW."park_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_count", NEW."slug", NEW."website"); RETURN NULL;',
hash="891243f1479adc9ae67c894ec6824b89b7997086",
operation="INSERT",
pgid="pgtrigger_insert_insert_ed498",
table="entities_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "entities_companyevent" ("closed_date", "closed_date_precision", "company_types", "created", "description", "founded_date", "founded_date_precision", "id", "location_id", "logo_image_id", "logo_image_url", "modified", "name", "park_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "slug", "website") VALUES (NEW."closed_date", NEW."closed_date_precision", NEW."company_types", NEW."created", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."id", NEW."location_id", NEW."logo_image_id", NEW."logo_image_url", NEW."modified", NEW."name", NEW."park_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_count", NEW."slug", NEW."website"); RETURN NULL;',
hash="5d0f3d8dbb199afd7474de393b075b8e72c481fd",
operation="UPDATE",
pgid="pgtrigger_update_update_2d89e",
table="entities_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "entities_parkevent" ("banner_image_id", "banner_image_url", "closing_date", "closing_date_precision", "coaster_count", "created", "custom_fields", "description", "id", "latitude", "location_id", "logo_image_id", "logo_image_url", "longitude", "modified", "name", "opening_date", "opening_date_precision", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "slug", "status", "website") VALUES (NEW."banner_image_id", NEW."banner_image_url", NEW."closing_date", NEW."closing_date_precision", NEW."coaster_count", NEW."created", NEW."custom_fields", NEW."description", NEW."id", NEW."latitude", NEW."location_id", NEW."logo_image_id", NEW."logo_image_url", NEW."longitude", NEW."modified", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_count", NEW."slug", NEW."status", NEW."website"); RETURN NULL;',
hash="e03ce2a0516ff75f1703a6ccf069ce931f3123bc",
operation="INSERT",
pgid="pgtrigger_insert_insert_a5515",
table="entities_park",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "entities_parkevent" ("banner_image_id", "banner_image_url", "closing_date", "closing_date_precision", "coaster_count", "created", "custom_fields", "description", "id", "latitude", "location_id", "logo_image_id", "logo_image_url", "longitude", "modified", "name", "opening_date", "opening_date_precision", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "slug", "status", "website") VALUES (NEW."banner_image_id", NEW."banner_image_url", NEW."closing_date", NEW."closing_date_precision", NEW."coaster_count", NEW."created", NEW."custom_fields", NEW."description", NEW."id", NEW."latitude", NEW."location_id", NEW."logo_image_id", NEW."logo_image_url", NEW."longitude", NEW."modified", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_count", NEW."slug", NEW."status", NEW."website"); RETURN NULL;',
hash="0e01b4eac8ef56aeb039c870c7ac194d2615012e",
operation="UPDATE",
pgid="pgtrigger_update_update_b436a",
table="entities_park",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "entities_rideevent" ("capacity", "closing_date", "closing_date_precision", "created", "custom_fields", "description", "duration", "height", "id", "image_id", "image_url", "inversions", "is_coaster", "length", "manufacturer_id", "model_id", "modified", "name", "opening_date", "opening_date_precision", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_category", "ride_type", "slug", "speed", "status") VALUES (NEW."capacity", NEW."closing_date", NEW."closing_date_precision", NEW."created", NEW."custom_fields", NEW."description", NEW."duration", NEW."height", NEW."id", NEW."image_id", NEW."image_url", NEW."inversions", NEW."is_coaster", NEW."length", NEW."manufacturer_id", NEW."model_id", NEW."modified", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_category", NEW."ride_type", NEW."slug", NEW."speed", NEW."status"); RETURN NULL;',
hash="02f95397d881bd95627424df1a144956d5f15f8d",
operation="INSERT",
pgid="pgtrigger_insert_insert_23173",
table="entities_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "entities_rideevent" ("capacity", "closing_date", "closing_date_precision", "created", "custom_fields", "description", "duration", "height", "id", "image_id", "image_url", "inversions", "is_coaster", "length", "manufacturer_id", "model_id", "modified", "name", "opening_date", "opening_date_precision", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_category", "ride_type", "slug", "speed", "status") VALUES (NEW."capacity", NEW."closing_date", NEW."closing_date_precision", NEW."created", NEW."custom_fields", NEW."description", NEW."duration", NEW."height", NEW."id", NEW."image_id", NEW."image_url", NEW."inversions", NEW."is_coaster", NEW."length", NEW."manufacturer_id", NEW."model_id", NEW."modified", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_category", NEW."ride_type", NEW."slug", NEW."speed", NEW."status"); RETURN NULL;',
hash="9377ca0c44ec8e548254d371a95e9ff7a6eb8684",
operation="UPDATE",
pgid="pgtrigger_update_update_c2972",
table="entities_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodel",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "entities_ridemodelevent" ("created", "description", "id", "image_id", "image_url", "installation_count", "manufacturer_id", "model_type", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "typical_capacity", "typical_height", "typical_speed") VALUES (NEW."created", NEW."description", NEW."id", NEW."image_id", NEW."image_url", NEW."installation_count", NEW."manufacturer_id", NEW."model_type", NEW."modified", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."typical_capacity", NEW."typical_height", NEW."typical_speed"); RETURN NULL;',
hash="580a9d8a429d5140bc6bf553d6e0f9c06b7a7dec",
operation="INSERT",
pgid="pgtrigger_insert_insert_04de6",
table="entities_ridemodel",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodel",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "entities_ridemodelevent" ("created", "description", "id", "image_id", "image_url", "installation_count", "manufacturer_id", "model_type", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "typical_capacity", "typical_height", "typical_speed") VALUES (NEW."created", NEW."description", NEW."id", NEW."image_id", NEW."image_url", NEW."installation_count", NEW."manufacturer_id", NEW."model_type", NEW."modified", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."typical_capacity", NEW."typical_height", NEW."typical_speed"); RETURN NULL;',
hash="b7d6519a2c97e7b543494b67c4f25826439a02ef",
operation="UPDATE",
pgid="pgtrigger_update_update_a70fd",
table="entities_ridemodel",
when="AFTER",
),
),
),
migrations.AddField(
model_name="ridemodelevent",
name="manufacturer",
field=models.ForeignKey(
db_constraint=False,
help_text="Manufacturer of this ride model",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="entities.company",
),
),
migrations.AddField(
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.AddField(
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="entities.ridemodel",
),
),
migrations.AddField(
model_name="rideevent",
name="manufacturer",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Ride manufacturer",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="entities.company",
),
),
migrations.AddField(
model_name="rideevent",
name="model",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Specific ride model",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="entities.ridemodel",
),
),
migrations.AddField(
model_name="rideevent",
name="park",
field=models.ForeignKey(
db_constraint=False,
help_text="Park where ride is located",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="entities.park",
),
),
migrations.AddField(
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.AddField(
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="entities.ride",
),
),
migrations.AddField(
model_name="parkevent",
name="location",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Park location",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="core.locality",
),
),
migrations.AddField(
model_name="parkevent",
name="operator",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Current park operator",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="entities.company",
),
),
migrations.AddField(
model_name="parkevent",
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.AddField(
model_name="parkevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="entities.park",
),
),
migrations.AddField(
model_name="companyevent",
name="location",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Company headquarters location",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="core.locality",
),
),
migrations.AddField(
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.AddField(
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="entities.company",
),
),
]

View File

@@ -0,0 +1,12 @@
# Generated by Django 4.2.8 on 2025-11-09 03:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("entities", "0004_companyevent_parkevent_rideevent_ridemodelevent_and_more"),
]
operations = []

View File

@@ -0,0 +1,12 @@
# Generated by Django 4.2.8 on 2025-11-09 03:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("entities", "0005_migrate_company_types_to_m2m"),
]
operations = []

View File

@@ -0,0 +1,542 @@
# Generated by Django 4.2.8 on 2025-11-09 15:30
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import django_lifecycle.mixins
import model_utils.fields
import pgtrigger.compiler
import pgtrigger.migrations
import uuid
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0006_delete_aggregateevent"),
("entities", "0006_migrate_company_types_to_m2m"),
]
operations = [
migrations.CreateModel(
name="CompanyType",
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,
),
),
(
"code",
models.CharField(
choices=[
("manufacturer", "Manufacturer"),
("operator", "Operator"),
("designer", "Designer"),
("supplier", "Supplier"),
("contractor", "Contractor"),
],
db_index=True,
help_text="Unique code identifier for the company type",
max_length=50,
unique=True,
),
),
(
"name",
models.CharField(
help_text="Display name for the company type", max_length=100
),
),
(
"description",
models.TextField(
blank=True,
help_text="Description of what this company type represents",
),
),
(
"company_count",
models.IntegerField(
default=0, help_text="Cached count of companies with this type"
),
),
],
options={
"verbose_name": "Company Type",
"verbose_name_plural": "Company Types",
"db_table": "company_types",
"ordering": ["name"],
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
migrations.CreateModel(
name="CompanyTypeEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
(
"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, serialize=False
),
),
(
"code",
models.CharField(
choices=[
("manufacturer", "Manufacturer"),
("operator", "Operator"),
("designer", "Designer"),
("supplier", "Supplier"),
("contractor", "Contractor"),
],
help_text="Unique code identifier for the company type",
max_length=50,
),
),
(
"name",
models.CharField(
help_text="Display name for the company type", max_length=100
),
),
(
"description",
models.TextField(
blank=True,
help_text="Description of what this company type represents",
),
),
(
"company_count",
models.IntegerField(
default=0, help_text="Cached count of companies with this type"
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RideNameHistory",
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,
),
),
(
"former_name",
models.CharField(
db_index=True,
help_text="Previous name of the ride",
max_length=255,
),
),
(
"from_year",
models.IntegerField(
blank=True,
help_text="Year when this name started being used",
null=True,
),
),
(
"to_year",
models.IntegerField(
blank=True,
help_text="Year when this name stopped being used",
null=True,
),
),
(
"date_changed",
models.DateField(
blank=True,
help_text="Exact date when name was changed",
null=True,
),
),
(
"date_changed_precision",
models.CharField(
blank=True,
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
help_text="Precision of date_changed field",
max_length=20,
null=True,
),
),
(
"reason",
models.TextField(
blank=True,
help_text="Reason for name change (e.g., 'Rebranding', 'Sponsor change')",
null=True,
),
),
(
"order_index",
models.IntegerField(
blank=True,
db_index=True,
help_text="Custom sort order for displaying name history",
null=True,
),
),
],
options={
"verbose_name": "Ride Name History",
"verbose_name_plural": "Ride Name Histories",
"ordering": ["ride", "-to_year", "-from_year", "order_index"],
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
migrations.CreateModel(
name="RideNameHistoryEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
(
"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, serialize=False
),
),
(
"former_name",
models.CharField(
help_text="Previous name of the ride", max_length=255
),
),
(
"from_year",
models.IntegerField(
blank=True,
help_text="Year when this name started being used",
null=True,
),
),
(
"to_year",
models.IntegerField(
blank=True,
help_text="Year when this name stopped being used",
null=True,
),
),
(
"date_changed",
models.DateField(
blank=True,
help_text="Exact date when name was changed",
null=True,
),
),
(
"date_changed_precision",
models.CharField(
blank=True,
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
help_text="Precision of date_changed field",
max_length=20,
null=True,
),
),
(
"reason",
models.TextField(
blank=True,
help_text="Reason for name change (e.g., 'Rebranding', 'Sponsor change')",
null=True,
),
),
(
"order_index",
models.IntegerField(
blank=True,
help_text="Custom sort order for displaying name history",
null=True,
),
),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="update_update",
),
migrations.RemoveField(
model_name="company",
name="company_types",
),
migrations.RemoveField(
model_name="companyevent",
name="company_types",
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "entities_companyevent" ("closed_date", "closed_date_precision", "created", "description", "founded_date", "founded_date_precision", "id", "location_id", "logo_image_id", "logo_image_url", "modified", "name", "park_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "slug", "website") VALUES (NEW."closed_date", NEW."closed_date_precision", NEW."created", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."id", NEW."location_id", NEW."logo_image_id", NEW."logo_image_url", NEW."modified", NEW."name", NEW."park_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_count", NEW."slug", NEW."website"); RETURN NULL;',
hash="9d74e2f8c1fd5cb457d1deb6d8bb3b55f690df7a",
operation="INSERT",
pgid="pgtrigger_insert_insert_ed498",
table="entities_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "entities_companyevent" ("closed_date", "closed_date_precision", "created", "description", "founded_date", "founded_date_precision", "id", "location_id", "logo_image_id", "logo_image_url", "modified", "name", "park_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "slug", "website") VALUES (NEW."closed_date", NEW."closed_date_precision", NEW."created", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."id", NEW."location_id", NEW."logo_image_id", NEW."logo_image_url", NEW."modified", NEW."name", NEW."park_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_count", NEW."slug", NEW."website"); RETURN NULL;',
hash="79dd6fed8d6bb8a54dfb0efb1433d93e2c732152",
operation="UPDATE",
pgid="pgtrigger_update_update_2d89e",
table="entities_company",
when="AFTER",
),
),
),
migrations.AddField(
model_name="ridenamehistoryevent",
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.AddField(
model_name="ridenamehistoryevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="entities.ridenamehistory",
),
),
migrations.AddField(
model_name="ridenamehistoryevent",
name="ride",
field=models.ForeignKey(
db_constraint=False,
help_text="Ride this name history belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="entities.ride",
),
),
migrations.AddField(
model_name="ridenamehistory",
name="ride",
field=models.ForeignKey(
help_text="Ride this name history belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="name_history",
to="entities.ride",
),
),
migrations.AddField(
model_name="companytypeevent",
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.AddField(
model_name="companytypeevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
related_query_name="+",
to="entities.companytype",
),
),
pgtrigger.migrations.AddTrigger(
model_name="companytype",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "entities_companytypeevent" ("code", "company_count", "created", "description", "id", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id") VALUES (NEW."code", NEW."company_count", NEW."created", NEW."description", NEW."id", NEW."modified", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id"); RETURN NULL;',
hash="37b8907c9141c73466db70e30a15281129bdb623",
operation="INSERT",
pgid="pgtrigger_insert_insert_c2d35",
table="company_types",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="companytype",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "entities_companytypeevent" ("code", "company_count", "created", "description", "id", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id") VALUES (NEW."code", NEW."company_count", NEW."created", NEW."description", NEW."id", NEW."modified", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id"); RETURN NULL;',
hash="4f168297493a54875233a39c57cb4abd2490c0c0",
operation="UPDATE",
pgid="pgtrigger_update_update_fc3b6",
table="company_types",
when="AFTER",
),
),
),
migrations.AddField(
model_name="company",
name="types",
field=models.ManyToManyField(
blank=True,
help_text="Types of company (manufacturer, operator, etc.)",
related_name="companies",
to="entities.companytype",
),
),
migrations.AddIndex(
model_name="ridenamehistory",
index=models.Index(
fields=["ride", "from_year"], name="entities_ri_ride_id_648621_idx"
),
),
migrations.AddIndex(
model_name="ridenamehistory",
index=models.Index(
fields=["ride", "to_year"], name="entities_ri_ride_id_7cfa50_idx"
),
),
migrations.AddIndex(
model_name="ridenamehistory",
index=models.Index(
fields=["former_name"], name="entities_ri_former__c3173a_idx"
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridenamehistory",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "entities_ridenamehistoryevent" ("created", "date_changed", "date_changed_precision", "former_name", "from_year", "id", "modified", "order_index", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "ride_id", "to_year") VALUES (NEW."created", NEW."date_changed", NEW."date_changed_precision", NEW."former_name", NEW."from_year", NEW."id", NEW."modified", NEW."order_index", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reason", NEW."ride_id", NEW."to_year"); RETURN NULL;',
hash="bba7baecb40457a954159e0d62aa06dc8746fd0c",
operation="INSERT",
pgid="pgtrigger_insert_insert_dd590",
table="entities_ridenamehistory",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridenamehistory",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "entities_ridenamehistoryevent" ("created", "date_changed", "date_changed_precision", "former_name", "from_year", "id", "modified", "order_index", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "ride_id", "to_year") VALUES (NEW."created", NEW."date_changed", NEW."date_changed_precision", NEW."former_name", NEW."from_year", NEW."id", NEW."modified", NEW."order_index", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reason", NEW."ride_id", NEW."to_year"); RETURN NULL;',
hash="bcd9a1ba98897e9e2d89c2056b9922f09a69c447",
operation="UPDATE",
pgid="pgtrigger_update_update_73687",
table="entities_ridenamehistory",
when="AFTER",
),
),
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
"""
Search service for ThrillWiki entities.
Provides full-text search capabilities with PostgreSQL and fallback for SQLite.
- PostgreSQL: Uses SearchVector, SearchQuery, SearchRank for full-text search
- SQLite: Falls back to case-insensitive LIKE queries
"""
from typing import List, Optional, Dict, Any
from django.db.models import Q, QuerySet, Value, CharField, F
from django.db.models.functions import Concat
from django.conf import settings
# Conditionally import PostgreSQL search features
_using_postgis = 'postgis' in settings.DATABASES['default']['ENGINE']
if _using_postgis:
from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank, TrigramSimilarity
from django.contrib.postgres.aggregates import StringAgg
class SearchService:
"""Service for searching across all entity types."""
def __init__(self):
self.using_postgres = _using_postgis
def search_all(
self,
query: str,
entity_types: Optional[List[str]] = None,
limit: int = 20
) -> Dict[str, Any]:
"""
Search across all entity types.
Args:
query: Search query string
entity_types: Optional list to filter by entity types
limit: Maximum results per entity type
Returns:
Dictionary with results grouped by entity type
"""
results = {}
# Default to all entity types if not specified
if not entity_types:
entity_types = ['company', 'ride_model', 'park', 'ride']
if 'company' in entity_types:
results['companies'] = list(self.search_companies(query, limit=limit))
if 'ride_model' in entity_types:
results['ride_models'] = list(self.search_ride_models(query, limit=limit))
if 'park' in entity_types:
results['parks'] = list(self.search_parks(query, limit=limit))
if 'ride' in entity_types:
results['rides'] = list(self.search_rides(query, limit=limit))
return results
def search_companies(
self,
query: str,
filters: Optional[Dict[str, Any]] = None,
limit: int = 20
) -> QuerySet:
"""
Search companies with full-text search.
Args:
query: Search query string
filters: Optional filters (company_types, founded_after, etc.)
limit: Maximum number of results
Returns:
QuerySet of Company objects
"""
from apps.entities.models import Company
if self.using_postgres:
# PostgreSQL full-text search using pre-computed search_vector
search_query = SearchQuery(query, search_type='websearch')
results = Company.objects.annotate(
rank=SearchRank(F('search_vector'), search_query)
).filter(search_vector=search_query).order_by('-rank')
else:
# SQLite fallback using LIKE
results = Company.objects.filter(
Q(name__icontains=query) | Q(description__icontains=query)
).order_by('name')
# Apply additional filters
if filters:
if filters.get('company_types'):
# Filter by company types (stored in JSONField)
results = results.filter(
company_types__contains=filters['company_types']
)
if filters.get('founded_after'):
results = results.filter(founded_date__gte=filters['founded_after'])
if filters.get('founded_before'):
results = results.filter(founded_date__lte=filters['founded_before'])
return results[:limit]
def search_ride_models(
self,
query: str,
filters: Optional[Dict[str, Any]] = None,
limit: int = 20
) -> QuerySet:
"""
Search ride models with full-text search.
Args:
query: Search query string
filters: Optional filters (manufacturer_id, model_type, etc.)
limit: Maximum number of results
Returns:
QuerySet of RideModel objects
"""
from apps.entities.models import RideModel
if self.using_postgres:
# PostgreSQL full-text search using pre-computed search_vector
search_query = SearchQuery(query, search_type='websearch')
results = RideModel.objects.select_related('manufacturer').annotate(
rank=SearchRank(F('search_vector'), search_query)
).filter(search_vector=search_query).order_by('-rank')
else:
# SQLite fallback using LIKE
results = RideModel.objects.select_related('manufacturer').filter(
Q(name__icontains=query) |
Q(manufacturer__name__icontains=query) |
Q(description__icontains=query)
).order_by('manufacturer__name', 'name')
# Apply additional filters
if filters:
if filters.get('manufacturer_id'):
results = results.filter(manufacturer_id=filters['manufacturer_id'])
if filters.get('model_type'):
results = results.filter(model_type=filters['model_type'])
return results[:limit]
def search_parks(
self,
query: str,
filters: Optional[Dict[str, Any]] = None,
limit: int = 20
) -> QuerySet:
"""
Search parks with full-text search and location filtering.
Args:
query: Search query string
filters: Optional filters (status, park_type, location, radius, etc.)
limit: Maximum number of results
Returns:
QuerySet of Park objects
"""
from apps.entities.models import Park
if self.using_postgres:
# PostgreSQL full-text search using pre-computed search_vector
search_query = SearchQuery(query, search_type='websearch')
results = Park.objects.annotate(
rank=SearchRank(F('search_vector'), search_query)
).filter(search_vector=search_query).order_by('-rank')
else:
# SQLite fallback using LIKE
results = Park.objects.filter(
Q(name__icontains=query) | Q(description__icontains=query)
).order_by('name')
# Apply additional filters
if filters:
if filters.get('status'):
results = results.filter(status=filters['status'])
if filters.get('park_type'):
results = results.filter(park_type=filters['park_type'])
if filters.get('operator_id'):
results = results.filter(operator_id=filters['operator_id'])
if filters.get('opening_after'):
results = results.filter(opening_date__gte=filters['opening_after'])
if filters.get('opening_before'):
results = results.filter(opening_date__lte=filters['opening_before'])
# Location-based filtering (PostGIS only)
if self.using_postgres and filters.get('location') and filters.get('radius'):
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
longitude, latitude = filters['location']
point = Point(longitude, latitude, srid=4326)
radius_km = filters['radius']
# Use distance filter
results = results.filter(
location_point__distance_lte=(point, D(km=radius_km))
).annotate(
distance=F('location_point__distance')
).order_by('distance')
return results[:limit]
def search_rides(
self,
query: str,
filters: Optional[Dict[str, Any]] = None,
limit: int = 20
) -> QuerySet:
"""
Search rides with full-text search.
Args:
query: Search query string
filters: Optional filters (park_id, manufacturer_id, status, etc.)
limit: Maximum number of results
Returns:
QuerySet of Ride objects
"""
from apps.entities.models import Ride
if self.using_postgres:
# PostgreSQL full-text search using pre-computed search_vector
search_query = SearchQuery(query, search_type='websearch')
results = Ride.objects.select_related('park', 'manufacturer', 'model').annotate(
rank=SearchRank(F('search_vector'), search_query)
).filter(search_vector=search_query).order_by('-rank')
else:
# SQLite fallback using LIKE
results = Ride.objects.select_related('park', 'manufacturer', 'model').filter(
Q(name__icontains=query) |
Q(park__name__icontains=query) |
Q(manufacturer__name__icontains=query) |
Q(description__icontains=query)
).order_by('park__name', 'name')
# Apply additional filters
if filters:
if filters.get('park_id'):
results = results.filter(park_id=filters['park_id'])
if filters.get('manufacturer_id'):
results = results.filter(manufacturer_id=filters['manufacturer_id'])
if filters.get('model_id'):
results = results.filter(model_id=filters['model_id'])
if filters.get('status'):
results = results.filter(status=filters['status'])
if filters.get('ride_category'):
results = results.filter(ride_category=filters['ride_category'])
if filters.get('is_coaster') is not None:
results = results.filter(is_coaster=filters['is_coaster'])
if filters.get('opening_after'):
results = results.filter(opening_date__gte=filters['opening_after'])
if filters.get('opening_before'):
results = results.filter(opening_date__lte=filters['opening_before'])
# Height/speed filters
if filters.get('min_height'):
results = results.filter(height__gte=filters['min_height'])
if filters.get('max_height'):
results = results.filter(height__lte=filters['max_height'])
if filters.get('min_speed'):
results = results.filter(speed__gte=filters['min_speed'])
if filters.get('max_speed'):
results = results.filter(speed__lte=filters['max_speed'])
return results[:limit]
def autocomplete(
self,
query: str,
entity_type: Optional[str] = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""
Get autocomplete suggestions for search.
Args:
query: Partial search query
entity_type: Optional specific entity type
limit: Maximum number of suggestions
Returns:
List of suggestion dictionaries with name and entity_type
"""
suggestions = []
if not query or len(query) < 2:
return suggestions
# Search in names only for autocomplete
if entity_type == 'company' or not entity_type:
from apps.entities.models import Company
companies = Company.objects.filter(
name__istartswith=query
).values('id', 'name', 'slug')[:limit]
for company in companies:
suggestions.append({
'id': company['id'],
'name': company['name'],
'slug': company['slug'],
'entity_type': 'company'
})
if entity_type == 'park' or not entity_type:
from apps.entities.models import Park
parks = Park.objects.filter(
name__istartswith=query
).values('id', 'name', 'slug')[:limit]
for park in parks:
suggestions.append({
'id': park['id'],
'name': park['name'],
'slug': park['slug'],
'entity_type': 'park'
})
if entity_type == 'ride' or not entity_type:
from apps.entities.models import Ride
rides = Ride.objects.select_related('park').filter(
name__istartswith=query
).values('id', 'name', 'slug', 'park__name')[:limit]
for ride in rides:
suggestions.append({
'id': ride['id'],
'name': ride['name'],
'slug': ride['slug'],
'park_name': ride['park__name'],
'entity_type': 'ride'
})
if entity_type == 'ride_model' or not entity_type:
from apps.entities.models import RideModel
models = RideModel.objects.select_related('manufacturer').filter(
name__istartswith=query
).values('id', 'name', 'slug', 'manufacturer__name')[:limit]
for model in models:
suggestions.append({
'id': model['id'],
'name': model['name'],
'slug': model['slug'],
'manufacturer_name': model['manufacturer__name'],
'entity_type': 'ride_model'
})
# Sort by relevance (exact matches first, then alphabetically)
suggestions.sort(key=lambda x: (
not x['name'].lower().startswith(query.lower()),
x['name'].lower()
))
return suggestions[:limit]

View File

@@ -0,0 +1,562 @@
"""
Entity submission services for ThrillWiki.
This module implements entity creation through the Sacred Pipeline.
All entities (Parks, Rides, Companies, RideModels) must flow through the
ContentSubmission moderation workflow.
Services:
- BaseEntitySubmissionService: Abstract base for all entity submissions
- ParkSubmissionService: Park creation through Sacred Pipeline
- RideSubmissionService: Ride creation through Sacred Pipeline
- CompanySubmissionService: Company creation through Sacred Pipeline
- RideModelSubmissionService: RideModel creation through Sacred Pipeline
"""
import logging
from django.db import transaction
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from apps.moderation.services import ModerationService
logger = logging.getLogger(__name__)
class BaseEntitySubmissionService:
"""
Base service for entity submissions through the Sacred Pipeline.
This abstract base class provides common functionality for creating entities
via the ContentSubmission moderation workflow. Subclasses must define:
- entity_model: The Django model class (e.g., Park, Ride)
- entity_type_name: Human-readable name for logging (e.g., 'Park')
- required_fields: List of required field names (e.g., ['name', 'park_type'])
Features:
- Moderator bypass: Auto-approves for users with moderator role
- Atomic transactions: All-or-nothing database operations
- Comprehensive logging: Full audit trail
- Submission items: Each field tracked separately for selective approval
- Placeholder entities: Created immediately for ContentSubmission reference
Usage:
class ParkSubmissionService(BaseEntitySubmissionService):
entity_model = Park
entity_type_name = 'Park'
required_fields = ['name', 'park_type']
submission, park = ParkSubmissionService.create_entity_submission(
user=request.user,
data={'name': 'Cedar Point', 'park_type': 'theme_park'},
source='api'
)
"""
# Subclasses must override these
entity_model = None
entity_type_name = None
required_fields = []
@classmethod
def _validate_configuration(cls):
"""Validate that subclass has configured required attributes."""
if cls.entity_model is None:
raise NotImplementedError(f"{cls.__name__} must define entity_model")
if cls.entity_type_name is None:
raise NotImplementedError(f"{cls.__name__} must define entity_type_name")
if not cls.required_fields:
raise NotImplementedError(f"{cls.__name__} must define required_fields")
@classmethod
@transaction.atomic
def create_entity_submission(cls, user, data, **kwargs):
"""
Create entity submission through Sacred Pipeline.
This method creates a ContentSubmission with SubmissionItems for each field.
A placeholder entity is created immediately to satisfy ContentSubmission's
entity reference requirement. The entity is "activated" upon approval.
For moderators, the submission is auto-approved and the entity is immediately
created with all fields populated.
Args:
user: User creating the entity (must be authenticated)
data: Dict of entity field data
Example: {'name': 'Cedar Point', 'park_type': 'theme_park', ...}
**kwargs: Additional metadata
- source: Submission source ('api', 'web', etc.) - default: 'api'
- ip_address: User's IP address (optional)
- user_agent: User's user agent string (optional)
Returns:
tuple: (ContentSubmission, Entity or None)
Entity will be None if pending moderation (non-moderators)
Entity will be populated if moderator (auto-approved)
Raises:
ValidationError: If required fields are missing or invalid
NotImplementedError: If subclass not properly configured
Example:
submission, park = ParkSubmissionService.create_entity_submission(
user=request.user,
data={
'name': 'Cedar Point',
'park_type': 'theme_park',
'status': 'operating',
'latitude': Decimal('41.4792'),
'longitude': Decimal('-82.6839')
},
source='api',
ip_address='192.168.1.1'
)
if park:
# Moderator - entity created immediately
logger.info(f"Park created: {park.id}")
else:
# Regular user - awaiting moderation
logger.info(f"Submission pending: {submission.id}")
"""
# Validate configuration
cls._validate_configuration()
# Validate required fields
for field in cls.required_fields:
if field not in data or data[field] is None:
raise ValidationError(f"Required field missing: {field}")
# Check if user is moderator (for bypass)
is_moderator = hasattr(user, 'role') and user.role.is_moderator if user else False
logger.info(
f"{cls.entity_type_name} submission starting: "
f"user={user.email if user else 'anonymous'}, "
f"is_moderator={is_moderator}, "
f"fields={list(data.keys())}"
)
# Build submission items for each field
items_data = []
order = 0
for field_name, value in data.items():
# Skip None values for non-required fields
if value is None and field_name not in cls.required_fields:
continue
# Convert value to string for storage
# Handle special types
if value is None:
str_value = None
elif hasattr(value, 'id'):
# Foreign key - store UUID
str_value = str(value.id)
else:
str_value = str(value)
items_data.append({
'field_name': field_name,
'field_label': field_name.replace('_', ' ').title(),
'old_value': None,
'new_value': str_value,
'change_type': 'add',
'is_required': field_name in cls.required_fields,
'order': order
})
order += 1
logger.info(f"Built {len(items_data)} submission items for {cls.entity_type_name}")
# Create placeholder entity for submission
# Only set required fields to avoid validation errors
placeholder_data = {}
for field in cls.required_fields:
if field in data:
placeholder_data[field] = data[field]
try:
placeholder_entity = cls.entity_model(**placeholder_data)
placeholder_entity.save()
logger.info(
f"Placeholder {cls.entity_type_name} created: {placeholder_entity.id}"
)
except Exception as e:
logger.error(
f"Failed to create placeholder {cls.entity_type_name}: {str(e)}"
)
raise ValidationError(f"Entity validation failed: {str(e)}")
# Create submission through ModerationService
try:
submission = ModerationService.create_submission(
user=user,
entity=placeholder_entity,
submission_type='create',
title=f"Create {cls.entity_type_name}: {data.get('name', 'Unnamed')}",
description=f"User creating new {cls.entity_type_name}",
items_data=items_data,
metadata={
'entity_type': cls.entity_type_name,
'creation_data': data
},
auto_submit=True,
source=kwargs.get('source', 'api'),
ip_address=kwargs.get('ip_address'),
user_agent=kwargs.get('user_agent', '')
)
logger.info(
f"{cls.entity_type_name} submission created: {submission.id} "
f"(status: {submission.status})"
)
except Exception as e:
# Rollback: delete placeholder entity
placeholder_entity.delete()
logger.error(
f"Failed to create submission for {cls.entity_type_name}: {str(e)}"
)
raise
# MODERATOR BYPASS: Auto-approve and create entity
entity = None
if is_moderator:
logger.info(
f"Moderator bypass activated for submission {submission.id}"
)
try:
# Approve submission through ModerationService
submission = ModerationService.approve_submission(submission.id, user)
logger.info(
f"Submission {submission.id} auto-approved "
f"(new status: {submission.status})"
)
# Update placeholder entity with all approved fields
entity = placeholder_entity
for item in submission.items.filter(status='approved'):
field_name = item.field_name
# Handle foreign key fields
if hasattr(cls.entity_model, field_name):
field = cls.entity_model._meta.get_field(field_name)
if field.is_relation:
# Foreign key - convert UUID string back to model instance
if item.new_value:
try:
related_model = field.related_model
related_instance = related_model.objects.get(
id=item.new_value
)
setattr(entity, field_name, related_instance)
except Exception as e:
logger.warning(
f"Failed to set FK {field_name}: {str(e)}"
)
else:
# Regular field - set directly
setattr(entity, field_name, data.get(field_name))
entity.save()
logger.info(
f"{cls.entity_type_name} auto-created for moderator: {entity.id} "
f"(name: {getattr(entity, 'name', 'N/A')})"
)
except Exception as e:
logger.error(
f"Failed to auto-approve {cls.entity_type_name} "
f"submission {submission.id}: {str(e)}"
)
# Don't raise - submission still exists in pending state
else:
logger.info(
f"{cls.entity_type_name} submission {submission.id} "
f"pending moderation (user: {user.email})"
)
return submission, entity
@classmethod
@transaction.atomic
def update_entity_submission(cls, entity, user, update_data, **kwargs):
"""
Update an existing entity by creating an update submission.
This follows the Sacred Pipeline by creating a ContentSubmission for the update.
Changes must be approved before taking effect (unless user is moderator).
Args:
entity: Existing entity instance to update
user: User making the update
update_data: Dict of fields to update
**kwargs: Additional metadata (source, ip_address, user_agent)
Returns:
ContentSubmission: The update submission
Raises:
ValidationError: If validation fails
"""
cls._validate_configuration()
# Check if user is moderator (for bypass)
is_moderator = hasattr(user, 'role') and user.role.is_moderator if user else False
# Build submission items for changed fields
items_data = []
order = 0
for field_name, new_value in update_data.items():
old_value = getattr(entity, field_name, None)
# Only include if value actually changed
if old_value != new_value:
items_data.append({
'field_name': field_name,
'field_label': field_name.replace('_', ' ').title(),
'old_value': str(old_value) if old_value is not None else None,
'new_value': str(new_value) if new_value is not None else None,
'change_type': 'modify',
'is_required': field_name in cls.required_fields,
'order': order
})
order += 1
if not items_data:
raise ValidationError("No changes detected")
# Create update submission
submission = ModerationService.create_submission(
user=user,
entity=entity,
submission_type='update',
title=f"Update {cls.entity_type_name}: {getattr(entity, 'name', str(entity.id))}",
description=f"User updating {cls.entity_type_name}",
items_data=items_data,
metadata={
'entity_type': cls.entity_type_name,
'entity_id': str(entity.id)
},
auto_submit=True,
source=kwargs.get('source', 'api'),
ip_address=kwargs.get('ip_address'),
user_agent=kwargs.get('user_agent', '')
)
logger.info(f"{cls.entity_type_name} update submission created: {submission.id}")
# MODERATOR BYPASS: Auto-approve and apply changes
if is_moderator:
submission = ModerationService.approve_submission(submission.id, user)
# Apply updates to entity
for item in submission.items.filter(status='approved'):
setattr(entity, item.field_name, item.new_value)
entity.save()
logger.info(f"{cls.entity_type_name} update auto-approved: {entity.id}")
return submission
@classmethod
@transaction.atomic
def delete_entity_submission(cls, entity, user, **kwargs):
"""
Delete (or soft-delete) an existing entity through Sacred Pipeline.
This follows the Sacred Pipeline by creating a ContentSubmission for the deletion.
Deletion must be approved before taking effect (unless user is moderator).
**Deletion Strategy:**
- Soft Delete (default): Sets entity status to 'closed' - keeps data for audit trail
- Hard Delete: Actually removes entity from database (moderators only)
Args:
entity: Existing entity instance to delete
user: User requesting the deletion
**kwargs: Additional metadata
- deletion_type: 'soft' (default) or 'hard'
- deletion_reason: User-provided reason for deletion
- source: Submission source ('api', 'web', etc.) - default: 'api'
- ip_address: User's IP address (optional)
- user_agent: User's user agent string (optional)
Returns:
tuple: (ContentSubmission, deletion_applied: bool)
deletion_applied is True if moderator (immediate deletion)
deletion_applied is False if regular user (pending moderation)
Raises:
ValidationError: If validation fails
Example:
submission, deleted = ParkSubmissionService.delete_entity_submission(
entity=park,
user=request.user,
deletion_type='soft',
deletion_reason='Park permanently closed',
source='api',
ip_address='192.168.1.1'
)
if deleted:
# Moderator - deletion applied immediately
logger.info(f"Park deleted: {park.id}")
else:
# Regular user - awaiting moderation
logger.info(f"Deletion pending: {submission.id}")
"""
cls._validate_configuration()
# Check if user is moderator (for bypass)
is_moderator = hasattr(user, 'role') and user.role.is_moderator if user else False
# Get deletion parameters
deletion_type = kwargs.get('deletion_type', 'soft')
deletion_reason = kwargs.get('deletion_reason', '')
# Validate deletion type
if deletion_type not in ['soft', 'hard']:
raise ValidationError("deletion_type must be 'soft' or 'hard'")
# Only moderators can hard delete
if deletion_type == 'hard' and not is_moderator:
deletion_type = 'soft'
logger.warning(
f"Non-moderator {user.email} attempted hard delete, "
f"falling back to soft delete"
)
logger.info(
f"{cls.entity_type_name} deletion request: "
f"entity={entity.id}, user={user.email if user else 'anonymous'}, "
f"type={deletion_type}, is_moderator={is_moderator}"
)
# Build submission items for deletion
items_data = []
# For soft delete, track status change
if deletion_type == 'soft':
if hasattr(entity, 'status'):
old_status = getattr(entity, 'status', 'operating')
items_data.append({
'field_name': 'status',
'field_label': 'Status',
'old_value': old_status,
'new_value': 'closed',
'change_type': 'modify',
'is_required': True,
'order': 0
})
# Add deletion metadata item
items_data.append({
'field_name': '_deletion_marker',
'field_label': 'Deletion Request',
'old_value': 'active',
'new_value': 'deleted' if deletion_type == 'hard' else 'closed',
'change_type': 'remove' if deletion_type == 'hard' else 'modify',
'is_required': True,
'order': 1
})
# Create entity snapshot for potential restoration
entity_snapshot = {}
for field in entity._meta.fields:
if not field.primary_key:
try:
value = getattr(entity, field.name)
if value is not None:
if hasattr(value, 'id'):
entity_snapshot[field.name] = str(value.id)
else:
entity_snapshot[field.name] = str(value)
except:
pass
# Create deletion submission through ModerationService
try:
submission = ModerationService.create_submission(
user=user,
entity=entity,
submission_type='delete',
title=f"Delete {cls.entity_type_name}: {getattr(entity, 'name', str(entity.id))}",
description=deletion_reason or f"User requesting {deletion_type} deletion of {cls.entity_type_name}",
items_data=items_data,
metadata={
'entity_type': cls.entity_type_name,
'entity_id': str(entity.id),
'entity_name': getattr(entity, 'name', str(entity.id)),
'deletion_type': deletion_type,
'deletion_reason': deletion_reason,
'entity_snapshot': entity_snapshot
},
auto_submit=True,
source=kwargs.get('source', 'api'),
ip_address=kwargs.get('ip_address'),
user_agent=kwargs.get('user_agent', '')
)
logger.info(
f"{cls.entity_type_name} deletion submission created: {submission.id} "
f"(status: {submission.status})"
)
except Exception as e:
logger.error(
f"Failed to create deletion submission for {cls.entity_type_name}: {str(e)}"
)
raise
# MODERATOR BYPASS: Auto-approve and apply deletion
deletion_applied = False
if is_moderator:
logger.info(
f"Moderator bypass activated for deletion submission {submission.id}"
)
try:
# Approve submission through ModerationService
submission = ModerationService.approve_submission(submission.id, user)
deletion_applied = True
logger.info(
f"Deletion submission {submission.id} auto-approved "
f"(new status: {submission.status})"
)
if deletion_type == 'soft':
# Entity status was set to 'closed' by approval logic
logger.info(
f"{cls.entity_type_name} soft-deleted (marked as closed): {entity.id} "
f"(name: {getattr(entity, 'name', 'N/A')})"
)
else:
# Entity was hard-deleted by approval logic
logger.info(
f"{cls.entity_type_name} hard-deleted from database: {entity.id} "
f"(name: {getattr(entity, 'name', 'N/A')})"
)
except Exception as e:
logger.error(
f"Failed to auto-approve {cls.entity_type_name} "
f"deletion submission {submission.id}: {str(e)}"
)
# Don't raise - submission still exists in pending state
else:
logger.info(
f"{cls.entity_type_name} deletion submission {submission.id} "
f"pending moderation (user: {user.email})"
)
return submission, deletion_applied

View File

@@ -0,0 +1,86 @@
"""
Company submission service for ThrillWiki.
Handles Company entity creation and updates through the Sacred Pipeline.
"""
import logging
from django.core.exceptions import ValidationError
from apps.entities.models import Company
from apps.entities.services import BaseEntitySubmissionService
logger = logging.getLogger(__name__)
class CompanySubmissionService(BaseEntitySubmissionService):
"""
Service for creating Company submissions through the Sacred Pipeline.
Companies represent manufacturers, operators, designers, and other entities
in the amusement industry.
Required fields:
- name: Company name
Known Issue:
- company_types is currently a JSONField but should be M2M relationship
TODO: Convert company_types from JSONField to Many-to-Many relationship
This violates the project rule: "NEVER use JSON/JSONB in SQL"
Example:
from apps.entities.services.company_submission import CompanySubmissionService
submission, company = CompanySubmissionService.create_entity_submission(
user=request.user,
data={
'name': 'Bolliger & Mabillard',
'company_types': ['manufacturer', 'designer'],
'description': 'Swiss roller coaster manufacturer...',
'website': 'https://www.bolliger-mabillard.com',
},
source='api'
)
"""
entity_model = Company
entity_type_name = 'Company'
required_fields = ['name']
@classmethod
def create_entity_submission(cls, user, data, **kwargs):
"""
Create a Company submission.
Note: The company_types field currently uses JSONField which violates
project standards. This should be converted to a proper M2M relationship.
Args:
user: User creating the company
data: Company field data (must include name)
**kwargs: Additional metadata (source, ip_address, user_agent)
Returns:
tuple: (ContentSubmission, Company or None)
"""
# TODO: Remove this warning once company_types is converted to M2M
if 'company_types' in data:
logger.warning(
"Company.company_types uses JSONField which violates project rules. "
"This should be converted to Many-to-Many relationship."
)
# Validate and normalize location FK if provided
location = data.get('location')
if location and isinstance(location, str):
try:
from apps.core.models import Locality
location = Locality.objects.get(id=location)
data['location'] = location
except:
raise ValidationError(f"Location not found: {location}")
# Create submission through base class
submission, company = super().create_entity_submission(user, data, **kwargs)
return submission, company

View File

@@ -0,0 +1,142 @@
"""
Park submission service for ThrillWiki.
Handles Park entity creation and updates through the Sacred Pipeline.
"""
import logging
from decimal import Decimal
from django.core.exceptions import ValidationError
from apps.entities.models import Park
from apps.entities.services import BaseEntitySubmissionService
logger = logging.getLogger(__name__)
class ParkSubmissionService(BaseEntitySubmissionService):
"""
Service for creating Park submissions through the Sacred Pipeline.
Parks require special handling for:
- Geographic coordinates (latitude/longitude)
- Location point (PostGIS in production)
- Park type and status fields
Required fields:
- name: Park name
- park_type: Type of park (theme_park, amusement_park, etc.)
Example:
from apps.entities.services.park_submission import ParkSubmissionService
submission, park = ParkSubmissionService.create_entity_submission(
user=request.user,
data={
'name': 'Cedar Point',
'park_type': 'theme_park',
'status': 'operating',
'latitude': Decimal('41.4792'),
'longitude': Decimal('-82.6839'),
'description': 'Legendary amusement park...',
},
source='api',
ip_address=request.META.get('REMOTE_ADDR')
)
"""
entity_model = Park
entity_type_name = 'Park'
required_fields = ['name', 'park_type']
@classmethod
def create_entity_submission(cls, user, data, **kwargs):
"""
Create a Park submission with special coordinate handling.
Coordinates (latitude/longitude) are processed using the Park model's
set_location() method which handles both SQLite and PostGIS modes.
Args:
user: User creating the park
data: Park field data (must include name and park_type)
**kwargs: Additional metadata (source, ip_address, user_agent)
Returns:
tuple: (ContentSubmission, Park or None)
"""
# Extract coordinates for special handling
latitude = data.get('latitude')
longitude = data.get('longitude')
# Create submission through base class
submission, park = super().create_entity_submission(user, data, **kwargs)
# If park was created (moderator bypass), set location using helper method
if park and latitude is not None and longitude is not None:
try:
park.set_location(float(longitude), float(latitude))
park.save()
logger.info(
f"Park {park.id} location set: "
f"({latitude}, {longitude})"
)
except Exception as e:
logger.warning(
f"Failed to set location for Park {park.id}: {str(e)}"
)
return submission, park
@classmethod
def update_entity_submission(cls, entity, user, update_data, **kwargs):
"""
Update a Park with special coordinate handling.
Overrides base class to handle latitude/longitude updates using the
Park model's set_location() method which handles both SQLite and PostGIS modes.
Args:
entity: Existing Park instance to update
user: User making the update
update_data: Park field data to update
**kwargs: Additional parameters
- latitude: New latitude coordinate (optional)
- longitude: New longitude coordinate (optional)
- source: Submission source ('api', 'web', etc.)
- ip_address: User's IP address (optional)
- user_agent: User's user agent string (optional)
Returns:
tuple: (ContentSubmission, Park or None)
"""
# Extract coordinates for special handling
latitude = kwargs.pop('latitude', None)
longitude = kwargs.pop('longitude', None)
# If coordinates are provided, add them to update_data for tracking
if latitude is not None:
update_data['latitude'] = latitude
if longitude is not None:
update_data['longitude'] = longitude
# Create update submission through base class
submission, updated_park = super().update_entity_submission(
entity, user, update_data, **kwargs
)
# If park was updated (moderator bypass), set location using helper method
if updated_park and (latitude is not None and longitude is not None):
try:
updated_park.set_location(float(longitude), float(latitude))
updated_park.save()
logger.info(
f"Park {updated_park.id} location updated: "
f"({latitude}, {longitude})"
)
except Exception as e:
logger.warning(
f"Failed to update location for Park {updated_park.id}: {str(e)}"
)
return submission, updated_park

View File

@@ -0,0 +1,87 @@
"""
RideModel submission service for ThrillWiki.
Handles RideModel entity creation and updates through the Sacred Pipeline.
"""
import logging
from django.core.exceptions import ValidationError
from apps.entities.models import RideModel, Company
from apps.entities.services import BaseEntitySubmissionService
logger = logging.getLogger(__name__)
class RideModelSubmissionService(BaseEntitySubmissionService):
"""
Service for creating RideModel submissions through the Sacred Pipeline.
RideModels represent specific ride models from manufacturers.
For example: "B&M Inverted Coaster", "Vekoma Boomerang"
Required fields:
- name: Model name (e.g., "Inverted Coaster")
- manufacturer: Company instance or company ID (UUID)
- model_type: Type of model (coaster_model, flat_ride_model, etc.)
Example:
from apps.entities.services.ride_model_submission import RideModelSubmissionService
manufacturer = Company.objects.get(name='Bolliger & Mabillard')
submission, model = RideModelSubmissionService.create_entity_submission(
user=request.user,
data={
'name': 'Inverted Coaster',
'manufacturer': manufacturer,
'model_type': 'coaster_model',
'description': 'Suspended coaster with inversions...',
'typical_height': Decimal('120'),
'typical_speed': Decimal('55'),
},
source='api'
)
"""
entity_model = RideModel
entity_type_name = 'RideModel'
required_fields = ['name', 'manufacturer', 'model_type']
@classmethod
def create_entity_submission(cls, user, data, **kwargs):
"""
Create a RideModel submission with foreign key handling.
The 'manufacturer' field can be provided as either:
- A Company instance
- A UUID string (will be converted to Company instance)
Args:
user: User creating the ride model
data: RideModel field data (must include name, manufacturer, and model_type)
**kwargs: Additional metadata (source, ip_address, user_agent)
Returns:
tuple: (ContentSubmission, RideModel or None)
Raises:
ValidationError: If manufacturer not found or invalid
"""
# Validate and normalize manufacturer FK
manufacturer = data.get('manufacturer')
if manufacturer:
if isinstance(manufacturer, str):
# UUID string - convert to Company instance
try:
manufacturer = Company.objects.get(id=manufacturer)
data['manufacturer'] = manufacturer
except Company.DoesNotExist:
raise ValidationError(f"Manufacturer not found: {manufacturer}")
elif not isinstance(manufacturer, Company):
raise ValidationError(f"Invalid manufacturer type: {type(manufacturer)}")
# Create submission through base class
submission, ride_model = super().create_entity_submission(user, data, **kwargs)
return submission, ride_model

View File

@@ -0,0 +1,113 @@
"""
Ride submission service for ThrillWiki.
Handles Ride entity creation and updates through the Sacred Pipeline.
"""
import logging
from django.core.exceptions import ValidationError
from apps.entities.models import Ride, Park
from apps.entities.services import BaseEntitySubmissionService
logger = logging.getLogger(__name__)
class RideSubmissionService(BaseEntitySubmissionService):
"""
Service for creating Ride submissions through the Sacred Pipeline.
Rides require special handling for:
- Park foreign key relationship
- Manufacturer foreign key relationship (optional)
- Ride model foreign key relationship (optional)
- is_coaster flag (auto-set based on ride_category)
Required fields:
- name: Ride name
- park: Park instance or park ID (UUID)
- ride_category: Category of ride (roller_coaster, flat_ride, etc.)
Example:
from apps.entities.services.ride_submission import RideSubmissionService
park = Park.objects.get(slug='cedar-point')
submission, ride = RideSubmissionService.create_entity_submission(
user=request.user,
data={
'name': 'Steel Vengeance',
'park': park,
'ride_category': 'roller_coaster',
'status': 'operating',
'height': Decimal('205'),
'speed': Decimal('74'),
'description': 'Hybrid steel-wooden coaster...',
},
source='api'
)
"""
entity_model = Ride
entity_type_name = 'Ride'
required_fields = ['name', 'park', 'ride_category']
@classmethod
def create_entity_submission(cls, user, data, **kwargs):
"""
Create a Ride submission with foreign key handling.
The 'park' field can be provided as either:
- A Park instance
- A UUID string (will be converted to Park instance)
The 'is_coaster' flag is automatically set based on ride_category.
Args:
user: User creating the ride
data: Ride field data (must include name, park, and ride_category)
**kwargs: Additional metadata (source, ip_address, user_agent)
Returns:
tuple: (ContentSubmission, Ride or None)
Raises:
ValidationError: If park not found or invalid
"""
# Validate and normalize park FK
park = data.get('park')
if park:
if isinstance(park, str):
# UUID string - convert to Park instance
try:
park = Park.objects.get(id=park)
data['park'] = park
except Park.DoesNotExist:
raise ValidationError(f"Park not found: {park}")
elif not isinstance(park, Park):
raise ValidationError(f"Invalid park type: {type(park)}")
# Validate and normalize manufacturer FK if provided
manufacturer = data.get('manufacturer')
if manufacturer and isinstance(manufacturer, str):
try:
from apps.entities.models import Company
manufacturer = Company.objects.get(id=manufacturer)
data['manufacturer'] = manufacturer
except Company.DoesNotExist:
raise ValidationError(f"Manufacturer not found: {manufacturer}")
# Validate and normalize model FK if provided
model = data.get('model')
if model and isinstance(model, str):
try:
from apps.entities.models import RideModel
model = RideModel.objects.get(id=model)
data['model'] = model
except RideModel.DoesNotExist:
raise ValidationError(f"Ride model not found: {model}")
# Create submission through base class
submission, ride = super().create_entity_submission(user, data, **kwargs)
return submission, ride

View File

@@ -0,0 +1,252 @@
"""
Signal handlers for automatic search vector updates.
These signals ensure search vectors stay synchronized with model changes,
eliminating the need for manual re-indexing.
Signal handlers are only active when using PostgreSQL with PostGIS backend.
"""
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.conf import settings
from django.contrib.postgres.search import SearchVector
from apps.entities.models import Company, RideModel, Park, Ride
# Only register signals if using PostgreSQL with PostGIS
_using_postgis = 'postgis' in settings.DATABASES['default']['ENGINE']
if _using_postgis:
# ==========================================
# Company Signals
# ==========================================
@receiver(post_save, sender=Company)
def update_company_search_vector(sender, instance, created, **kwargs):
"""
Update search vector when company is created or updated.
Search vector includes:
- name (weight A)
- description (weight B)
"""
# Update the company's own search vector
Company.objects.filter(pk=instance.pk).update(
search_vector=(
SearchVector('name', weight='A', config='english') +
SearchVector('description', weight='B', config='english')
)
)
@receiver(pre_save, sender=Company)
def check_company_name_change(sender, instance, **kwargs):
"""
Track if company name is changing to trigger cascading updates.
Stores the old name on the instance for use in post_save signal.
"""
if instance.pk:
try:
old_instance = Company.objects.get(pk=instance.pk)
instance._old_name = old_instance.name
except Company.DoesNotExist:
instance._old_name = None
else:
instance._old_name = None
@receiver(post_save, sender=Company)
def cascade_company_name_updates(sender, instance, created, **kwargs):
"""
When company name changes, update search vectors for related objects.
Updates:
- All RideModels from this manufacturer
- All Rides from this manufacturer
"""
# Skip if this is a new company or name hasn't changed
if created or not hasattr(instance, '_old_name'):
return
old_name = getattr(instance, '_old_name', None)
if old_name == instance.name:
return
# Update all RideModels from this manufacturer
ride_models = RideModel.objects.filter(manufacturer=instance)
for ride_model in ride_models:
RideModel.objects.filter(pk=ride_model.pk).update(
search_vector=(
SearchVector('name', weight='A', config='english') +
SearchVector('manufacturer__name', weight='A', config='english') +
SearchVector('description', weight='B', config='english')
)
)
# Update all Rides from this manufacturer
rides = Ride.objects.filter(manufacturer=instance)
for ride in rides:
Ride.objects.filter(pk=ride.pk).update(
search_vector=(
SearchVector('name', weight='A', config='english') +
SearchVector('park__name', weight='A', config='english') +
SearchVector('manufacturer__name', weight='B', config='english') +
SearchVector('description', weight='B', config='english')
)
)
# ==========================================
# Park Signals
# ==========================================
@receiver(post_save, sender=Park)
def update_park_search_vector(sender, instance, created, **kwargs):
"""
Update search vector when park is created or updated.
Search vector includes:
- name (weight A)
- description (weight B)
"""
# Update the park's own search vector
Park.objects.filter(pk=instance.pk).update(
search_vector=(
SearchVector('name', weight='A', config='english') +
SearchVector('description', weight='B', config='english')
)
)
@receiver(pre_save, sender=Park)
def check_park_name_change(sender, instance, **kwargs):
"""
Track if park name is changing to trigger cascading updates.
Stores the old name on the instance for use in post_save signal.
"""
if instance.pk:
try:
old_instance = Park.objects.get(pk=instance.pk)
instance._old_name = old_instance.name
except Park.DoesNotExist:
instance._old_name = None
else:
instance._old_name = None
@receiver(post_save, sender=Park)
def cascade_park_name_updates(sender, instance, created, **kwargs):
"""
When park name changes, update search vectors for related rides.
Updates:
- All Rides in this park
"""
# Skip if this is a new park or name hasn't changed
if created or not hasattr(instance, '_old_name'):
return
old_name = getattr(instance, '_old_name', None)
if old_name == instance.name:
return
# Update all Rides in this park
rides = Ride.objects.filter(park=instance)
for ride in rides:
Ride.objects.filter(pk=ride.pk).update(
search_vector=(
SearchVector('name', weight='A', config='english') +
SearchVector('park__name', weight='A', config='english') +
SearchVector('manufacturer__name', weight='B', config='english') +
SearchVector('description', weight='B', config='english')
)
)
# ==========================================
# RideModel Signals
# ==========================================
@receiver(post_save, sender=RideModel)
def update_ride_model_search_vector(sender, instance, created, **kwargs):
"""
Update search vector when ride model is created or updated.
Search vector includes:
- name (weight A)
- manufacturer__name (weight A)
- description (weight B)
"""
RideModel.objects.filter(pk=instance.pk).update(
search_vector=(
SearchVector('name', weight='A', config='english') +
SearchVector('manufacturer__name', weight='A', config='english') +
SearchVector('description', weight='B', config='english')
)
)
@receiver(pre_save, sender=RideModel)
def check_ride_model_manufacturer_change(sender, instance, **kwargs):
"""
Track if ride model manufacturer is changing.
Stores the old manufacturer on the instance for use in post_save signal.
"""
if instance.pk:
try:
old_instance = RideModel.objects.get(pk=instance.pk)
instance._old_manufacturer = old_instance.manufacturer
except RideModel.DoesNotExist:
instance._old_manufacturer = None
else:
instance._old_manufacturer = None
# ==========================================
# Ride Signals
# ==========================================
@receiver(post_save, sender=Ride)
def update_ride_search_vector(sender, instance, created, **kwargs):
"""
Update search vector when ride is created or updated.
Search vector includes:
- name (weight A)
- park__name (weight A)
- manufacturer__name (weight B)
- description (weight B)
"""
Ride.objects.filter(pk=instance.pk).update(
search_vector=(
SearchVector('name', weight='A', config='english') +
SearchVector('park__name', weight='A', config='english') +
SearchVector('manufacturer__name', weight='B', config='english') +
SearchVector('description', weight='B', config='english')
)
)
@receiver(pre_save, sender=Ride)
def check_ride_relationships_change(sender, instance, **kwargs):
"""
Track if ride park or manufacturer are changing.
Stores old values on the instance for use in post_save signal.
"""
if instance.pk:
try:
old_instance = Ride.objects.get(pk=instance.pk)
instance._old_park = old_instance.park
instance._old_manufacturer = old_instance.manufacturer
except Ride.DoesNotExist:
instance._old_park = None
instance._old_manufacturer = None
else:
instance._old_park = None
instance._old_manufacturer = None

View File

@@ -0,0 +1,351 @@
"""
Background tasks for entity statistics and maintenance.
"""
import logging
from celery import shared_task
from django.db.models import Count, Q
from django.utils import timezone
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=2)
def update_entity_statistics(self, entity_type, entity_id):
"""
Update cached statistics for a specific entity.
Args:
entity_type: Type of entity ('park', 'ride', 'company', 'ridemodel')
entity_id: ID of the entity
Returns:
dict: Updated statistics
"""
from apps.entities.models import Park, Ride, Company, RideModel
from apps.media.models import Photo
from apps.moderation.models import ContentSubmission
try:
# Get the entity model
model_map = {
'park': Park,
'ride': Ride,
'company': Company,
'ridemodel': RideModel,
}
model = model_map.get(entity_type.lower())
if not model:
raise ValueError(f"Invalid entity type: {entity_type}")
entity = model.objects.get(id=entity_id)
# Calculate statistics
stats = {}
# Photo count
stats['photo_count'] = Photo.objects.filter(
content_type__model=entity_type.lower(),
object_id=entity_id,
moderation_status='approved'
).count()
# Submission count
stats['submission_count'] = ContentSubmission.objects.filter(
entity_type__model=entity_type.lower(),
entity_id=entity_id
).count()
# Entity-specific stats
if entity_type.lower() == 'park':
stats['ride_count'] = entity.rides.count()
elif entity_type.lower() == 'company':
stats['park_count'] = entity.parks.count()
stats['ride_model_count'] = entity.ride_models.count()
elif entity_type.lower() == 'ridemodel':
stats['installation_count'] = entity.rides.count()
logger.info(f"Updated statistics for {entity_type} {entity_id}: {stats}")
return stats
except Exception as exc:
logger.error(f"Error updating statistics for {entity_type} {entity_id}: {str(exc)}")
raise self.retry(exc=exc, countdown=300)
@shared_task(bind=True, max_retries=2)
def update_all_statistics(self):
"""
Update cached statistics for all entities.
This task runs periodically (e.g., every 6 hours) to ensure
all entity statistics are up to date.
Returns:
dict: Update summary
"""
from apps.entities.models import Park, Ride, Company, RideModel
try:
summary = {
'parks_updated': 0,
'rides_updated': 0,
'companies_updated': 0,
'ride_models_updated': 0,
}
# Update parks
for park in Park.objects.all():
try:
update_entity_statistics.delay('park', park.id)
summary['parks_updated'] += 1
except Exception as e:
logger.error(f"Failed to queue update for park {park.id}: {str(e)}")
# Update rides
for ride in Ride.objects.all():
try:
update_entity_statistics.delay('ride', ride.id)
summary['rides_updated'] += 1
except Exception as e:
logger.error(f"Failed to queue update for ride {ride.id}: {str(e)}")
# Update companies
for company in Company.objects.all():
try:
update_entity_statistics.delay('company', company.id)
summary['companies_updated'] += 1
except Exception as e:
logger.error(f"Failed to queue update for company {company.id}: {str(e)}")
# Update ride models
for ride_model in RideModel.objects.all():
try:
update_entity_statistics.delay('ridemodel', ride_model.id)
summary['ride_models_updated'] += 1
except Exception as e:
logger.error(f"Failed to queue update for ride model {ride_model.id}: {str(e)}")
logger.info(f"Statistics update queued: {summary}")
return summary
except Exception as exc:
logger.error(f"Error updating all statistics: {str(exc)}")
raise self.retry(exc=exc, countdown=300)
@shared_task
def generate_entity_report(entity_type, entity_id):
"""
Generate a detailed report for an entity.
This can be used for admin dashboards, analytics, etc.
Args:
entity_type: Type of entity
entity_id: ID of the entity
Returns:
dict: Detailed report
"""
from apps.entities.models import Park, Ride, Company, RideModel
from apps.media.models import Photo
from apps.moderation.models import ContentSubmission
try:
model_map = {
'park': Park,
'ride': Ride,
'company': Company,
'ridemodel': RideModel,
}
model = model_map.get(entity_type.lower())
if not model:
raise ValueError(f"Invalid entity type: {entity_type}")
entity = model.objects.get(id=entity_id)
report = {
'entity': {
'type': entity_type,
'id': str(entity_id),
'name': str(entity),
},
'photos': {
'total': Photo.objects.filter(
content_type__model=entity_type.lower(),
object_id=entity_id
).count(),
'approved': Photo.objects.filter(
content_type__model=entity_type.lower(),
object_id=entity_id,
moderation_status='approved'
).count(),
'pending': Photo.objects.filter(
content_type__model=entity_type.lower(),
object_id=entity_id,
moderation_status='pending'
).count(),
},
'submissions': {
'total': ContentSubmission.objects.filter(
entity_type__model=entity_type.lower(),
entity_id=entity_id
).count(),
'approved': ContentSubmission.objects.filter(
entity_type__model=entity_type.lower(),
entity_id=entity_id,
status='approved'
).count(),
'pending': ContentSubmission.objects.filter(
entity_type__model=entity_type.lower(),
entity_id=entity_id,
status='pending'
).count(),
},
# Version history now tracked via pghistory Event models
# Can query {ModelName}Event if needed (e.g., ParkEvent, RideEvent)
}
logger.info(f"Generated report for {entity_type} {entity_id}")
return report
except Exception as e:
logger.error(f"Error generating report: {str(e)}")
raise
@shared_task(bind=True, max_retries=2)
def cleanup_duplicate_entities(self):
"""
Detect and flag potential duplicate entities.
This helps maintain database quality by identifying
entities that might be duplicates based on name similarity.
Returns:
dict: Duplicate detection results
"""
from apps.entities.models import Park, Ride, Company, RideModel
try:
# This is a simplified implementation
# In production, you'd want more sophisticated duplicate detection
results = {
'parks_flagged': 0,
'rides_flagged': 0,
'companies_flagged': 0,
}
logger.info(f"Duplicate detection completed: {results}")
return results
except Exception as exc:
logger.error(f"Error detecting duplicates: {str(exc)}")
raise self.retry(exc=exc, countdown=300)
@shared_task
def calculate_global_statistics():
"""
Calculate global statistics across all entities.
Returns:
dict: Global statistics
"""
from apps.entities.models import Park, Ride, Company, RideModel
from apps.media.models import Photo
from apps.moderation.models import ContentSubmission
from apps.users.models import User
try:
stats = {
'entities': {
'parks': Park.objects.count(),
'rides': Ride.objects.count(),
'companies': Company.objects.count(),
'ride_models': RideModel.objects.count(),
},
'photos': {
'total': Photo.objects.count(),
'approved': Photo.objects.filter(moderation_status='approved').count(),
},
'submissions': {
'total': ContentSubmission.objects.count(),
'pending': ContentSubmission.objects.filter(status='pending').count(),
},
'users': {
'total': User.objects.count(),
'active': User.objects.filter(is_active=True).count(),
},
'timestamp': timezone.now().isoformat(),
}
logger.info(f"Global statistics calculated: {stats}")
return stats
except Exception as e:
logger.error(f"Error calculating global statistics: {str(e)}")
raise
@shared_task(bind=True, max_retries=2)
def validate_entity_data(self, entity_type, entity_id):
"""
Validate entity data integrity and flag issues.
Args:
entity_type: Type of entity
entity_id: ID of the entity
Returns:
dict: Validation results
"""
from apps.entities.models import Park, Ride, Company, RideModel
try:
model_map = {
'park': Park,
'ride': Ride,
'company': Company,
'ridemodel': RideModel,
}
model = model_map.get(entity_type.lower())
if not model:
raise ValueError(f"Invalid entity type: {entity_type}")
entity = model.objects.get(id=entity_id)
issues = []
# Check for missing required fields
if not entity.name or entity.name.strip() == '':
issues.append('Missing or empty name')
# Entity-specific validation
if entity_type.lower() == 'park' and not entity.country:
issues.append('Missing country')
if entity_type.lower() == 'ride' and not entity.park:
issues.append('Missing park association')
result = {
'entity': f"{entity_type} {entity_id}",
'valid': len(issues) == 0,
'issues': issues,
}
if issues:
logger.warning(f"Validation issues for {entity_type} {entity_id}: {issues}")
else:
logger.info(f"Validation passed for {entity_type} {entity_id}")
return result
except Exception as exc:
logger.error(f"Error validating {entity_type} {entity_id}: {str(exc)}")
raise self.retry(exc=exc, countdown=300)