Add secret management guide, client-side performance monitoring, and search accessibility enhancements

- 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.
This commit is contained in:
pacnpal
2025-12-23 16:41:42 -05:00
parent ae31e889d7
commit edcd8f2076
155 changed files with 22046 additions and 4645 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -207,7 +207,7 @@ class Command(BaseCommand):
self.stdout.write("Creating parks...")
# Park creation data - will be used to create parks in the database
# TODO(THRILLWIKI-111): Complete park creation implementation
# See FUTURE_WORK.md - THRILLWIKI-111 for implementation plan
parks_data = [
{
"name": "Magic Kingdom",

View File

@@ -13,12 +13,23 @@ class ParkArea(TrackedModel):
objects = ParkAreaManager()
id: int # Type hint for Django's automatic id field
park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name="areas")
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255)
description = models.TextField(blank=True)
opening_date = models.DateField(null=True, blank=True)
closing_date = models.DateField(null=True, blank=True)
park = models.ForeignKey(
Park,
on_delete=models.CASCADE,
related_name="areas",
help_text="Park this area belongs to",
)
name = models.CharField(max_length=255, help_text="Name of the park area")
slug = models.SlugField(
max_length=255, help_text="URL-friendly identifier (unique within park)"
)
description = models.TextField(blank=True, help_text="Detailed description of the area")
opening_date = models.DateField(
null=True, blank=True, help_text="Date this area opened"
)
closing_date = models.DateField(
null=True, blank=True, help_text="Date this area closed (if applicable)"
)
def save(self, *args, **kwargs):
if not self.slug:
@@ -28,5 +39,8 @@ class ParkArea(TrackedModel):
def __str__(self):
return self.name
class Meta:
class Meta(TrackedModel.Meta):
verbose_name = "Park Area"
verbose_name_plural = "Park Areas"
ordering = ["park", "name"]
unique_together = ("park", "slug")

View File

@@ -13,20 +13,27 @@ class Company(TrackedModel):
objects = CompanyManager()
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
name = models.CharField(max_length=255, help_text="Company name")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
roles = ArrayField(
RichChoiceField(choice_group="company_roles", domain="parks", max_length=20),
default=list,
blank=True,
help_text="Company roles (operator, manufacturer, etc.)",
)
description = models.TextField(blank=True)
website = models.URLField(blank=True)
description = models.TextField(blank=True, help_text="Detailed company description")
website = models.URLField(blank=True, help_text="Company website URL")
# Operator-specific fields
founded_year = models.PositiveIntegerField(blank=True, null=True)
parks_count = models.IntegerField(default=0)
rides_count = models.IntegerField(default=0)
founded_year = models.PositiveIntegerField(
blank=True, null=True, help_text="Year the company was founded"
)
parks_count = models.IntegerField(
default=0, help_text="Number of parks operated (auto-calculated)"
)
rides_count = models.IntegerField(
default=0, help_text="Number of rides manufactured (auto-calculated)"
)
def save(self, *args, **kwargs):
if not self.slug:
@@ -38,8 +45,9 @@ class Company(TrackedModel):
class Meta(TrackedModel.Meta):
app_label = "parks"
ordering = ["name"]
verbose_name = "Company"
verbose_name_plural = "Companies"
ordering = ["name"]
@pghistory.track()
@@ -51,7 +59,10 @@ class CompanyHeadquarters(models.Model):
# Relationships
company = models.OneToOneField(
"Company", on_delete=models.CASCADE, related_name="headquarters"
"Company",
on_delete=models.CASCADE,
related_name="headquarters",
help_text="Company this headquarters belongs to",
)
# Address Fields (No coordinates needed)

View File

@@ -30,7 +30,10 @@ class ParkPhoto(TrackedModel):
"""Photo model specific to parks."""
park = models.ForeignKey(
"parks.Park", on_delete=models.CASCADE, related_name="photos"
"parks.Park",
on_delete=models.CASCADE,
related_name="photos",
help_text="Park this photo belongs to",
)
image = models.ForeignKey(
@@ -39,10 +42,18 @@ class ParkPhoto(TrackedModel):
help_text="Park photo stored on Cloudflare Images"
)
caption = models.CharField(max_length=255, blank=True)
alt_text = models.CharField(max_length=255, blank=True)
is_primary = models.BooleanField(default=False)
is_approved = models.BooleanField(default=False)
caption = models.CharField(
max_length=255, blank=True, help_text="Photo caption or description"
)
alt_text = models.CharField(
max_length=255, blank=True, help_text="Alternative text for accessibility"
)
is_primary = models.BooleanField(
default=False, help_text="Whether this is the primary photo for the park"
)
is_approved = models.BooleanField(
default=False, help_text="Whether this photo has been approved by moderators"
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
@@ -55,10 +66,13 @@ class ParkPhoto(TrackedModel):
on_delete=models.SET_NULL,
null=True,
related_name="uploaded_park_photos",
help_text="User who uploaded this photo",
)
class Meta(TrackedModel.Meta):
app_label = "parks"
verbose_name = "Park Photo"
verbose_name_plural = "Park Photos"
ordering = ["-is_primary", "-created_at"]
indexes = [
models.Index(fields=["park", "is_primary"]),

View File

@@ -24,9 +24,9 @@ class Park(StateMachineMixin, TrackedModel):
objects = ParkManager()
id: int # Type hint for Django's automatic id field
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
description = models.TextField(blank=True)
name = models.CharField(max_length=255, help_text="Park name")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
description = models.TextField(blank=True, help_text="Park description")
state_field_name = "status"
status = RichFSMField(
@@ -50,20 +50,20 @@ class Park(StateMachineMixin, TrackedModel):
# ParkLocation
# Details
opening_date = models.DateField(null=True, blank=True)
closing_date = models.DateField(null=True, blank=True)
operating_season = models.CharField(max_length=255, blank=True)
opening_date = models.DateField(null=True, blank=True, help_text="Opening date")
closing_date = models.DateField(null=True, blank=True, help_text="Closing date")
operating_season = models.CharField(max_length=255, blank=True, help_text="Operating season")
size_acres = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True
max_digits=10, decimal_places=2, null=True, blank=True, help_text="Park size in acres"
)
website = models.URLField(blank=True)
website = models.URLField(blank=True, help_text="Official website URL")
# Statistics
average_rating = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True
max_digits=3, decimal_places=2, null=True, blank=True, help_text="Average user rating (110)"
)
ride_count = models.IntegerField(null=True, blank=True)
coaster_count = models.IntegerField(null=True, blank=True)
ride_count = models.IntegerField(null=True, blank=True, help_text="Total ride count")
coaster_count = models.IntegerField(null=True, blank=True, help_text="Total coaster count")
# Image settings - references to existing photos
banner_image = models.ForeignKey(
@@ -133,6 +133,8 @@ class Park(StateMachineMixin, TrackedModel):
)
class Meta:
verbose_name = "Park"
verbose_name_plural = "Parks"
ordering = ["name"]
constraints = [
# Business rule: Closing date must be after opening date

View File

@@ -15,35 +15,51 @@ class ParkReview(TrackedModel):
A review of a park.
"""
park = models.ForeignKey(
"parks.Park", on_delete=models.CASCADE, related_name="reviews"
"parks.Park",
on_delete=models.CASCADE,
related_name="reviews",
help_text="Park being reviewed",
)
user = models.ForeignKey(
"accounts.User", on_delete=models.CASCADE, related_name="park_reviews"
"accounts.User",
on_delete=models.CASCADE,
related_name="park_reviews",
help_text="User who wrote the review",
)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(10)]
validators=[MinValueValidator(1), MaxValueValidator(10)],
help_text="Rating from 1-10",
)
title = models.CharField(max_length=200)
content = models.TextField()
visit_date = models.DateField()
title = models.CharField(max_length=200, help_text="Review title")
content = models.TextField(help_text="Review content")
visit_date = models.DateField(help_text="Date the user visited the park")
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Moderation
is_published = models.BooleanField(default=True)
moderation_notes = models.TextField(blank=True)
is_published = models.BooleanField(
default=True, help_text="Whether this review is publicly visible"
)
moderation_notes = models.TextField(
blank=True, help_text="Internal notes from moderators"
)
moderated_by = models.ForeignKey(
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="moderated_park_reviews",
help_text="Moderator who reviewed this",
)
moderated_at = models.DateTimeField(
null=True, blank=True, help_text="When this review was moderated"
)
moderated_at = models.DateTimeField(null=True, blank=True)
class Meta:
class Meta(TrackedModel.Meta):
verbose_name = "Park Review"
verbose_name_plural = "Park Reviews"
ordering = ["-created_at"]
unique_together = ["park", "user"]
constraints = [

View File

@@ -0,0 +1,156 @@
"""
Tests for parks admin interfaces.
These tests verify the functionality of park, area, company, location,
and review admin classes including query optimization and custom actions.
"""
import pytest
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase
from apps.parks.admin import (
CompanyAdmin,
CompanyHeadquartersAdmin,
ParkAdmin,
ParkAreaAdmin,
ParkLocationAdmin,
ParkReviewAdmin,
)
from apps.parks.models import Company, CompanyHeadquarters, Park, ParkArea, ParkLocation, ParkReview
User = get_user_model()
class TestParkAdmin(TestCase):
"""Tests for ParkAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = ParkAdmin(model=Park, admin_site=self.site)
def test_list_display_fields(self):
"""Verify all required fields are in list_display."""
required_fields = [
"name",
"formatted_location",
"status_badge",
"operator_link",
"ride_count",
"average_rating",
"created_at",
]
for field in required_fields:
assert field in self.admin.list_display
def test_list_select_related(self):
"""Verify select_related is configured for ForeignKeys."""
assert "operator" in self.admin.list_select_related
assert "property_owner" in self.admin.list_select_related
assert "location" in self.admin.list_select_related
def test_list_prefetch_related(self):
"""Verify prefetch_related is configured for reverse relations."""
assert "areas" in self.admin.list_prefetch_related
assert "rides" in self.admin.list_prefetch_related
def test_search_fields_include_relations(self):
"""Verify search includes related object fields."""
assert "location__city" in self.admin.search_fields
assert "operator__name" in self.admin.search_fields
def test_export_fields_configured(self):
"""Verify export fields are configured."""
assert hasattr(self.admin, "export_fields")
assert "id" in self.admin.export_fields
assert "name" in self.admin.export_fields
def test_actions_registered(self):
"""Verify custom actions are registered."""
request = self.factory.get("/admin/")
request.user = User(is_superuser=True)
actions = self.admin.get_actions(request)
assert "bulk_activate" in actions
assert "bulk_deactivate" in actions
assert "export_to_csv" in actions
class TestParkAreaAdmin(TestCase):
"""Tests for ParkAreaAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = ParkAreaAdmin(model=ParkArea, admin_site=self.site)
def test_list_select_related(self):
"""Verify select_related for park."""
assert "park" in self.admin.list_select_related
def test_list_prefetch_related(self):
"""Verify prefetch_related for rides."""
assert "rides" in self.admin.list_prefetch_related
class TestParkLocationAdmin(TestCase):
"""Tests for ParkLocationAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = ParkLocationAdmin(model=ParkLocation, admin_site=self.site)
def test_list_select_related(self):
"""Verify select_related for park."""
assert "park" in self.admin.list_select_related
def test_readonly_coordinates(self):
"""Verify coordinate fields are readonly."""
assert "latitude" in self.admin.readonly_fields
assert "longitude" in self.admin.readonly_fields
class TestCompanyAdmin(TestCase):
"""Tests for CompanyAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = CompanyAdmin(model=Company, admin_site=self.site)
def test_list_prefetch_related(self):
"""Verify prefetch_related for related parks."""
assert "operated_parks" in self.admin.list_prefetch_related
assert "owned_parks" in self.admin.list_prefetch_related
class TestParkReviewAdmin(TestCase):
"""Tests for ParkReviewAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = ParkReviewAdmin(model=ParkReview, admin_site=self.site)
def test_list_select_related(self):
"""Verify select_related for user and park."""
assert "park" in self.admin.list_select_related
assert "user" in self.admin.list_select_related
assert "moderated_by" in self.admin.list_select_related
def test_moderation_actions_registered(self):
"""Verify moderation actions are registered."""
request = self.factory.get("/admin/")
request.user = User(is_superuser=True)
actions = self.admin.get_actions(request)
assert "bulk_approve" in actions
assert "bulk_reject" in actions
def test_readonly_moderation_fields(self):
"""Verify moderation fields are readonly."""
assert "moderated_by" in self.admin.readonly_fields
assert "moderated_at" in self.admin.readonly_fields

View File

@@ -1,5 +1,39 @@
# Park Search Tests
## Why These Tests Are Disabled
These tests were disabled because they need updating to work with the new `ParkLocation` model instead of the generic `Location` model. The model refactoring changed how location data is stored and accessed for parks.
## Re-enabling These Tests
To re-enable these tests, follow these steps:
1. **Update model imports** in `test_filters.py` and `test_models.py`:
- Replace `from apps.locations.models import Location` with `from apps.parks.models import ParkLocation`
- Update any other location-related imports
2. **Update test fixtures** to use `ParkLocation` instead of `Location`:
- Change factory classes to create `ParkLocation` instances
- Update fixture data to match the new model structure
3. **Update assertions** to match new model structure:
- Adjust field references (e.g., `park.location` may now be `park.park_location`)
- Update any serializer-based assertions
4. **Move files** back to the active test directory:
```bash
mv backend/apps/parks/tests_disabled/*.py backend/apps/parks/tests/
```
5. **Run tests** to verify they pass:
```bash
uv run pytest backend/apps/parks/tests/
```
**Tracking**: See TODO(THRILLWIKI-XXX) for tracking issue
---
## Overview
Test suite for the park search functionality including:

View File

@@ -33,6 +33,11 @@ from django.views.decorators.http import require_POST
from django.template.loader import render_to_string
import json
import logging
from apps.core.logging import log_exception, log_business_event
logger = logging.getLogger(__name__)
# Constants
PARK_DETAIL_URL = "parks:park_detail"
@@ -285,6 +290,12 @@ class ParkListView(HTMXFilterableMixin, ListView):
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
return self.filterset.qs
except Exception as e:
log_exception(
logger,
e,
context={"operation": "get_filtered_queryset", "filters": filter_params},
request=self.request,
)
messages.error(self.request, f"Error loading parks: {str(e)}")
queryset = self.model.objects.none()
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
@@ -330,6 +341,15 @@ class ParkListView(HTMXFilterableMixin, ListView):
return context
except Exception as e:
log_exception(
logger,
e,
context={
"operation": "get_context_data",
"search_query": self.request.GET.get("search", ""),
},
request=self.request,
)
messages.error(self.request, f"Error applying filters: {str(e)}")
# Ensure filterset exists in error case
if not hasattr(self, "filterset"):
@@ -478,6 +498,16 @@ def search_parks(request: HttpRequest) -> HttpResponse:
return response
except Exception as e:
log_exception(
logger,
e,
context={
"operation": "search_parks",
"search_query": request.GET.get("search", ""),
"view_mode": request.GET.get("view_mode", "grid"),
},
request=request,
)
response = render(
request,
PARK_LIST_ITEM_TEMPLATE,
@@ -505,7 +535,13 @@ def htmx_saved_trips(request: HttpRequest) -> HttpResponse:
qs = Trip.objects.filter(owner=request.user).order_by("-created_at")
trips = list(qs[:10])
except Exception:
except Exception as e:
log_exception(
logger,
e,
context={"operation": "htmx_saved_trips"},
request=request,
)
trips = []
return render(request, SAVED_TRIPS_TEMPLATE, {"trips": trips})
@@ -514,7 +550,13 @@ def _get_session_trip(request: HttpRequest) -> list:
raw = request.session.get("trip_parks", [])
try:
return [int(x) for x in raw]
except Exception:
except Exception as e:
log_exception(
logger,
e,
context={"operation": "get_session_trip", "raw": raw},
request=request,
)
return []
@@ -527,11 +569,21 @@ def _save_session_trip(request: HttpRequest, trip_list: list) -> None:
def htmx_add_park_to_trip(request: HttpRequest) -> HttpResponse:
"""Add a park id to `request.session['trip_parks']` and return the full trip list partial."""
park_id = request.POST.get("park_id")
payload = None
if not park_id:
try:
payload = json.loads(request.body.decode("utf-8"))
park_id = payload.get("park_id")
except Exception:
except Exception as e:
log_exception(
logger,
e,
context={
"operation": "htmx_add_park_to_trip",
"payload": request.body.decode("utf-8", errors="replace")[:500],
},
request=request,
)
park_id = None
if not park_id:
@@ -539,7 +591,16 @@ def htmx_add_park_to_trip(request: HttpRequest) -> HttpResponse:
try:
pid = int(park_id)
except Exception:
except Exception as e:
log_exception(
logger,
e,
context={
"operation": "htmx_add_park_to_trip",
"park_id": park_id,
},
request=request,
)
return HttpResponse("", status=400)
trip = _get_session_trip(request)
@@ -565,11 +626,21 @@ def htmx_add_park_to_trip(request: HttpRequest) -> HttpResponse:
def htmx_remove_park_from_trip(request: HttpRequest) -> HttpResponse:
"""Remove a park id from `request.session['trip_parks']` and return the updated trip list partial."""
park_id = request.POST.get("park_id")
payload = None
if not park_id:
try:
payload = json.loads(request.body.decode("utf-8"))
park_id = payload.get("park_id")
except Exception:
except Exception as e:
log_exception(
logger,
e,
context={
"operation": "htmx_remove_park_from_trip",
"payload": request.body.decode("utf-8", errors="replace")[:500],
},
request=request,
)
park_id = None
if not park_id:
@@ -577,7 +648,16 @@ def htmx_remove_park_from_trip(request: HttpRequest) -> HttpResponse:
try:
pid = int(park_id)
except Exception:
except Exception as e:
log_exception(
logger,
e,
context={
"operation": "htmx_remove_park_from_trip",
"park_id": park_id,
},
request=request,
)
return HttpResponse("", status=400)
trip = _get_session_trip(request)
@@ -605,7 +685,16 @@ def htmx_reorder_parks(request: HttpRequest) -> HttpResponse:
try:
payload = json.loads(request.body.decode("utf-8"))
order = payload.get("order", [])
except Exception:
except Exception as e:
log_exception(
logger,
e,
context={
"operation": "htmx_reorder_parks",
"payload": request.body.decode("utf-8", errors="replace")[:500],
},
request=request,
)
order = request.POST.getlist("order[]")
# Normalize to ints
@@ -613,7 +702,16 @@ def htmx_reorder_parks(request: HttpRequest) -> HttpResponse:
for item in order:
try:
clean_order.append(int(item))
except Exception:
except Exception as e:
log_exception(
logger,
e,
context={
"operation": "htmx_reorder_parks",
"order_item": item,
},
request=request,
)
continue
_save_session_trip(request, clean_order)
@@ -676,7 +774,27 @@ def htmx_optimize_route(request: HttpRequest) -> HttpResponse:
total_miles += haversine_miles(
a["latitude"], a["longitude"], b["latitude"], b["longitude"]
)
except Exception:
except Exception as e:
log_exception(
logger,
e,
context={
"operation": "htmx_optimize_route",
"waypoint_index_a": i,
"waypoint_index_b": i + 1,
"waypoint_a": {
"id": a.get("id"),
"latitude": a.get("latitude"),
"longitude": a.get("longitude"),
},
"waypoint_b": {
"id": b.get("id"),
"latitude": b.get("latitude"),
"longitude": b.get("longitude"),
},
},
request=request,
)
continue
# Estimate drive time assuming average speed of 60 mph
@@ -812,6 +930,18 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
if service_result["status"] == "auto_approved":
self.object = service_result["park"]
log_business_event(
logger,
event_type="park_created",
message=f"Park created: {self.object.name} (auto-approved)",
context={
"park_id": self.object.id,
"park_name": self.object.name,
"status": "auto_approved",
"photo_count": service_result["uploaded_count"],
},
request=self.request,
)
messages.success(
self.request,
f"Successfully created {self.object.name}. "
@@ -820,6 +950,16 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
return HttpResponseRedirect(self.get_success_url())
elif service_result["status"] == "queued":
log_business_event(
logger,
event_type="park_created",
message="Park submission queued for moderation",
context={
"status": "queued",
"park_name": form.cleaned_data.get("name"),
},
request=self.request,
)
messages.success(
self.request,
"Your park submission has been sent for review. "
@@ -916,6 +1056,18 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
if service_result["status"] == "auto_approved":
self.object = service_result["park"]
log_business_event(
logger,
event_type="park_updated",
message=f"Park updated: {self.object.name} (auto-approved)",
context={
"park_id": self.object.id,
"park_name": self.object.name,
"status": "auto_approved",
"photo_count": service_result["uploaded_count"],
},
request=self.request,
)
messages.success(
self.request,
f"Successfully updated {self.object.name}. "
@@ -924,6 +1076,17 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
return HttpResponseRedirect(self.get_success_url())
elif service_result["status"] == "queued":
log_business_event(
logger,
event_type="park_updated",
message=f"Park update queued for moderation: {self.object.name}",
context={
"park_id": self.object.id,
"park_name": self.object.name,
"status": "queued",
},
request=self.request,
)
messages.success(
self.request,
f"Your changes to {self.object.name} have been sent for review. "