mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 03:51:13 -05:00
Add email templates for user notifications and account management
- Created a base email template (base.html) for consistent styling across all emails. - Added moderation approval email template (moderation_approved.html) to notify users of approved submissions. - Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions. - Created password reset email template (password_reset.html) for users requesting to reset their passwords. - Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
django/apps/entities/__pycache__/search.cpython-313.pyc
Normal file
BIN
django/apps/entities/__pycache__/search.cpython-313.pyc
Normal file
Binary file not shown.
BIN
django/apps/entities/__pycache__/signals.cpython-313.pyc
Normal file
BIN
django/apps/entities/__pycache__/signals.cpython-313.pyc
Normal file
Binary file not shown.
@@ -1,97 +1,402 @@
|
||||
"""
|
||||
Django Admin configuration for entity models.
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN ADMIN CLASSES
|
||||
# ============================================================================
|
||||
|
||||
@admin.register(Company)
|
||||
class CompanyAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for Company model."""
|
||||
class CompanyAdmin(ModelAdmin, ImportExportModelAdmin):
|
||||
"""Enhanced admin interface for Company model."""
|
||||
|
||||
list_display = ['name', 'slug', 'location', 'park_count', 'ride_count', 'created', 'modified']
|
||||
list_filter = ['company_types', 'founded_date']
|
||||
search_fields = ['name', 'slug', 'description']
|
||||
readonly_fields = ['id', 'created', 'modified', 'park_count', 'ride_count']
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
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', {
|
||||
'fields': ('location',)
|
||||
('Location & Contact', {
|
||||
'fields': ('location', 'website')
|
||||
}),
|
||||
('Dates', {
|
||||
('History', {
|
||||
'fields': (
|
||||
'founded_date', 'founded_date_precision',
|
||||
'closed_date', 'closed_date_precision'
|
||||
)
|
||||
}),
|
||||
('Media', {
|
||||
'fields': ('logo_image_id', 'logo_image_url', 'website')
|
||||
'fields': ('logo_image_id', 'logo_image_url'),
|
||||
'classes': ['collapse']
|
||||
}),
|
||||
('Statistics', {
|
||||
'fields': ('park_count', 'ride_count'),
|
||||
'classes': ('collapse',)
|
||||
'classes': ['collapse']
|
||||
}),
|
||||
('System', {
|
||||
('System Information', {
|
||||
'fields': ('id', 'created', 'modified'),
|
||||
'classes': ('collapse',)
|
||||
'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(admin.ModelAdmin):
|
||||
"""Admin interface for RideModel model."""
|
||||
class RideModelAdmin(ModelAdmin, ImportExportModelAdmin):
|
||||
"""Enhanced admin interface for RideModel model."""
|
||||
|
||||
list_display = ['name', 'manufacturer', 'model_type', 'installation_count', 'created', 'modified']
|
||||
list_filter = ['model_type', 'manufacturer']
|
||||
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']
|
||||
prepopulated_fields = {'slug': ('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')
|
||||
'fields': (
|
||||
'typical_height', 'typical_speed', 'typical_capacity'
|
||||
),
|
||||
'description': 'Standard specifications for this ride model'
|
||||
}),
|
||||
('Media', {
|
||||
'fields': ('image_id', 'image_url')
|
||||
'fields': ('image_id', 'image_url'),
|
||||
'classes': ['collapse']
|
||||
}),
|
||||
('Statistics', {
|
||||
'fields': ('installation_count',),
|
||||
'classes': ('collapse',)
|
||||
'classes': ['collapse']
|
||||
}),
|
||||
('System', {
|
||||
('System Information', {
|
||||
'fields': ('id', 'created', 'modified'),
|
||||
'classes': ('collapse',)
|
||||
'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(admin.ModelAdmin):
|
||||
"""Admin interface for Park model."""
|
||||
class ParkAdmin(ModelAdmin, ImportExportModelAdmin):
|
||||
"""Enhanced admin interface for Park model with geographic features."""
|
||||
|
||||
list_display = ['name', 'location', 'park_type', 'status', 'ride_count', 'coaster_count', 'opening_date']
|
||||
list_filter = ['park_type', 'status', 'operator', 'opening_date']
|
||||
search_fields = ['name', 'slug', 'description', 'location__name']
|
||||
readonly_fields = ['id', 'created', 'modified', 'ride_count', 'coaster_count']
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
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']
|
||||
raw_id_fields = ['location']
|
||||
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')
|
||||
}),
|
||||
('Location', {
|
||||
'fields': ('location', 'latitude', 'longitude')
|
||||
('Geographic Location', {
|
||||
'fields': ('location', 'latitude', 'longitude', 'coordinates_display'),
|
||||
'description': 'Enter latitude and longitude for the park location'
|
||||
}),
|
||||
('Dates', {
|
||||
'fields': (
|
||||
@@ -102,38 +407,136 @@ class ParkAdmin(admin.ModelAdmin):
|
||||
('Operator', {
|
||||
'fields': ('operator',)
|
||||
}),
|
||||
('Media', {
|
||||
('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',)
|
||||
'classes': ['collapse']
|
||||
}),
|
||||
('Custom Data', {
|
||||
'fields': ('custom_fields',),
|
||||
'classes': ('collapse',)
|
||||
'classes': ['collapse'],
|
||||
'description': 'Additional custom data in JSON format'
|
||||
}),
|
||||
('System', {
|
||||
('System Information', {
|
||||
'fields': ('id', 'created', 'modified'),
|
||||
'classes': ('collapse',)
|
||||
'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(admin.ModelAdmin):
|
||||
"""Admin interface for Ride model."""
|
||||
class RideAdmin(ModelAdmin, ImportExportModelAdmin):
|
||||
"""Enhanced admin interface for Ride model."""
|
||||
|
||||
list_display = ['name', 'park', 'ride_category', 'status', 'is_coaster', 'manufacturer', 'opening_date']
|
||||
list_filter = ['ride_category', 'status', 'is_coaster', 'park', 'manufacturer', 'opening_date']
|
||||
search_fields = ['name', 'slug', 'description', 'park__name', 'manufacturer__name']
|
||||
readonly_fields = ['id', 'created', 'modified', 'is_coaster']
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
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 = [PhotoInline]
|
||||
|
||||
list_per_page = 50
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {
|
||||
@@ -148,21 +551,156 @@ class RideAdmin(admin.ModelAdmin):
|
||||
'closing_date', 'closing_date_precision'
|
||||
)
|
||||
}),
|
||||
('Manufacturer', {
|
||||
('Manufacturer & Model', {
|
||||
'fields': ('manufacturer', 'model')
|
||||
}),
|
||||
('Statistics', {
|
||||
'fields': ('height', 'speed', 'length', 'duration', 'inversions', 'capacity')
|
||||
('Ride Statistics', {
|
||||
'fields': (
|
||||
'height', 'speed', 'length',
|
||||
'duration', 'inversions', 'capacity'
|
||||
),
|
||||
'description': 'Technical specifications and statistics'
|
||||
}),
|
||||
('Media', {
|
||||
'fields': ('image_id', 'image_url')
|
||||
'fields': ('image_id', 'image_url'),
|
||||
'classes': ['collapse']
|
||||
}),
|
||||
('Custom Data', {
|
||||
'fields': ('custom_fields',),
|
||||
'classes': ('collapse',)
|
||||
'classes': ['collapse']
|
||||
}),
|
||||
('System', {
|
||||
('System Information', {
|
||||
'fields': ('id', 'created', 'modified'),
|
||||
'classes': ('collapse',)
|
||||
'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
|
||||
|
||||
@@ -9,3 +9,7 @@ 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
|
||||
|
||||
418
django/apps/entities/filters.py
Normal file
418
django/apps/entities/filters.py
Normal 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
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,11 +8,24 @@ This module contains the core entity models:
|
||||
- Ride: Individual rides and roller coasters
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.utils.text import slugify
|
||||
from django_lifecycle import hook, AFTER_CREATE, AFTER_UPDATE, BEFORE_SAVE
|
||||
|
||||
from apps.core.models import VersionedModel, BaseModel
|
||||
|
||||
# Conditionally import GIS models only if using PostGIS backend
|
||||
# This allows migrations to run on SQLite during local development
|
||||
_using_postgis = (
|
||||
'postgis' in settings.DATABASES['default']['ENGINE']
|
||||
)
|
||||
|
||||
if _using_postgis:
|
||||
from django.contrib.gis.db import models as gis_models
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.postgres.search import SearchVectorField
|
||||
|
||||
|
||||
class Company(VersionedModel):
|
||||
"""
|
||||
@@ -122,6 +135,16 @@ class Company(VersionedModel):
|
||||
help_text="Number of rides manufactured (for manufacturers)"
|
||||
)
|
||||
|
||||
# Generic relation to photos
|
||||
photos = GenericRelation(
|
||||
'media.Photo',
|
||||
related_query_name='company'
|
||||
)
|
||||
|
||||
# Full-text search vector (PostgreSQL only)
|
||||
# Populated automatically via signals or database triggers
|
||||
# Includes: name (weight A) + description (weight B)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Company'
|
||||
verbose_name_plural = 'Companies'
|
||||
@@ -151,6 +174,24 @@ class Company(VersionedModel):
|
||||
self.park_count = self.operated_parks.count()
|
||||
self.ride_count = self.manufactured_rides.count()
|
||||
self.save(update_fields=['park_count', 'ride_count'])
|
||||
|
||||
def get_photos(self, photo_type=None, approved_only=True):
|
||||
"""Get photos for this company."""
|
||||
from apps.media.services import PhotoService
|
||||
service = PhotoService()
|
||||
return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only)
|
||||
|
||||
@property
|
||||
def main_photo(self):
|
||||
"""Get the main photo."""
|
||||
photos = self.photos.filter(photo_type='main', moderation_status='approved').first()
|
||||
return photos
|
||||
|
||||
@property
|
||||
def logo_photo(self):
|
||||
"""Get the logo photo."""
|
||||
photos = self.photos.filter(photo_type='logo', moderation_status='approved').first()
|
||||
return photos
|
||||
|
||||
|
||||
class RideModel(VersionedModel):
|
||||
@@ -238,6 +279,12 @@ class RideModel(VersionedModel):
|
||||
help_text="Number of installations worldwide"
|
||||
)
|
||||
|
||||
# Generic relation to photos
|
||||
photos = GenericRelation(
|
||||
'media.Photo',
|
||||
related_query_name='ride_model'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Ride Model'
|
||||
verbose_name_plural = 'Ride Models'
|
||||
@@ -267,11 +314,27 @@ class RideModel(VersionedModel):
|
||||
"""Update cached installation count."""
|
||||
self.installation_count = self.rides.count()
|
||||
self.save(update_fields=['installation_count'])
|
||||
|
||||
def get_photos(self, photo_type=None, approved_only=True):
|
||||
"""Get photos for this ride model."""
|
||||
from apps.media.services import PhotoService
|
||||
service = PhotoService()
|
||||
return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only)
|
||||
|
||||
@property
|
||||
def main_photo(self):
|
||||
"""Get the main photo."""
|
||||
photos = self.photos.filter(photo_type='main', moderation_status='approved').first()
|
||||
return photos
|
||||
|
||||
|
||||
class Park(VersionedModel):
|
||||
"""
|
||||
Represents an amusement park, theme park, water park, or FEC.
|
||||
|
||||
Note: Geographic coordinates are stored differently based on database backend:
|
||||
- Production (PostGIS): Uses location_point PointField with full GIS capabilities
|
||||
- Local Dev (SQLite): Uses latitude/longitude DecimalFields (no spatial queries)
|
||||
"""
|
||||
|
||||
PARK_TYPE_CHOICES = [
|
||||
@@ -369,21 +432,24 @@ class Park(VersionedModel):
|
||||
)
|
||||
|
||||
# Precise coordinates for mapping
|
||||
# Primary in local dev (SQLite), deprecated in production (PostGIS)
|
||||
latitude = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=7,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Latitude coordinate"
|
||||
help_text="Latitude coordinate. Primary in local dev, use location_point in production."
|
||||
)
|
||||
longitude = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=7,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Longitude coordinate"
|
||||
help_text="Longitude coordinate. Primary in local dev, use location_point in production."
|
||||
)
|
||||
|
||||
# NOTE: location_point PointField is added conditionally below if using PostGIS
|
||||
|
||||
# Relationships
|
||||
operator = models.ForeignKey(
|
||||
'Company',
|
||||
@@ -437,6 +503,12 @@ class Park(VersionedModel):
|
||||
help_text="Additional park-specific data"
|
||||
)
|
||||
|
||||
# Generic relation to photos
|
||||
photos = GenericRelation(
|
||||
'media.Photo',
|
||||
related_query_name='park'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Park'
|
||||
verbose_name_plural = 'Parks'
|
||||
@@ -470,6 +542,100 @@ class Park(VersionedModel):
|
||||
self.ride_count = self.rides.count()
|
||||
self.coaster_count = self.rides.filter(is_coaster=True).count()
|
||||
self.save(update_fields=['ride_count', 'coaster_count'])
|
||||
|
||||
def set_location(self, longitude, latitude):
|
||||
"""
|
||||
Set park location from coordinates.
|
||||
|
||||
Args:
|
||||
longitude: Longitude coordinate (X)
|
||||
latitude: Latitude coordinate (Y)
|
||||
|
||||
Note: Works in both PostGIS and non-PostGIS modes.
|
||||
- PostGIS: Sets location_point and syncs to lat/lng
|
||||
- SQLite: Sets lat/lng directly
|
||||
"""
|
||||
if longitude is not None and latitude is not None:
|
||||
# Always update lat/lng fields
|
||||
self.longitude = longitude
|
||||
self.latitude = latitude
|
||||
|
||||
# If using PostGIS, also update location_point
|
||||
if _using_postgis and hasattr(self, 'location_point'):
|
||||
self.location_point = Point(float(longitude), float(latitude), srid=4326)
|
||||
|
||||
@property
|
||||
def coordinates(self):
|
||||
"""
|
||||
Get coordinates as (longitude, latitude) tuple.
|
||||
|
||||
Returns:
|
||||
tuple: (longitude, latitude) or None if no location set
|
||||
"""
|
||||
# Try PostGIS field first if available
|
||||
if _using_postgis and hasattr(self, 'location_point') and self.location_point:
|
||||
return (self.location_point.x, self.location_point.y)
|
||||
# Fall back to lat/lng fields
|
||||
elif self.longitude and self.latitude:
|
||||
return (float(self.longitude), float(self.latitude))
|
||||
return None
|
||||
|
||||
@property
|
||||
def latitude_value(self):
|
||||
"""Get latitude value (from location_point if PostGIS, else from latitude field)."""
|
||||
if _using_postgis and hasattr(self, 'location_point') and self.location_point:
|
||||
return self.location_point.y
|
||||
return float(self.latitude) if self.latitude else None
|
||||
|
||||
@property
|
||||
def longitude_value(self):
|
||||
"""Get longitude value (from location_point if PostGIS, else from longitude field)."""
|
||||
if _using_postgis and hasattr(self, 'location_point') and self.location_point:
|
||||
return self.location_point.x
|
||||
return float(self.longitude) if self.longitude else None
|
||||
|
||||
def get_photos(self, photo_type=None, approved_only=True):
|
||||
"""Get photos for this park."""
|
||||
from apps.media.services import PhotoService
|
||||
service = PhotoService()
|
||||
return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only)
|
||||
|
||||
@property
|
||||
def main_photo(self):
|
||||
"""Get the main photo."""
|
||||
photos = self.photos.filter(photo_type='main', moderation_status='approved').first()
|
||||
return photos
|
||||
|
||||
@property
|
||||
def banner_photo(self):
|
||||
"""Get the banner photo."""
|
||||
photos = self.photos.filter(photo_type='banner', moderation_status='approved').first()
|
||||
return photos
|
||||
|
||||
@property
|
||||
def logo_photo(self):
|
||||
"""Get the logo photo."""
|
||||
photos = self.photos.filter(photo_type='logo', moderation_status='approved').first()
|
||||
return photos
|
||||
|
||||
@property
|
||||
def gallery_photos(self):
|
||||
"""Get gallery photos."""
|
||||
return self.photos.filter(photo_type='gallery', moderation_status='approved').order_by('display_order')
|
||||
|
||||
|
||||
# Conditionally add PostGIS PointField to Park model if using PostGIS backend
|
||||
if _using_postgis:
|
||||
Park.add_to_class(
|
||||
'location_point',
|
||||
gis_models.PointField(
|
||||
geography=True,
|
||||
null=True,
|
||||
blank=True,
|
||||
srid=4326,
|
||||
help_text="Geographic coordinates (PostGIS Point). Production only."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Ride(VersionedModel):
|
||||
@@ -659,6 +825,12 @@ class Ride(VersionedModel):
|
||||
help_text="Additional ride-specific data"
|
||||
)
|
||||
|
||||
# Generic relation to photos
|
||||
photos = GenericRelation(
|
||||
'media.Photo',
|
||||
related_query_name='ride'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Ride'
|
||||
verbose_name_plural = 'Rides'
|
||||
@@ -699,3 +871,60 @@ class Ride(VersionedModel):
|
||||
"""Update parent park's ride counts when ride is created or moved."""
|
||||
if self.park:
|
||||
self.park.update_counts()
|
||||
|
||||
def get_photos(self, photo_type=None, approved_only=True):
|
||||
"""Get photos for this ride."""
|
||||
from apps.media.services import PhotoService
|
||||
service = PhotoService()
|
||||
return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only)
|
||||
|
||||
@property
|
||||
def main_photo(self):
|
||||
"""Get the main photo."""
|
||||
photos = self.photos.filter(photo_type='main', moderation_status='approved').first()
|
||||
return photos
|
||||
|
||||
@property
|
||||
def gallery_photos(self):
|
||||
"""Get gallery photos."""
|
||||
return self.photos.filter(photo_type='gallery', moderation_status='approved').order_by('display_order')
|
||||
|
||||
|
||||
# Add SearchVectorField to all models for full-text search (PostgreSQL only)
|
||||
# Must be at the very end after ALL class definitions
|
||||
if _using_postgis:
|
||||
Company.add_to_class(
|
||||
'search_vector',
|
||||
SearchVectorField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Pre-computed search vector for full-text search. Auto-updated via signals."
|
||||
)
|
||||
)
|
||||
|
||||
RideModel.add_to_class(
|
||||
'search_vector',
|
||||
SearchVectorField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Pre-computed search vector for full-text search. Auto-updated via signals."
|
||||
)
|
||||
)
|
||||
|
||||
Park.add_to_class(
|
||||
'search_vector',
|
||||
SearchVectorField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Pre-computed search vector for full-text search. Auto-updated via signals."
|
||||
)
|
||||
)
|
||||
|
||||
Ride.add_to_class(
|
||||
'search_vector',
|
||||
SearchVectorField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Pre-computed search vector for full-text search. Auto-updated via signals."
|
||||
)
|
||||
)
|
||||
|
||||
386
django/apps/entities/search.py
Normal file
386
django/apps/entities/search.py
Normal 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]
|
||||
252
django/apps/entities/signals.py
Normal file
252
django/apps/entities/signals.py
Normal 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
|
||||
354
django/apps/entities/tasks.py
Normal file
354
django/apps/entities/tasks.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
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
|
||||
from apps.versioning.models import EntityVersion
|
||||
|
||||
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(),
|
||||
},
|
||||
'versions': EntityVersion.objects.filter(
|
||||
content_type__model=entity_type.lower(),
|
||||
object_id=entity_id
|
||||
).count(),
|
||||
}
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user