mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 09:51:09 -05:00
- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols. - Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage. - Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
1404 lines
41 KiB
Python
1404 lines
41 KiB
Python
"""
|
|
Django admin configuration for the Rides application.
|
|
|
|
This module provides comprehensive admin interfaces for managing rides, ride models,
|
|
roller coaster stats, reviews, and rankings. All admin classes use optimized
|
|
querysets and follow the standardized admin patterns defined in apps.core.admin.
|
|
|
|
Performance targets:
|
|
- List views: < 15 queries
|
|
- Change views: < 20 queries
|
|
- Page load time: < 500ms for 100 records
|
|
"""
|
|
|
|
from django.contrib import admin
|
|
from django.contrib.gis.admin import GISModelAdmin
|
|
from django.db.models import Avg, Count
|
|
from django.utils import timezone
|
|
from django.utils.html import format_html
|
|
|
|
from apps.core.admin import (
|
|
BaseModelAdmin,
|
|
ExportActionMixin,
|
|
QueryOptimizationMixin,
|
|
ReadOnlyAdminMixin,
|
|
SlugFieldMixin,
|
|
TimestampFieldsMixin,
|
|
)
|
|
|
|
from .models.company import Company
|
|
from .models.location import RideLocation
|
|
from .models.rankings import RankingSnapshot, RidePairComparison, RideRanking
|
|
from .models.reviews import RideReview
|
|
from .models.rides import Ride, RideModel, RollerCoasterStats
|
|
|
|
|
|
class RideLocationInline(admin.StackedInline):
|
|
"""
|
|
Inline admin for RideLocation within Ride admin.
|
|
|
|
Displays location and accessibility information in a collapsible section.
|
|
"""
|
|
|
|
model = RideLocation
|
|
extra = 0
|
|
classes = ("collapse",)
|
|
fields = (
|
|
"park_area",
|
|
"point",
|
|
"entrance_notes",
|
|
"accessibility_notes",
|
|
)
|
|
|
|
|
|
class RollerCoasterStatsInline(admin.StackedInline):
|
|
"""
|
|
Inline admin for RollerCoasterStats within Ride admin.
|
|
|
|
Shows roller coaster-specific statistics in a collapsible section.
|
|
Only relevant for roller coaster category rides.
|
|
"""
|
|
|
|
model = RollerCoasterStats
|
|
extra = 0
|
|
classes = ("collapse",)
|
|
fields = (
|
|
("height_ft", "length_ft", "speed_mph"),
|
|
("track_material", "roller_coaster_type"),
|
|
("propulsion_system", "inversions"),
|
|
("max_drop_height_ft", "ride_time_seconds"),
|
|
("train_style", "trains_count"),
|
|
("cars_per_train", "seats_per_car"),
|
|
)
|
|
|
|
|
|
class RideLocationAdmin(QueryOptimizationMixin, GISModelAdmin):
|
|
"""
|
|
Admin interface for standalone RideLocation management.
|
|
|
|
Provides GIS functionality with map widgets for coordinate entry.
|
|
Optimized with select_related for ride and park data.
|
|
"""
|
|
|
|
list_display = (
|
|
"ride_link",
|
|
"park_name",
|
|
"park_area",
|
|
"has_coordinates",
|
|
"created_at",
|
|
)
|
|
list_filter = ("park_area", "created_at")
|
|
list_select_related = ["ride", "ride__park"]
|
|
search_fields = (
|
|
"ride__name",
|
|
"ride__park__name",
|
|
"park_area",
|
|
"entrance_notes",
|
|
)
|
|
readonly_fields = (
|
|
"latitude",
|
|
"longitude",
|
|
"coordinates",
|
|
"created_at",
|
|
"updated_at",
|
|
)
|
|
autocomplete_fields = ["ride"]
|
|
list_per_page = 50
|
|
show_full_result_count = False
|
|
|
|
fieldsets = (
|
|
(
|
|
"Ride",
|
|
{
|
|
"fields": ("ride",),
|
|
"description": "Select the ride for this location.",
|
|
},
|
|
),
|
|
(
|
|
"Location Information",
|
|
{
|
|
"fields": (
|
|
"park_area",
|
|
"point",
|
|
"latitude",
|
|
"longitude",
|
|
"coordinates",
|
|
),
|
|
"description": "Optional coordinates - not all rides need precise location tracking.",
|
|
},
|
|
),
|
|
(
|
|
"Navigation Notes",
|
|
{
|
|
"fields": ("entrance_notes", "accessibility_notes"),
|
|
"description": "Helpful information for visitors navigating to this ride.",
|
|
},
|
|
),
|
|
(
|
|
"Metadata",
|
|
{
|
|
"fields": ("created_at", "updated_at"),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Ride")
|
|
def ride_link(self, obj):
|
|
"""Display ride name as a clickable link."""
|
|
if obj.ride:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:rides_ride_change", args=[obj.ride.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.ride.name)
|
|
return "-"
|
|
|
|
@admin.display(description="Park")
|
|
def park_name(self, obj):
|
|
"""Display park name."""
|
|
if obj.ride and obj.ride.park:
|
|
return obj.ride.park.name
|
|
return "-"
|
|
|
|
@admin.display(description="Latitude")
|
|
def latitude(self, obj):
|
|
"""Display latitude coordinate."""
|
|
return obj.latitude
|
|
|
|
@admin.display(description="Longitude")
|
|
def longitude(self, obj):
|
|
"""Display longitude coordinate."""
|
|
return obj.longitude
|
|
|
|
@admin.display(description="Has Coords", boolean=True)
|
|
def has_coordinates(self, obj):
|
|
"""Indicate whether location has valid coordinates."""
|
|
return obj.point is not None
|
|
|
|
|
|
@admin.register(Ride)
|
|
class RideAdmin(
|
|
QueryOptimizationMixin,
|
|
ExportActionMixin,
|
|
SlugFieldMixin,
|
|
TimestampFieldsMixin,
|
|
BaseModelAdmin,
|
|
):
|
|
"""
|
|
Admin interface for Ride management.
|
|
|
|
Provides comprehensive ride administration with:
|
|
- Optimized queries using select_related/prefetch_related
|
|
- Bulk actions for status changes and exports
|
|
- Inline editing of locations and coaster stats
|
|
- Enhanced filtering and search capabilities
|
|
- FSM-aware status handling
|
|
|
|
Query optimizations:
|
|
- select_related: park, park_area, manufacturer, designer, ride_model, banner_image, card_image
|
|
- prefetch_related: coaster_stats, location, reviews
|
|
"""
|
|
|
|
list_display = (
|
|
"name",
|
|
"park_link",
|
|
"category_badge",
|
|
"manufacturer_link",
|
|
"status_badge",
|
|
"opening_date",
|
|
"average_rating_display",
|
|
"review_count",
|
|
)
|
|
list_filter = (
|
|
"category",
|
|
"status",
|
|
"park",
|
|
"manufacturer",
|
|
"designer",
|
|
"opening_date",
|
|
)
|
|
list_select_related = [
|
|
"park",
|
|
"park_area",
|
|
"manufacturer",
|
|
"designer",
|
|
"ride_model",
|
|
"banner_image",
|
|
"card_image",
|
|
]
|
|
list_prefetch_related = ["reviews", "coaster_stats", "location"]
|
|
search_fields = (
|
|
"name",
|
|
"slug",
|
|
"description",
|
|
"park__name",
|
|
"manufacturer__name",
|
|
"designer__name",
|
|
)
|
|
readonly_fields = ("created_at", "updated_at", "average_rating", "review_count")
|
|
autocomplete_fields = [
|
|
"park",
|
|
"park_area",
|
|
"manufacturer",
|
|
"designer",
|
|
"ride_model",
|
|
"banner_image",
|
|
"card_image",
|
|
]
|
|
inlines = [RideLocationInline, RollerCoasterStatsInline]
|
|
date_hierarchy = "opening_date"
|
|
ordering = ("park", "name")
|
|
|
|
export_fields = [
|
|
"id",
|
|
"name",
|
|
"slug",
|
|
"category",
|
|
"status",
|
|
"park",
|
|
"manufacturer",
|
|
"opening_date",
|
|
"created_at",
|
|
]
|
|
export_filename_prefix = "rides"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Basic Information",
|
|
{
|
|
"fields": (
|
|
"name",
|
|
"slug",
|
|
"description",
|
|
"park",
|
|
"park_area",
|
|
"category",
|
|
),
|
|
"description": "Core ride identification and categorization.",
|
|
},
|
|
),
|
|
(
|
|
"Companies",
|
|
{
|
|
"fields": (
|
|
"manufacturer",
|
|
"designer",
|
|
"ride_model",
|
|
),
|
|
"description": "Companies responsible for manufacturing and designing the ride.",
|
|
},
|
|
),
|
|
(
|
|
"Status & Dates",
|
|
{
|
|
"fields": (
|
|
"status",
|
|
"post_closing_status",
|
|
"opening_date",
|
|
"closing_date",
|
|
"status_since",
|
|
),
|
|
"description": "Operational status and historical dates.",
|
|
},
|
|
),
|
|
(
|
|
"Ride Specifications",
|
|
{
|
|
"fields": (
|
|
"min_height_in",
|
|
"max_height_in",
|
|
"capacity_per_hour",
|
|
"ride_duration_seconds",
|
|
),
|
|
"classes": ("collapse",),
|
|
"description": "Technical specifications and requirements.",
|
|
},
|
|
),
|
|
(
|
|
"Media",
|
|
{
|
|
"fields": ("banner_image", "card_image"),
|
|
"classes": ("collapse",),
|
|
"description": "Images displayed in banners and card views.",
|
|
},
|
|
),
|
|
(
|
|
"Statistics",
|
|
{
|
|
"fields": ("average_rating", "review_count"),
|
|
"classes": ("collapse",),
|
|
"description": "Auto-calculated statistics (read-only).",
|
|
},
|
|
),
|
|
(
|
|
"Metadata",
|
|
{
|
|
"fields": ("created_at", "updated_at"),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Park")
|
|
def park_link(self, obj):
|
|
"""Display park as clickable link."""
|
|
if obj.park:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:parks_park_change", args=[obj.park.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.park.name)
|
|
return "-"
|
|
|
|
@admin.display(description="Manufacturer")
|
|
def manufacturer_link(self, obj):
|
|
"""Display manufacturer as clickable link."""
|
|
if obj.manufacturer:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:rides_company_change", args=[obj.manufacturer.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.manufacturer.name)
|
|
return "-"
|
|
|
|
@admin.display(description="Category")
|
|
def category_badge(self, obj):
|
|
"""Display category with color-coded badge."""
|
|
colors = {
|
|
"roller_coaster": "#e74c3c",
|
|
"water_ride": "#3498db",
|
|
"dark_ride": "#9b59b6",
|
|
"flat_ride": "#2ecc71",
|
|
"transport": "#f39c12",
|
|
"show": "#1abc9c",
|
|
}
|
|
color = colors.get(obj.category, "#95a5a6")
|
|
choices_dict = dict(obj._meta.get_field("category").choices)
|
|
label = choices_dict.get(obj.category, obj.category)
|
|
return format_html(
|
|
'<span style="background-color: {}; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">{}</span>',
|
|
color,
|
|
label,
|
|
)
|
|
|
|
@admin.display(description="Status")
|
|
def status_badge(self, obj):
|
|
"""Display status with color-coded badge."""
|
|
colors = {
|
|
"operating": "green",
|
|
"closed": "red",
|
|
"sbno": "orange",
|
|
"under_construction": "blue",
|
|
"announced": "purple",
|
|
}
|
|
color = colors.get(obj.status, "gray")
|
|
return format_html(
|
|
'<span style="background-color: {}; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">{}</span>',
|
|
color,
|
|
obj.get_status_display(),
|
|
)
|
|
|
|
@admin.display(description="Rating")
|
|
def average_rating_display(self, obj):
|
|
"""Display average rating as stars."""
|
|
if hasattr(obj, "_avg_rating") and obj._avg_rating:
|
|
rating = obj._avg_rating
|
|
elif obj.average_rating:
|
|
rating = float(obj.average_rating)
|
|
else:
|
|
return "-"
|
|
full_stars = int(rating)
|
|
stars = "★" * full_stars + "☆" * (5 - full_stars)
|
|
return format_html(
|
|
'<span style="color: gold;">{}</span> {:.1f}',
|
|
stars,
|
|
rating,
|
|
)
|
|
|
|
@admin.display(description="Reviews")
|
|
def review_count(self, obj):
|
|
"""Display count of reviews."""
|
|
if hasattr(obj, "_review_count"):
|
|
return obj._review_count
|
|
return obj.reviews.count()
|
|
|
|
def get_queryset(self, request):
|
|
"""Optimize queryset with annotations for list display."""
|
|
qs = super().get_queryset(request)
|
|
qs = qs.annotate(
|
|
_review_count=Count("reviews", distinct=True),
|
|
_avg_rating=Avg("reviews__rating"),
|
|
)
|
|
return qs
|
|
|
|
@admin.action(description="Set status to Operating")
|
|
def bulk_set_operating(self, request, queryset):
|
|
"""Set status to operating for selected rides."""
|
|
updated = queryset.update(status="operating", status_since=timezone.now().date())
|
|
self.message_user(request, f"Successfully set {updated} rides to Operating.")
|
|
|
|
@admin.action(description="Set status to Closed")
|
|
def bulk_set_closed(self, request, queryset):
|
|
"""Set status to closed for selected rides."""
|
|
updated = queryset.update(status="closed", status_since=timezone.now().date())
|
|
self.message_user(request, f"Successfully set {updated} rides to Closed.")
|
|
|
|
@admin.action(description="Set status to SBNO")
|
|
def bulk_set_sbno(self, request, queryset):
|
|
"""Set status to SBNO for selected rides."""
|
|
updated = queryset.update(status="sbno", status_since=timezone.now().date())
|
|
self.message_user(request, f"Successfully set {updated} rides to SBNO.")
|
|
|
|
@admin.action(description="Recalculate ride ratings")
|
|
def recalculate_ratings(self, request, queryset):
|
|
"""Recalculate average ratings for selected rides."""
|
|
count = 0
|
|
for ride in queryset:
|
|
avg = ride.reviews.aggregate(avg=Avg("rating"))["avg"]
|
|
if avg:
|
|
ride.average_rating = avg
|
|
ride.save(update_fields=["average_rating", "updated_at"])
|
|
count += 1
|
|
self.message_user(request, f"Successfully recalculated ratings for {count} rides.")
|
|
|
|
def get_actions(self, request):
|
|
"""Add custom actions to the admin."""
|
|
actions = super().get_actions(request)
|
|
actions["bulk_set_operating"] = (
|
|
self.bulk_set_operating,
|
|
"bulk_set_operating",
|
|
"Set status to Operating",
|
|
)
|
|
actions["bulk_set_closed"] = (
|
|
self.bulk_set_closed,
|
|
"bulk_set_closed",
|
|
"Set status to Closed",
|
|
)
|
|
actions["bulk_set_sbno"] = (
|
|
self.bulk_set_sbno,
|
|
"bulk_set_sbno",
|
|
"Set status to SBNO",
|
|
)
|
|
actions["recalculate_ratings"] = (
|
|
self.recalculate_ratings,
|
|
"recalculate_ratings",
|
|
"Recalculate ride ratings",
|
|
)
|
|
return actions
|
|
|
|
|
|
@admin.register(RideModel)
|
|
class RideModelAdmin(
|
|
QueryOptimizationMixin,
|
|
ExportActionMixin,
|
|
TimestampFieldsMixin,
|
|
BaseModelAdmin,
|
|
):
|
|
"""
|
|
Admin interface for ride models.
|
|
|
|
Manages ride model/type information with manufacturer linking
|
|
and installation counts.
|
|
"""
|
|
|
|
list_display = (
|
|
"name",
|
|
"manufacturer_link",
|
|
"category_badge",
|
|
"installation_count",
|
|
)
|
|
list_filter = ("manufacturer", "category")
|
|
list_select_related = ["manufacturer"]
|
|
list_prefetch_related = ["rides"]
|
|
search_fields = ("name", "description", "manufacturer__name")
|
|
ordering = ("manufacturer", "name")
|
|
autocomplete_fields = ["manufacturer"]
|
|
|
|
export_fields = ["id", "name", "manufacturer", "category", "description"]
|
|
export_filename_prefix = "ride_models"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Model Information",
|
|
{
|
|
"fields": (
|
|
"name",
|
|
"manufacturer",
|
|
"category",
|
|
"description",
|
|
),
|
|
"description": "Core model identification and categorization.",
|
|
},
|
|
),
|
|
(
|
|
"Statistics",
|
|
{
|
|
"fields": ("installation_count",),
|
|
"classes": ("collapse",),
|
|
"description": "Auto-calculated statistics.",
|
|
},
|
|
),
|
|
)
|
|
|
|
readonly_fields = ("installation_count",)
|
|
|
|
@admin.display(description="Manufacturer")
|
|
def manufacturer_link(self, obj):
|
|
"""Display manufacturer as clickable link."""
|
|
if obj.manufacturer:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:rides_company_change", args=[obj.manufacturer.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.manufacturer.name)
|
|
return "-"
|
|
|
|
@admin.display(description="Category")
|
|
def category_badge(self, obj):
|
|
"""Display category with color-coded badge."""
|
|
colors = {
|
|
"roller_coaster": "#e74c3c",
|
|
"water_ride": "#3498db",
|
|
"dark_ride": "#9b59b6",
|
|
"flat_ride": "#2ecc71",
|
|
}
|
|
color = colors.get(obj.category, "#95a5a6")
|
|
choices_dict = dict(obj._meta.get_field("category").choices)
|
|
label = choices_dict.get(obj.category, obj.category)
|
|
return format_html(
|
|
'<span style="background-color: {}; color: white; padding: 2px 8px; '
|
|
'border-radius: 4px; font-size: 11px;">{}</span>',
|
|
color,
|
|
label,
|
|
)
|
|
|
|
@admin.display(description="Installations")
|
|
def installation_count(self, obj):
|
|
"""Display number of ride installations."""
|
|
if hasattr(obj, "_installation_count"):
|
|
return obj._installation_count
|
|
return obj.rides.count()
|
|
|
|
def get_queryset(self, request):
|
|
"""Optimize queryset with installation count annotation."""
|
|
qs = super().get_queryset(request)
|
|
qs = qs.annotate(_installation_count=Count("rides", distinct=True))
|
|
return qs
|
|
|
|
|
|
@admin.register(RollerCoasterStats)
|
|
class RollerCoasterStatsAdmin(QueryOptimizationMixin, ExportActionMixin, BaseModelAdmin):
|
|
"""
|
|
Admin interface for roller coaster statistics.
|
|
|
|
Manages detailed roller coaster specifications with
|
|
calculated capacity and record indicators.
|
|
"""
|
|
|
|
list_display = (
|
|
"ride_link",
|
|
"park_name",
|
|
"height_ft",
|
|
"speed_mph",
|
|
"length_ft",
|
|
"inversions",
|
|
"track_material",
|
|
"roller_coaster_type",
|
|
)
|
|
list_filter = (
|
|
"track_material",
|
|
"roller_coaster_type",
|
|
"propulsion_system",
|
|
"inversions",
|
|
)
|
|
list_select_related = ["ride", "ride__park", "ride__manufacturer"]
|
|
search_fields = (
|
|
"ride__name",
|
|
"ride__park__name",
|
|
"track_type",
|
|
"train_style",
|
|
)
|
|
readonly_fields = ("calculated_capacity",)
|
|
autocomplete_fields = ["ride"]
|
|
|
|
export_fields = [
|
|
"id",
|
|
"ride",
|
|
"height_ft",
|
|
"speed_mph",
|
|
"length_ft",
|
|
"inversions",
|
|
"track_material",
|
|
"roller_coaster_type",
|
|
]
|
|
export_filename_prefix = "coaster_stats"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Ride",
|
|
{
|
|
"fields": ("ride",),
|
|
"description": "The roller coaster these stats belong to.",
|
|
},
|
|
),
|
|
(
|
|
"Basic Stats",
|
|
{
|
|
"fields": (
|
|
"height_ft",
|
|
"length_ft",
|
|
"speed_mph",
|
|
"max_drop_height_ft",
|
|
),
|
|
"description": "Primary statistics for the roller coaster.",
|
|
},
|
|
),
|
|
(
|
|
"Track & Design",
|
|
{
|
|
"fields": (
|
|
"track_material",
|
|
"track_type",
|
|
"roller_coaster_type",
|
|
"propulsion_system",
|
|
"inversions",
|
|
),
|
|
"description": "Track construction and design details.",
|
|
},
|
|
),
|
|
(
|
|
"Operation Details",
|
|
{
|
|
"fields": (
|
|
"ride_time_seconds",
|
|
"train_style",
|
|
"trains_count",
|
|
"cars_per_train",
|
|
"seats_per_car",
|
|
"calculated_capacity",
|
|
),
|
|
"classes": ("collapse",),
|
|
"description": "Operational specifications and capacity.",
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Ride")
|
|
def ride_link(self, obj):
|
|
"""Display ride as clickable link."""
|
|
if obj.ride:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:rides_ride_change", args=[obj.ride.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.ride.name)
|
|
return "-"
|
|
|
|
@admin.display(description="Park")
|
|
def park_name(self, obj):
|
|
"""Display park name."""
|
|
if obj.ride and obj.ride.park:
|
|
return obj.ride.park.name
|
|
return "-"
|
|
|
|
@admin.display(description="Calculated Capacity")
|
|
def calculated_capacity(self, obj):
|
|
"""Calculate theoretical hourly capacity."""
|
|
if all(
|
|
[
|
|
obj.trains_count,
|
|
obj.cars_per_train,
|
|
obj.seats_per_car,
|
|
obj.ride_time_seconds,
|
|
]
|
|
):
|
|
total_seats = obj.trains_count * obj.cars_per_train * obj.seats_per_car
|
|
cycles_per_hour = 3600 / (obj.ride_time_seconds + 120) # 2 min loading
|
|
return f"{int(total_seats * cycles_per_hour)} riders/hour"
|
|
return "N/A"
|
|
|
|
@admin.action(description="Bulk update from ride model")
|
|
def sync_with_model(self, request, queryset):
|
|
"""Sync stats with ride model defaults where available."""
|
|
count = 0
|
|
for stats in queryset.select_related("ride__ride_model"):
|
|
if stats.ride and stats.ride.ride_model:
|
|
# Sync would happen here if model had default stats
|
|
count += 1
|
|
self.message_user(request, f"Synced {count} coaster stats with ride models.")
|
|
|
|
def get_actions(self, request):
|
|
"""Add custom actions to the admin."""
|
|
actions = super().get_actions(request)
|
|
actions["sync_with_model"] = (
|
|
self.sync_with_model,
|
|
"sync_with_model",
|
|
"Bulk update from ride model",
|
|
)
|
|
return actions
|
|
|
|
|
|
@admin.register(RideReview)
|
|
class RideReviewAdmin(QueryOptimizationMixin, ExportActionMixin, BaseModelAdmin):
|
|
"""
|
|
Admin interface for ride reviews.
|
|
|
|
Provides review moderation with:
|
|
- Bulk approve/reject actions
|
|
- Moderation status filtering
|
|
- User and ride linking
|
|
- Automatic moderation tracking
|
|
"""
|
|
|
|
list_display = (
|
|
"ride_link",
|
|
"park_name",
|
|
"user_link",
|
|
"rating_display",
|
|
"title",
|
|
"visit_date",
|
|
"is_published",
|
|
"moderation_status",
|
|
"created_at",
|
|
)
|
|
list_filter = (
|
|
"is_published",
|
|
"rating",
|
|
"visit_date",
|
|
"created_at",
|
|
"ride__park",
|
|
"ride__category",
|
|
)
|
|
list_select_related = ["ride", "ride__park", "user", "moderated_by"]
|
|
search_fields = (
|
|
"title",
|
|
"content",
|
|
"user__username",
|
|
"ride__name",
|
|
"ride__park__name",
|
|
)
|
|
readonly_fields = ("created_at", "updated_at", "moderated_by", "moderated_at")
|
|
autocomplete_fields = ["ride", "user"]
|
|
date_hierarchy = "created_at"
|
|
ordering = ("-created_at",)
|
|
|
|
export_fields = [
|
|
"id",
|
|
"ride",
|
|
"user",
|
|
"rating",
|
|
"title",
|
|
"visit_date",
|
|
"is_published",
|
|
"created_at",
|
|
]
|
|
export_filename_prefix = "ride_reviews"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Review Details",
|
|
{
|
|
"fields": (
|
|
"user",
|
|
"ride",
|
|
"rating",
|
|
"title",
|
|
"content",
|
|
"visit_date",
|
|
),
|
|
"description": "Core review information.",
|
|
},
|
|
),
|
|
(
|
|
"Publication Status",
|
|
{
|
|
"fields": ("is_published",),
|
|
"description": "Toggle to publish or unpublish this review.",
|
|
},
|
|
),
|
|
(
|
|
"Moderation",
|
|
{
|
|
"fields": (
|
|
"moderated_by",
|
|
"moderated_at",
|
|
"moderation_notes",
|
|
),
|
|
"classes": ("collapse",),
|
|
"description": "Moderation tracking (auto-populated on status change).",
|
|
},
|
|
),
|
|
(
|
|
"Metadata",
|
|
{
|
|
"fields": ("created_at", "updated_at"),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Ride")
|
|
def ride_link(self, obj):
|
|
"""Display ride as clickable link."""
|
|
if obj.ride:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:rides_ride_change", args=[obj.ride.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.ride.name)
|
|
return "-"
|
|
|
|
@admin.display(description="Park")
|
|
def park_name(self, obj):
|
|
"""Display park name."""
|
|
if obj.ride and obj.ride.park:
|
|
return obj.ride.park.name
|
|
return "-"
|
|
|
|
@admin.display(description="User")
|
|
def user_link(self, obj):
|
|
"""Display user as clickable link."""
|
|
if obj.user:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
|
return "-"
|
|
|
|
@admin.display(description="Rating")
|
|
def rating_display(self, obj):
|
|
"""Display rating as stars."""
|
|
if obj.rating:
|
|
stars = "★" * obj.rating + "☆" * (5 - obj.rating)
|
|
return format_html('<span style="color: gold;">{}</span>', stars)
|
|
return "-"
|
|
|
|
@admin.display(description="Moderation")
|
|
def moderation_status(self, obj):
|
|
"""Display moderation status with color coding."""
|
|
if obj.moderated_by:
|
|
if obj.is_published:
|
|
return format_html(
|
|
'<span style="color: green; font-weight: bold;">Approved</span>'
|
|
)
|
|
return format_html(
|
|
'<span style="color: red; font-weight: bold;">Rejected</span>'
|
|
)
|
|
return format_html('<span style="color: orange;">Pending</span>')
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
"""Auto-set moderation info when publication status changes."""
|
|
if change and "is_published" in form.changed_data:
|
|
obj.moderated_by = request.user
|
|
obj.moderated_at = timezone.now()
|
|
super().save_model(request, obj, form, change)
|
|
|
|
@admin.action(description="Approve and publish selected reviews")
|
|
def bulk_approve(self, request, queryset):
|
|
"""Approve and publish all selected reviews."""
|
|
updated = queryset.update(
|
|
is_published=True,
|
|
moderated_by=request.user,
|
|
moderated_at=timezone.now(),
|
|
)
|
|
self.message_user(request, f"Successfully approved {updated} reviews.")
|
|
|
|
@admin.action(description="Reject selected reviews")
|
|
def bulk_reject(self, request, queryset):
|
|
"""Reject and unpublish all selected reviews."""
|
|
updated = queryset.update(
|
|
is_published=False,
|
|
moderated_by=request.user,
|
|
moderated_at=timezone.now(),
|
|
)
|
|
self.message_user(request, f"Successfully rejected {updated} reviews.")
|
|
|
|
@admin.action(description="Flag for review")
|
|
def flag_for_review(self, request, queryset):
|
|
"""Flag suspicious reviews for closer review."""
|
|
updated = queryset.update(is_published=False)
|
|
self.message_user(request, f"Flagged {updated} reviews for review.")
|
|
|
|
def get_actions(self, request):
|
|
"""Add moderation actions to the admin."""
|
|
actions = super().get_actions(request)
|
|
actions["bulk_approve"] = (
|
|
self.bulk_approve,
|
|
"bulk_approve",
|
|
"Approve and publish selected reviews",
|
|
)
|
|
actions["bulk_reject"] = (
|
|
self.bulk_reject,
|
|
"bulk_reject",
|
|
"Reject selected reviews",
|
|
)
|
|
actions["flag_for_review"] = (
|
|
self.flag_for_review,
|
|
"flag_for_review",
|
|
"Flag for review",
|
|
)
|
|
return actions
|
|
|
|
|
|
@admin.register(Company)
|
|
class CompanyAdmin(
|
|
QueryOptimizationMixin,
|
|
ExportActionMixin,
|
|
SlugFieldMixin,
|
|
TimestampFieldsMixin,
|
|
BaseModelAdmin,
|
|
):
|
|
"""
|
|
Admin interface for Company (manufacturers/designers) management.
|
|
|
|
Manages ride manufacturers and designers with:
|
|
- Role-based filtering
|
|
- Ride and coaster count annotations
|
|
- Enhanced search capabilities
|
|
"""
|
|
|
|
list_display = (
|
|
"name",
|
|
"roles_display",
|
|
"website",
|
|
"founded_date",
|
|
"rides_count_display",
|
|
"coasters_count_display",
|
|
)
|
|
list_filter = ("roles", "founded_date")
|
|
list_prefetch_related = ["manufactured_rides", "designed_rides", "ride_models"]
|
|
search_fields = ("name", "slug", "description")
|
|
readonly_fields = ("created_at", "updated_at", "rides_count", "coasters_count")
|
|
|
|
export_fields = ["id", "name", "slug", "roles", "website", "founded_date", "created_at"]
|
|
export_filename_prefix = "ride_companies"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Basic Information",
|
|
{
|
|
"fields": (
|
|
"name",
|
|
"slug",
|
|
"roles",
|
|
"description",
|
|
"website",
|
|
),
|
|
"description": "Company identification and role.",
|
|
},
|
|
),
|
|
(
|
|
"Company Details",
|
|
{
|
|
"fields": (
|
|
"founded_date",
|
|
),
|
|
"classes": ("collapse",),
|
|
"description": "Historical information about the company.",
|
|
},
|
|
),
|
|
(
|
|
"Statistics",
|
|
{
|
|
"fields": ("rides_count", "coasters_count"),
|
|
"classes": ("collapse",),
|
|
"description": "Auto-calculated ride counts.",
|
|
},
|
|
),
|
|
(
|
|
"Metadata",
|
|
{
|
|
"fields": ("created_at", "updated_at"),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Roles")
|
|
def roles_display(self, obj):
|
|
"""Display roles as formatted badges."""
|
|
if obj.roles:
|
|
badges = []
|
|
colors = {
|
|
"MANUFACTURER": "#007bff",
|
|
"DESIGNER": "#28a745",
|
|
}
|
|
for role in obj.roles:
|
|
color = colors.get(role, "#6c757d")
|
|
badges.append(
|
|
f'<span style="background-color: {color}; color: white; '
|
|
f'padding: 2px 6px; border-radius: 3px; font-size: 10px; '
|
|
f'margin-right: 4px;">{role}</span>'
|
|
)
|
|
return format_html("".join(badges))
|
|
return "-"
|
|
|
|
@admin.display(description="Rides")
|
|
def rides_count_display(self, obj):
|
|
"""Display total ride count."""
|
|
if hasattr(obj, "_rides_count"):
|
|
return obj._rides_count
|
|
return obj.rides_count if hasattr(obj, "rides_count") else "-"
|
|
|
|
@admin.display(description="Coasters")
|
|
def coasters_count_display(self, obj):
|
|
"""Display coaster count."""
|
|
if hasattr(obj, "_coasters_count"):
|
|
return obj._coasters_count
|
|
return obj.coasters_count if hasattr(obj, "coasters_count") else "-"
|
|
|
|
def get_queryset(self, request):
|
|
"""Optimize queryset with ride count annotations."""
|
|
qs = super().get_queryset(request)
|
|
qs = qs.annotate(
|
|
_rides_count=Count("manufactured_rides", distinct=True),
|
|
_coasters_count=Count(
|
|
"manufactured_rides",
|
|
filter=admin.models.Q(manufactured_rides__category="roller_coaster"),
|
|
distinct=True,
|
|
),
|
|
)
|
|
return qs
|
|
|
|
|
|
@admin.register(RideRanking)
|
|
class RideRankingAdmin(ReadOnlyAdminMixin, ExportActionMixin, BaseModelAdmin):
|
|
"""
|
|
Admin interface for ride rankings.
|
|
|
|
Read-only admin for viewing calculated ride rankings.
|
|
Rankings are automatically calculated and should not be
|
|
manually modified.
|
|
"""
|
|
|
|
list_display = (
|
|
"rank",
|
|
"ride_link",
|
|
"park_name",
|
|
"winning_percentage_display",
|
|
"wins",
|
|
"losses",
|
|
"ties",
|
|
"average_rating",
|
|
"mutual_riders_count",
|
|
"last_calculated",
|
|
)
|
|
list_filter = (
|
|
"ride__category",
|
|
"last_calculated",
|
|
"calculation_version",
|
|
)
|
|
list_select_related = ["ride", "ride__park"]
|
|
search_fields = (
|
|
"ride__name",
|
|
"ride__park__name",
|
|
)
|
|
readonly_fields = (
|
|
"ride",
|
|
"rank",
|
|
"wins",
|
|
"losses",
|
|
"ties",
|
|
"winning_percentage",
|
|
"mutual_riders_count",
|
|
"comparison_count",
|
|
"average_rating",
|
|
"last_calculated",
|
|
"calculation_version",
|
|
"total_comparisons",
|
|
)
|
|
ordering = ["rank"]
|
|
|
|
export_fields = [
|
|
"rank",
|
|
"ride",
|
|
"winning_percentage",
|
|
"wins",
|
|
"losses",
|
|
"ties",
|
|
"average_rating",
|
|
"last_calculated",
|
|
]
|
|
export_filename_prefix = "ride_rankings"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Ride Information",
|
|
{
|
|
"fields": ("ride",),
|
|
"description": "The ride this ranking belongs to.",
|
|
},
|
|
),
|
|
(
|
|
"Ranking Metrics",
|
|
{
|
|
"fields": (
|
|
"rank",
|
|
"winning_percentage",
|
|
"wins",
|
|
"losses",
|
|
"ties",
|
|
"total_comparisons",
|
|
),
|
|
"description": "Head-to-head comparison results.",
|
|
},
|
|
),
|
|
(
|
|
"Additional Metrics",
|
|
{
|
|
"fields": (
|
|
"average_rating",
|
|
"mutual_riders_count",
|
|
"comparison_count",
|
|
),
|
|
"description": "Supporting metrics for ranking calculation.",
|
|
},
|
|
),
|
|
(
|
|
"Calculation Info",
|
|
{
|
|
"fields": (
|
|
"last_calculated",
|
|
"calculation_version",
|
|
),
|
|
"classes": ("collapse",),
|
|
"description": "When and how this ranking was calculated.",
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Ride")
|
|
def ride_link(self, obj):
|
|
"""Display ride as clickable link."""
|
|
if obj.ride:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:rides_ride_change", args=[obj.ride.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.ride.name)
|
|
return "-"
|
|
|
|
@admin.display(description="Park")
|
|
def park_name(self, obj):
|
|
"""Display park name."""
|
|
if obj.ride and obj.ride.park:
|
|
return obj.ride.park.name
|
|
return "-"
|
|
|
|
@admin.display(description="Win %")
|
|
def winning_percentage_display(self, obj):
|
|
"""Display winning percentage formatted."""
|
|
return f"{obj.winning_percentage:.1%}"
|
|
|
|
|
|
@admin.register(RidePairComparison)
|
|
class RidePairComparisonAdmin(ReadOnlyAdminMixin, ExportActionMixin, BaseModelAdmin):
|
|
"""
|
|
Admin interface for ride pair comparisons.
|
|
|
|
Read-only admin for viewing head-to-head ride comparison data.
|
|
Comparisons are automatically calculated.
|
|
"""
|
|
|
|
list_display = (
|
|
"comparison_summary",
|
|
"ride_a_link",
|
|
"ride_b_link",
|
|
"winner_display",
|
|
"ride_a_wins",
|
|
"ride_b_wins",
|
|
"ties",
|
|
"mutual_riders_count",
|
|
"last_calculated",
|
|
)
|
|
list_filter = ("last_calculated",)
|
|
list_select_related = ["ride_a", "ride_a__park", "ride_b", "ride_b__park"]
|
|
search_fields = (
|
|
"ride_a__name",
|
|
"ride_b__name",
|
|
"ride_a__park__name",
|
|
"ride_b__park__name",
|
|
)
|
|
readonly_fields = (
|
|
"ride_a",
|
|
"ride_b",
|
|
"ride_a_wins",
|
|
"ride_b_wins",
|
|
"ties",
|
|
"mutual_riders_count",
|
|
"ride_a_avg_rating",
|
|
"ride_b_avg_rating",
|
|
"last_calculated",
|
|
"winner",
|
|
"is_tie",
|
|
)
|
|
ordering = ["-mutual_riders_count"]
|
|
|
|
export_fields = [
|
|
"ride_a",
|
|
"ride_b",
|
|
"ride_a_wins",
|
|
"ride_b_wins",
|
|
"ties",
|
|
"mutual_riders_count",
|
|
"last_calculated",
|
|
]
|
|
export_filename_prefix = "ride_comparisons"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Rides Being Compared",
|
|
{
|
|
"fields": ("ride_a", "ride_b"),
|
|
"description": "The two rides in this comparison.",
|
|
},
|
|
),
|
|
(
|
|
"Comparison Results",
|
|
{
|
|
"fields": (
|
|
"ride_a_wins",
|
|
"ride_b_wins",
|
|
"ties",
|
|
"winner",
|
|
"is_tie",
|
|
),
|
|
"description": "Head-to-head comparison results.",
|
|
},
|
|
),
|
|
(
|
|
"Rating Data",
|
|
{
|
|
"fields": (
|
|
"ride_a_avg_rating",
|
|
"ride_b_avg_rating",
|
|
"mutual_riders_count",
|
|
),
|
|
"description": "Rating metrics for comparison.",
|
|
},
|
|
),
|
|
(
|
|
"Calculation Info",
|
|
{
|
|
"fields": ("last_calculated",),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Comparison")
|
|
def comparison_summary(self, obj):
|
|
"""Display comparison summary."""
|
|
return f"{obj.ride_a.name} vs {obj.ride_b.name}"
|
|
|
|
@admin.display(description="Ride A")
|
|
def ride_a_link(self, obj):
|
|
"""Display ride A as clickable link."""
|
|
if obj.ride_a:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:rides_ride_change", args=[obj.ride_a.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.ride_a.name)
|
|
return "-"
|
|
|
|
@admin.display(description="Ride B")
|
|
def ride_b_link(self, obj):
|
|
"""Display ride B as clickable link."""
|
|
if obj.ride_b:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:rides_ride_change", args=[obj.ride_b.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.ride_b.name)
|
|
return "-"
|
|
|
|
@admin.display(description="Winner")
|
|
def winner_display(self, obj):
|
|
"""Display winner or tie status."""
|
|
if obj.is_tie:
|
|
return format_html('<span style="color: orange;">TIE</span>')
|
|
winner = obj.winner
|
|
if winner:
|
|
return format_html('<span style="color: green;">{}</span>', winner.name)
|
|
return "-"
|
|
|
|
|
|
@admin.register(RankingSnapshot)
|
|
class RankingSnapshotAdmin(ReadOnlyAdminMixin, ExportActionMixin, BaseModelAdmin):
|
|
"""
|
|
Admin interface for ranking history snapshots.
|
|
|
|
Read-only admin for viewing historical ranking data.
|
|
Snapshots are automatically created during ranking calculations.
|
|
"""
|
|
|
|
list_display = (
|
|
"ride_link",
|
|
"park_name",
|
|
"rank",
|
|
"winning_percentage_display",
|
|
"snapshot_date",
|
|
)
|
|
list_filter = (
|
|
"snapshot_date",
|
|
"ride__category",
|
|
)
|
|
list_select_related = ["ride", "ride__park"]
|
|
search_fields = (
|
|
"ride__name",
|
|
"ride__park__name",
|
|
)
|
|
readonly_fields = (
|
|
"ride",
|
|
"rank",
|
|
"winning_percentage",
|
|
"snapshot_date",
|
|
)
|
|
date_hierarchy = "snapshot_date"
|
|
ordering = ["-snapshot_date", "rank"]
|
|
|
|
export_fields = ["ride", "rank", "winning_percentage", "snapshot_date"]
|
|
export_filename_prefix = "ranking_snapshots"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Ride Information",
|
|
{
|
|
"fields": ("ride",),
|
|
"description": "The ride this snapshot belongs to.",
|
|
},
|
|
),
|
|
(
|
|
"Ranking Data",
|
|
{
|
|
"fields": (
|
|
"rank",
|
|
"winning_percentage",
|
|
"snapshot_date",
|
|
),
|
|
"description": "Historical ranking data at this point in time.",
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Ride")
|
|
def ride_link(self, obj):
|
|
"""Display ride as clickable link."""
|
|
if obj.ride:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:rides_ride_change", args=[obj.ride.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.ride.name)
|
|
return "-"
|
|
|
|
@admin.display(description="Park")
|
|
def park_name(self, obj):
|
|
"""Display park name."""
|
|
if obj.ride and obj.ride.park:
|
|
return obj.ride.park.name
|
|
return "-"
|
|
|
|
@admin.display(description="Win %")
|
|
def winning_percentage_display(self, obj):
|
|
"""Display winning percentage formatted."""
|
|
return f"{obj.winning_percentage:.1%}"
|
|
|
|
|
|
# Register standalone admin
|
|
admin.site.register(RideLocation, RideLocationAdmin)
|