mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 05:51:11 -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.
1012 lines
30 KiB
Python
1012 lines
30 KiB
Python
"""
|
|
Django admin configuration for the Parks application.
|
|
|
|
This module provides comprehensive admin interfaces for managing parks, park areas,
|
|
companies, locations, and reviews. All admin classes use optimized querysets and
|
|
follow the standardized admin patterns defined in apps.core.admin.
|
|
|
|
Performance targets:
|
|
- List views: < 10 queries
|
|
- Change views: < 15 queries
|
|
- Page load time: < 500ms for 100 records
|
|
"""
|
|
|
|
import pghistory.models
|
|
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 import (
|
|
Company,
|
|
CompanyHeadquarters,
|
|
Park,
|
|
ParkArea,
|
|
ParkLocation,
|
|
ParkReview,
|
|
)
|
|
|
|
|
|
class ParkLocationInline(admin.StackedInline):
|
|
"""
|
|
Inline admin for ParkLocation within Park admin.
|
|
|
|
Displays location information in a collapsible section with fields
|
|
organized for efficient data entry.
|
|
"""
|
|
|
|
model = ParkLocation
|
|
extra = 0
|
|
classes = ("collapse",)
|
|
fields = (
|
|
("city", "state", "country"),
|
|
"street_address",
|
|
"postal_code",
|
|
"point",
|
|
("highway_exit", "best_arrival_time"),
|
|
"parking_notes",
|
|
"seasonal_notes",
|
|
("osm_id", "osm_type"),
|
|
)
|
|
|
|
|
|
class ParkAreaInline(admin.TabularInline):
|
|
"""
|
|
Inline admin for ParkArea within Park admin.
|
|
|
|
Shows areas as a collapsed tabular list for quick overview.
|
|
"""
|
|
|
|
model = ParkArea
|
|
extra = 0
|
|
classes = ("collapse",)
|
|
fields = ("name", "slug", "description")
|
|
prepopulated_fields = {"slug": ("name",)}
|
|
show_change_link = True
|
|
|
|
|
|
class ParkLocationAdmin(QueryOptimizationMixin, GISModelAdmin):
|
|
"""
|
|
Admin interface for standalone ParkLocation management.
|
|
|
|
Provides full GIS functionality with map widgets for geographic
|
|
coordinate entry. Optimized with select_related for park data.
|
|
"""
|
|
|
|
list_display = (
|
|
"park_link",
|
|
"city",
|
|
"state",
|
|
"country",
|
|
"latitude",
|
|
"longitude",
|
|
"has_coordinates",
|
|
)
|
|
list_filter = ("country", "state")
|
|
list_select_related = ["park"]
|
|
search_fields = (
|
|
"park__name",
|
|
"city",
|
|
"state",
|
|
"country",
|
|
"street_address",
|
|
)
|
|
readonly_fields = ("latitude", "longitude", "coordinates", "created_at", "updated_at")
|
|
autocomplete_fields = ["park"]
|
|
list_per_page = 50
|
|
show_full_result_count = False
|
|
|
|
fieldsets = (
|
|
(
|
|
"Park",
|
|
{
|
|
"fields": ("park",),
|
|
"description": "Select the park for this location.",
|
|
},
|
|
),
|
|
(
|
|
"Address",
|
|
{
|
|
"fields": (
|
|
"street_address",
|
|
"city",
|
|
"state",
|
|
"country",
|
|
"postal_code",
|
|
),
|
|
"description": "Enter the physical address of the park.",
|
|
},
|
|
),
|
|
(
|
|
"Geographic Coordinates",
|
|
{
|
|
"fields": ("point", "latitude", "longitude", "coordinates"),
|
|
"description": "Set coordinates by clicking on the map or entering latitude/longitude. "
|
|
"Coordinates are required for map display.",
|
|
},
|
|
),
|
|
(
|
|
"Travel Information",
|
|
{
|
|
"fields": (
|
|
"highway_exit",
|
|
"best_arrival_time",
|
|
"parking_notes",
|
|
"seasonal_notes",
|
|
),
|
|
"classes": ("collapse",),
|
|
"description": "Optional travel tips and parking information for visitors.",
|
|
},
|
|
),
|
|
(
|
|
"OpenStreetMap Integration",
|
|
{
|
|
"fields": ("osm_id", "osm_type"),
|
|
"classes": ("collapse",),
|
|
"description": "OpenStreetMap identifiers for data synchronization.",
|
|
},
|
|
),
|
|
(
|
|
"Metadata",
|
|
{
|
|
"fields": ("created_at", "updated_at"),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Park")
|
|
def park_link(self, obj):
|
|
"""Display park name as a clickable link to the park admin."""
|
|
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="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
|
|
|
|
|
|
class ParkAdmin(
|
|
QueryOptimizationMixin,
|
|
ExportActionMixin,
|
|
SlugFieldMixin,
|
|
TimestampFieldsMixin,
|
|
BaseModelAdmin,
|
|
):
|
|
"""
|
|
Admin interface for Park management.
|
|
|
|
Provides comprehensive park administration with:
|
|
- Optimized queries using select_related/prefetch_related
|
|
- Bulk actions for status changes and exports
|
|
- Inline editing of locations and areas
|
|
- Enhanced filtering and search capabilities
|
|
|
|
Query optimizations:
|
|
- select_related: operator, property_owner, location, banner_image, card_image
|
|
- prefetch_related: areas, rides
|
|
"""
|
|
|
|
list_display = (
|
|
"name",
|
|
"formatted_location",
|
|
"status_badge",
|
|
"operator_link",
|
|
"ride_count",
|
|
"average_rating",
|
|
"created_at",
|
|
)
|
|
list_filter = ("status", "location__country", "location__state", "operator")
|
|
list_select_related = ["operator", "property_owner", "location", "banner_image", "card_image"]
|
|
list_prefetch_related = ["areas", "rides"]
|
|
search_fields = (
|
|
"name",
|
|
"slug",
|
|
"description",
|
|
"location__city",
|
|
"location__state",
|
|
"location__country",
|
|
"operator__name",
|
|
)
|
|
readonly_fields = ("created_at", "updated_at", "ride_count", "average_rating")
|
|
autocomplete_fields = ["operator", "property_owner", "banner_image", "card_image"]
|
|
date_hierarchy = "created_at"
|
|
ordering = ("-created_at",)
|
|
inlines = [ParkLocationInline, ParkAreaInline]
|
|
|
|
export_fields = ["id", "name", "slug", "status", "created_at", "updated_at"]
|
|
export_filename_prefix = "parks"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Basic Information",
|
|
{
|
|
"fields": ("name", "slug", "description"),
|
|
"description": "Core park identification and description.",
|
|
},
|
|
),
|
|
(
|
|
"Status",
|
|
{
|
|
"fields": ("status",),
|
|
"description": "Current operational status of the park.",
|
|
},
|
|
),
|
|
(
|
|
"Ownership & Operation",
|
|
{
|
|
"fields": ("operator", "property_owner"),
|
|
"description": "Companies responsible for operating and owning the park.",
|
|
},
|
|
),
|
|
(
|
|
"Media",
|
|
{
|
|
"fields": ("banner_image", "card_image"),
|
|
"classes": ("collapse",),
|
|
"description": "Images displayed in banners and card views.",
|
|
},
|
|
),
|
|
(
|
|
"Statistics",
|
|
{
|
|
"fields": ("ride_count", "average_rating"),
|
|
"classes": ("collapse",),
|
|
"description": "Auto-calculated statistics (read-only).",
|
|
},
|
|
),
|
|
(
|
|
"Metadata",
|
|
{
|
|
"fields": ("created_at", "updated_at"),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Location")
|
|
def formatted_location(self, obj):
|
|
"""Display formatted location string."""
|
|
return obj.formatted_location or "-"
|
|
|
|
@admin.display(description="Status")
|
|
def status_badge(self, obj):
|
|
"""Display status with color-coded badge."""
|
|
colors = {
|
|
"operating": "green",
|
|
"closed": "red",
|
|
"seasonal": "orange",
|
|
"under_construction": "blue",
|
|
}
|
|
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="Operator")
|
|
def operator_link(self, obj):
|
|
"""Display operator as clickable link."""
|
|
if obj.operator:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:parks_company_change", args=[obj.operator.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.operator.name)
|
|
return "-"
|
|
|
|
@admin.display(description="Rides")
|
|
def ride_count(self, obj):
|
|
"""Display count of rides at this park."""
|
|
if hasattr(obj, "_ride_count"):
|
|
return obj._ride_count
|
|
return obj.rides.count()
|
|
|
|
@admin.display(description="Avg Rating")
|
|
def average_rating(self, obj):
|
|
"""Display average park review rating."""
|
|
if hasattr(obj, "_avg_rating"):
|
|
rating = obj._avg_rating
|
|
else:
|
|
rating = obj.reviews.aggregate(avg=Avg("rating"))["avg"]
|
|
if rating:
|
|
stars = "★" * int(rating) + "☆" * (5 - int(rating))
|
|
return format_html('<span style="color: gold;">{}</span> {:.1f}', stars, rating)
|
|
return "-"
|
|
|
|
def get_queryset(self, request):
|
|
"""Optimize queryset with annotations for list display."""
|
|
qs = super().get_queryset(request)
|
|
qs = qs.annotate(
|
|
_ride_count=Count("rides", distinct=True),
|
|
_avg_rating=Avg("reviews__rating"),
|
|
)
|
|
return qs
|
|
|
|
@admin.action(description="Activate selected parks")
|
|
def bulk_activate(self, request, queryset):
|
|
"""Set status to operating for selected parks."""
|
|
updated = queryset.update(status="operating")
|
|
self.message_user(request, f"Successfully activated {updated} parks.")
|
|
|
|
@admin.action(description="Deactivate selected parks")
|
|
def bulk_deactivate(self, request, queryset):
|
|
"""Set status to closed for selected parks."""
|
|
updated = queryset.update(status="closed")
|
|
self.message_user(request, f"Successfully deactivated {updated} parks.")
|
|
|
|
@admin.action(description="Recalculate park statistics")
|
|
def recalculate_stats(self, request, queryset):
|
|
"""Recalculate ride counts and ratings for selected parks."""
|
|
for park in queryset:
|
|
# Statistics are auto-calculated, so just touch the record
|
|
park.save(update_fields=["updated_at"])
|
|
self.message_user(
|
|
request, f"Successfully recalculated statistics for {queryset.count()} parks."
|
|
)
|
|
|
|
def get_actions(self, request):
|
|
"""Add custom actions to the admin."""
|
|
actions = super().get_actions(request)
|
|
actions["bulk_activate"] = (
|
|
self.bulk_activate,
|
|
"bulk_activate",
|
|
"Activate selected parks",
|
|
)
|
|
actions["bulk_deactivate"] = (
|
|
self.bulk_deactivate,
|
|
"bulk_deactivate",
|
|
"Deactivate selected parks",
|
|
)
|
|
actions["recalculate_stats"] = (
|
|
self.recalculate_stats,
|
|
"recalculate_stats",
|
|
"Recalculate park statistics",
|
|
)
|
|
return actions
|
|
|
|
|
|
class ParkAreaAdmin(
|
|
QueryOptimizationMixin,
|
|
ExportActionMixin,
|
|
SlugFieldMixin,
|
|
TimestampFieldsMixin,
|
|
BaseModelAdmin,
|
|
):
|
|
"""
|
|
Admin interface for ParkArea management.
|
|
|
|
Manages themed areas within parks with optimized queries
|
|
and ride count annotations.
|
|
"""
|
|
|
|
list_display = ("name", "park_link", "ride_count", "created_at", "updated_at")
|
|
list_filter = ("park",)
|
|
list_select_related = ["park"]
|
|
list_prefetch_related = ["rides"]
|
|
search_fields = ("name", "slug", "description", "park__name")
|
|
readonly_fields = ("created_at", "updated_at", "ride_count")
|
|
autocomplete_fields = ["park"]
|
|
|
|
export_fields = ["id", "name", "slug", "park", "created_at"]
|
|
export_filename_prefix = "park_areas"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Basic Information",
|
|
{
|
|
"fields": ("name", "slug", "description"),
|
|
"description": "Area identification within the park.",
|
|
},
|
|
),
|
|
(
|
|
"Park",
|
|
{
|
|
"fields": ("park",),
|
|
"description": "The park this area belongs to.",
|
|
},
|
|
),
|
|
(
|
|
"Statistics",
|
|
{
|
|
"fields": ("ride_count",),
|
|
"classes": ("collapse",),
|
|
"description": "Auto-calculated statistics.",
|
|
},
|
|
),
|
|
(
|
|
"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="Rides")
|
|
def ride_count(self, obj):
|
|
"""Display count of rides in this area."""
|
|
if hasattr(obj, "_ride_count"):
|
|
return obj._ride_count
|
|
return obj.rides.count()
|
|
|
|
def get_queryset(self, request):
|
|
"""Optimize queryset with ride count annotation."""
|
|
qs = super().get_queryset(request)
|
|
qs = qs.annotate(_ride_count=Count("rides", distinct=True))
|
|
return qs
|
|
|
|
|
|
class CompanyHeadquartersInline(admin.StackedInline):
|
|
"""
|
|
Inline admin for CompanyHeadquarters within Company admin.
|
|
|
|
Displays headquarters address information in a collapsible section.
|
|
"""
|
|
|
|
model = CompanyHeadquarters
|
|
extra = 0
|
|
classes = ("collapse",)
|
|
fields = (
|
|
("city", "state_province", "country"),
|
|
"street_address",
|
|
"postal_code",
|
|
"mailing_address",
|
|
)
|
|
|
|
|
|
class CompanyHeadquartersAdmin(
|
|
QueryOptimizationMixin, TimestampFieldsMixin, BaseModelAdmin
|
|
):
|
|
"""
|
|
Admin interface for standalone CompanyHeadquarters management.
|
|
|
|
Provides headquarters address management with company linking
|
|
and location-based filtering.
|
|
"""
|
|
|
|
list_display = (
|
|
"company_link",
|
|
"location_display",
|
|
"city",
|
|
"country",
|
|
"created_at",
|
|
)
|
|
list_filter = ("country", "state_province")
|
|
list_select_related = ["company"]
|
|
search_fields = (
|
|
"company__name",
|
|
"city",
|
|
"state_province",
|
|
"country",
|
|
"street_address",
|
|
)
|
|
readonly_fields = ("created_at", "updated_at")
|
|
autocomplete_fields = ["company"]
|
|
|
|
fieldsets = (
|
|
(
|
|
"Company",
|
|
{
|
|
"fields": ("company",),
|
|
"description": "The company this headquarters belongs to.",
|
|
},
|
|
),
|
|
(
|
|
"Address",
|
|
{
|
|
"fields": (
|
|
"street_address",
|
|
"city",
|
|
"state_province",
|
|
"country",
|
|
"postal_code",
|
|
),
|
|
"description": "Physical address of the company headquarters.",
|
|
},
|
|
),
|
|
(
|
|
"Additional Information",
|
|
{
|
|
"fields": ("mailing_address",),
|
|
"classes": ("collapse",),
|
|
"description": "Mailing address if different from physical address.",
|
|
},
|
|
),
|
|
(
|
|
"Metadata",
|
|
{
|
|
"fields": ("created_at", "updated_at"),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Company")
|
|
def company_link(self, obj):
|
|
"""Display company as clickable link."""
|
|
if obj.company:
|
|
from django.urls import reverse
|
|
|
|
url = reverse("admin:parks_company_change", args=[obj.company.pk])
|
|
return format_html('<a href="{}">{}</a>', url, obj.company.name)
|
|
return "-"
|
|
|
|
|
|
class CompanyAdmin(
|
|
QueryOptimizationMixin,
|
|
ExportActionMixin,
|
|
SlugFieldMixin,
|
|
TimestampFieldsMixin,
|
|
BaseModelAdmin,
|
|
):
|
|
"""
|
|
Admin interface for Company management.
|
|
|
|
Manages park operators and property owners with:
|
|
- Role-based filtering
|
|
- Park count annotations
|
|
- Headquarters inline editing
|
|
- Search by headquarters location
|
|
|
|
Query optimizations:
|
|
- prefetch_related: operated_parks, owned_parks, headquarters
|
|
"""
|
|
|
|
list_display = (
|
|
"name",
|
|
"roles_display",
|
|
"headquarters_location",
|
|
"operated_parks_count",
|
|
"owned_parks_count",
|
|
"website",
|
|
"founded_year",
|
|
)
|
|
list_filter = ("roles", "founded_year")
|
|
list_prefetch_related = ["operated_parks", "owned_parks"]
|
|
search_fields = (
|
|
"name",
|
|
"slug",
|
|
"description",
|
|
"headquarters__city",
|
|
"headquarters__country",
|
|
)
|
|
readonly_fields = ("created_at", "updated_at", "operated_parks_count", "owned_parks_count")
|
|
inlines = [CompanyHeadquartersInline]
|
|
|
|
export_fields = ["id", "name", "slug", "roles", "website", "founded_year", "created_at"]
|
|
export_filename_prefix = "companies"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Basic Information",
|
|
{
|
|
"fields": ("name", "slug", "description"),
|
|
"description": "Company identification and description.",
|
|
},
|
|
),
|
|
(
|
|
"Roles",
|
|
{
|
|
"fields": ("roles",),
|
|
"description": "Select all roles this company performs (operator, owner, etc.).",
|
|
},
|
|
),
|
|
(
|
|
"Contact & Links",
|
|
{
|
|
"fields": ("website",),
|
|
"description": "Company website and contact information.",
|
|
},
|
|
),
|
|
(
|
|
"History",
|
|
{
|
|
"fields": ("founded_year",),
|
|
"classes": ("collapse",),
|
|
"description": "Historical information about the company.",
|
|
},
|
|
),
|
|
(
|
|
"Statistics",
|
|
{
|
|
"fields": ("operated_parks_count", "owned_parks_count"),
|
|
"classes": ("collapse",),
|
|
"description": "Auto-calculated park 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 = {"operator": "#007bff", "owner": "#28a745", "manufacturer": "#6c757d"}
|
|
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="Headquarters")
|
|
def headquarters_location(self, obj):
|
|
"""Display headquarters location if available."""
|
|
if hasattr(obj, "headquarters"):
|
|
return obj.headquarters.location_display
|
|
return "-"
|
|
|
|
@admin.display(description="Operated")
|
|
def operated_parks_count(self, obj):
|
|
"""Display count of parks operated by this company."""
|
|
if hasattr(obj, "_operated_count"):
|
|
return obj._operated_count
|
|
return obj.operated_parks.count()
|
|
|
|
@admin.display(description="Owned")
|
|
def owned_parks_count(self, obj):
|
|
"""Display count of parks owned by this company."""
|
|
if hasattr(obj, "_owned_count"):
|
|
return obj._owned_count
|
|
return obj.owned_parks.count()
|
|
|
|
def get_queryset(self, request):
|
|
"""Optimize queryset with park count annotations."""
|
|
qs = super().get_queryset(request)
|
|
qs = qs.annotate(
|
|
_operated_count=Count("operated_parks", distinct=True),
|
|
_owned_count=Count("owned_parks", distinct=True),
|
|
)
|
|
return qs
|
|
|
|
@admin.action(description="Update park counts")
|
|
def update_park_counts(self, request, queryset):
|
|
"""Refresh park count statistics for selected companies."""
|
|
for company in queryset:
|
|
company.save(update_fields=["updated_at"])
|
|
self.message_user(
|
|
request, f"Successfully updated counts for {queryset.count()} companies."
|
|
)
|
|
|
|
def get_actions(self, request):
|
|
"""Add custom actions to the admin."""
|
|
actions = super().get_actions(request)
|
|
actions["update_park_counts"] = (
|
|
self.update_park_counts,
|
|
"update_park_counts",
|
|
"Update park counts",
|
|
)
|
|
return actions
|
|
|
|
|
|
@admin.register(ParkReview)
|
|
class ParkReviewAdmin(QueryOptimizationMixin, ExportActionMixin, BaseModelAdmin):
|
|
"""
|
|
Admin interface for park reviews.
|
|
|
|
Provides review moderation with:
|
|
- Bulk approve/reject actions
|
|
- Moderation status filtering
|
|
- User and park linking
|
|
- Automatic moderation tracking
|
|
|
|
Query optimizations:
|
|
- select_related: park, user, moderated_by
|
|
"""
|
|
|
|
list_display = (
|
|
"park_link",
|
|
"user_link",
|
|
"rating_display",
|
|
"title",
|
|
"visit_date",
|
|
"is_published",
|
|
"moderation_status",
|
|
"created_at",
|
|
)
|
|
list_filter = (
|
|
"is_published",
|
|
"rating",
|
|
"visit_date",
|
|
"created_at",
|
|
"park",
|
|
)
|
|
list_select_related = ["park", "user", "moderated_by"]
|
|
search_fields = (
|
|
"title",
|
|
"content",
|
|
"user__username",
|
|
"park__name",
|
|
)
|
|
readonly_fields = ("created_at", "updated_at", "moderated_by", "moderated_at")
|
|
autocomplete_fields = ["park", "user"]
|
|
date_hierarchy = "created_at"
|
|
ordering = ("-created_at",)
|
|
|
|
export_fields = ["id", "park", "user", "rating", "title", "visit_date", "is_published", "created_at"]
|
|
export_filename_prefix = "park_reviews"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Review Details",
|
|
{
|
|
"fields": (
|
|
"user",
|
|
"park",
|
|
"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="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="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.")
|
|
|
|
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",
|
|
)
|
|
return actions
|
|
|
|
|
|
@admin.register(pghistory.models.Events)
|
|
class PgHistoryEventsAdmin(ReadOnlyAdminMixin, ExportActionMixin, BaseModelAdmin):
|
|
"""
|
|
Admin interface for pghistory Events.
|
|
|
|
Provides read-only access to change history events for auditing.
|
|
Superusers can delete records for maintenance purposes.
|
|
|
|
Note: This admin is read-only because history events are auto-generated
|
|
and should not be manually modified.
|
|
"""
|
|
|
|
list_display = (
|
|
"pgh_id",
|
|
"pgh_created_at",
|
|
"pgh_label",
|
|
"pgh_model",
|
|
"pgh_obj_id",
|
|
"pgh_context_display",
|
|
)
|
|
list_filter = (
|
|
"pgh_label",
|
|
"pgh_model",
|
|
"pgh_created_at",
|
|
)
|
|
search_fields = (
|
|
"pgh_obj_id",
|
|
"pgh_context",
|
|
"pgh_model",
|
|
)
|
|
readonly_fields = (
|
|
"pgh_id",
|
|
"pgh_created_at",
|
|
"pgh_label",
|
|
"pgh_model",
|
|
"pgh_obj_id",
|
|
"pgh_context",
|
|
"pgh_data",
|
|
)
|
|
date_hierarchy = "pgh_created_at"
|
|
ordering = ("-pgh_created_at",)
|
|
|
|
export_fields = ["pgh_id", "pgh_created_at", "pgh_label", "pgh_model", "pgh_obj_id"]
|
|
export_filename_prefix = "history_events"
|
|
|
|
fieldsets = (
|
|
(
|
|
"Event Information",
|
|
{
|
|
"fields": (
|
|
"pgh_id",
|
|
"pgh_created_at",
|
|
"pgh_label",
|
|
"pgh_model",
|
|
"pgh_obj_id",
|
|
),
|
|
"description": "Core event identification and timing.",
|
|
},
|
|
),
|
|
(
|
|
"Context & Data",
|
|
{
|
|
"fields": (
|
|
"pgh_context",
|
|
"pgh_data",
|
|
),
|
|
"classes": ("collapse",),
|
|
"description": "Detailed context and data snapshot at time of event.",
|
|
},
|
|
),
|
|
)
|
|
|
|
@admin.display(description="Context")
|
|
def pgh_context_display(self, obj):
|
|
"""Display context information in a readable format."""
|
|
if obj.pgh_context:
|
|
if isinstance(obj.pgh_context, dict):
|
|
context_items = []
|
|
for key, value in list(obj.pgh_context.items())[:3]:
|
|
context_items.append(f"{key}: {value}")
|
|
result = ", ".join(context_items)
|
|
if len(obj.pgh_context) > 3:
|
|
result += "..."
|
|
return result
|
|
return str(obj.pgh_context)[:100]
|
|
return "-"
|
|
|
|
@admin.action(description="Export audit trail to CSV")
|
|
def export_audit_trail(self, request, queryset):
|
|
"""Export selected history events for audit reporting."""
|
|
return self.export_to_csv(request, queryset)
|
|
|
|
def get_actions(self, request):
|
|
"""Add export actions to the admin."""
|
|
actions = super().get_actions(request)
|
|
actions["export_audit_trail"] = (
|
|
self.export_audit_trail,
|
|
"export_audit_trail",
|
|
"Export audit trail to CSV",
|
|
)
|
|
return actions
|
|
|
|
|
|
# Register the models with their admin classes
|
|
admin.site.register(Park, ParkAdmin)
|
|
admin.site.register(ParkArea, ParkAreaAdmin)
|
|
admin.site.register(ParkLocation, ParkLocationAdmin)
|
|
admin.site.register(Company, CompanyAdmin)
|
|
admin.site.register(CompanyHeadquarters, CompanyHeadquartersAdmin)
|