feat: Enhance parks listing with view mode toggle and search functionality

- Implemented a consolidated search bar for parks with live search capabilities.
- Added view mode toggle between grid and list views for better user experience.
- Updated park listing template to support dynamic rendering based on selected view mode.
- Improved pagination controls with HTMX for seamless navigation.
- Fixed import paths in parks and rides API to resolve 501 errors, ensuring proper functionality.
- Documented changes and integration requirements for frontend compatibility.
This commit is contained in:
pacnpal
2025-08-31 11:39:14 -04:00
parent 5bf351fd2b
commit 91906e0d57
12 changed files with 654 additions and 140 deletions

View File

@@ -26,8 +26,7 @@ from drf_spectacular.types import OpenApiTypes
# Import models
try:
from apps.parks.models import Park
from apps.companies.models import Company
from apps.parks.models import Park, Company
MODELS_AVAILABLE = True
except Exception:
Park = None # type: ignore
@@ -165,9 +164,10 @@ class ParkListCreateAPIView(APIView):
qs = Park.objects.all().select_related(
"operator", "property_owner", "location"
).prefetch_related("rides").annotate(
ride_count=Count('rides'),
roller_coaster_count=Count('rides', filter=Q(rides__category='RC')),
average_rating=Avg('reviews__rating')
ride_count_calculated=Count('rides'),
roller_coaster_count_calculated=Count(
'rides', filter=Q(rides__category='RC')),
average_rating_calculated=Avg('reviews__rating')
)
# Apply comprehensive filtering

View File

@@ -36,16 +36,15 @@ from apps.api.v1.serializers.rides import (
# Attempt to import model-level helpers; fall back gracefully if not present.
try:
from apps.rides.models import Ride, RideModel, Company as RideCompany # type: ignore
from apps.parks.models import Park, Company as ParkCompany # type: ignore
from apps.rides.models import Ride, RideModel
from apps.parks.models import Park, Company
MODELS_AVAILABLE = True
except Exception:
Ride = None # type: ignore
RideModel = None # type: ignore
RideCompany = None # type: ignore
Company = None # type: ignore
Park = None # type: ignore
ParkCompany = None # type: ignore
MODELS_AVAILABLE = False
# Attempt to import ModelChoices to return filter options
@@ -894,7 +893,7 @@ class CompanySearchAPIView(APIView):
if not q:
return Response([], status=status.HTTP_200_OK)
if RideCompany is None:
if Company is None:
# Provide helpful placeholder structure
return Response(
[
@@ -903,7 +902,7 @@ class CompanySearchAPIView(APIView):
]
)
qs = RideCompany.objects.filter(name__icontains=q)[:20] # type: ignore
qs = Company.objects.filter(name__icontains=q)[:20] # type: ignore
results = [
{"id": c.id, "name": c.name, "slug": getattr(c, "slug", "")} for c in qs
]

View File

@@ -235,9 +235,9 @@ class ParkListView(HTMXFilterableMixin, ListView):
self.filter_service = ParkFilterService()
def get_template_names(self) -> list[str]:
"""Return park_list_item.html for HTMX requests"""
"""Return park_list.html for HTMX requests"""
if self.request.htmx:
return ["parks/partials/park_list_item.html"]
return ["parks/partials/park_list.html"]
return [self.template_name]
def get_view_mode(self) -> ViewMode:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -11,16 +11,140 @@
<!-- Results Section -->
<div id="search-results">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<!-- Consolidated Search and View Controls -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex justify-between items-center">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
Search Results
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ results.count|default:0 }} found)</span>
</h2>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<!-- Left side: Search bar with results count -->
<div class="flex-1 max-w-md">
<div class="relative">
<input type="text"
name="search"
value="{{ request.GET.search|default:'' }}"
placeholder="Search parks by name, location..."
hx-get="{% url 'parks:park_list' %}"
hx-target="#results-container"
hx-trigger="input changed delay:500ms"
hx-indicator="#loading-indicator"
hx-swap="outerHTML"
hx-push-url="true"
class="w-full pl-10 pr-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</div>
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Parks ({{ results.count|default:0 }} found)
</div>
</div>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Right side: View switching buttons -->
<div class="flex items-center gap-2">
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1" x-data="{ viewMode: '{{ request.GET.view_mode|default:'grid' }}' }">
<button type="button"
@click="viewMode = 'grid'; switchView('grid')"
:class="viewMode === 'grid' ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
</button>
<button type="button"
@click="viewMode = 'list'; switchView('list')"
:class="viewMode === 'list' ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Results Container -->
<div id="results-container" class="divide-y divide-gray-200 dark:divide-gray-700" x-data="{ viewMode: '{{ request.GET.view_mode|default:'grid' }}' }">
<!-- Grid View -->
<div x-show="viewMode === 'grid'" class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for park in results %}
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-shadow">
<!-- Park Image -->
<div class="h-48 bg-gray-200 dark:bg-gray-600 overflow-hidden">
{% if park.photos.exists %}
<img src="{{ park.photos.first.image.url }}"
alt="{{ park.name }}"
class="w-full h-full object-cover">
{% else %}
<div class="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
</div>
<!-- Park Details -->
<div class="p-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
{{ park.name }}
</a>
</h3>
{% if park.formatted_location %}
<div class="text-sm text-gray-600 dark:text-gray-400 flex items-center mb-3">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
{{ park.formatted_location }}
</div>
{% endif %}
<div class="flex flex-wrap gap-2 mb-3">
{% if park.average_rating %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
{{ park.average_rating }}/10
</span>
{% endif %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} dark:bg-opacity-30">
{{ park.get_status_display }}
</span>
{% if park.ride_count %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300">
{{ park.ride_count }} Ride{{ park.ride_count|pluralize }}
</span>
{% endif %}
</div>
{% if park.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ park.description|truncatewords:20 }}
</p>
{% endif %}
</div>
</div>
{% empty %}
<div class="col-span-full p-12 text-center text-gray-500 dark:text-gray-400">
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No parks found</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try adjusting your search criteria or filters.</p>
</div>
{% endfor %}
</div>
</div>
<!-- List View -->
<div x-show="viewMode === 'list'" class="divide-y divide-gray-200 dark:divide-gray-700">
{% for park in results %}
<div class="p-6 flex flex-col md:flex-row gap-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<!-- Park Image -->
@@ -108,10 +232,69 @@
</div>
</div>
</div>
</div>
</div>
{# Include required scripts #}
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://unpkg.com/unpoly@3/unpoly.min.js"></script>
<script>
// View switching functionality
function switchView(mode) {
// Update URL parameter
const url = new URL(window.location);
url.searchParams.set('view_mode', mode);
// Update the URL without reloading
window.history.pushState({}, '', url);
// Store preference in localStorage
localStorage.setItem('parkViewMode', mode);
}
// Initialize view mode from URL or localStorage
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const urlViewMode = urlParams.get('view_mode');
const savedViewMode = localStorage.getItem('parkViewMode');
const defaultViewMode = urlViewMode || savedViewMode || 'grid';
// Set initial view mode
if (!urlViewMode) {
const url = new URL(window.location);
url.searchParams.set('view_mode', defaultViewMode);
window.history.replaceState({}, '', url);
}
});
// Enhanced search functionality
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.querySelector('input[name="search"]');
if (searchInput) {
let searchTimeout;
// Preserve view mode in search requests
searchInput.addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
// Get current view mode
const currentViewMode = new URLSearchParams(window.location.search).get('view_mode') || 'grid';
// Add view mode to the HTMX request
const currentUrl = new URL(e.target.getAttribute('hx-get'), window.location.origin);
currentUrl.searchParams.set('view_mode', currentViewMode);
currentUrl.searchParams.set('search', e.target.value);
// Update the hx-get attribute
e.target.setAttribute('hx-get', currentUrl.pathname + currentUrl.search);
// Trigger the HTMX request
htmx.trigger(e.target, 'input');
}, 500);
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}Parks{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-6">
<!-- Consolidated Search and View Controls Bar -->
<div class="bg-gray-800 rounded-lg p-4 mb-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<!-- Search Section -->
<div class="flex-1 max-w-2xl">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<input
type="text"
name="search"
value="{{ search_query }}"
placeholder="Search parks by name, location, or features..."
class="block w-full pl-10 pr-3 py-2 border border-gray-600 rounded-md leading-5 bg-gray-700 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-trigger="keyup changed delay:300ms"
hx-target="#park-results"
hx-include="[name='view_mode']"
hx-indicator="#search-spinner"
/>
<div id="search-spinner" class="absolute inset-y-0 right-0 pr-3 flex items-center htmx-indicator">
<svg class="animate-spin h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
</div>
<!-- Results Count and View Controls -->
<div class="flex items-center gap-4">
<!-- Results Count -->
<div class="text-gray-300 text-sm whitespace-nowrap">
<span class="font-medium">Parks</span>
{% if total_results %}
<span class="text-gray-400">({{ total_results }} found)</span>
{% endif %}
</div>
<!-- View Mode Toggle -->
<div class="flex bg-gray-700 rounded-lg p-1">
<input type="hidden" name="view_mode" value="{{ view_mode }}" />
<!-- Grid View Button -->
<button
type="button"
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'grid' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
title="Grid View"
hx-get="{% url 'parks:park_list' %}?view_mode=grid"
hx-target="#park-results"
hx-include="[name='search']"
hx-push-url="true"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
</svg>
</button>
<!-- List View Button -->
<button
type="button"
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'list' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
title="List View"
hx-get="{% url 'parks:park_list' %}?view_mode=list"
hx-target="#park-results"
hx-include="[name='search']"
hx-push-url="true"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Results Container -->
<div id="park-results">
{% include "parks/partials/park_list.html" %}
</div>
</div>
{% endblock %}

View File

@@ -1,12 +1,74 @@
<!-- Parks Grid -->
<div class="grid-adaptive">
{% if view_mode == 'list' %}
<!-- Parks List View -->
<div class="space-y-4">
{% for park in parks %}
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 overflow-hidden">
<div class="flex flex-col md:flex-row">
{% if park.photos.exists %}
<div class="md:w-48 md:flex-shrink-0">
<img src="{{ park.photos.first.image.url }}"
alt="{{ park.name }}"
class="w-full h-48 md:h-full object-cover">
</div>
{% endif %}
<div class="flex-1 p-6">
<div class="flex flex-col md:flex-row md:items-start md:justify-between">
<div class="flex-1">
<h2 class="text-2xl font-bold mb-2">
<a href="{% url 'parks:park_detail' park.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ park.name }}
</a>
</h2>
{% if park.city or park.state or park.country %}
<p class="text-gray-600 dark:text-gray-400 mb-3">
<i class="mr-1 fas fa-map-marker-alt"></i>
{% spaceless %}
{% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %}
{% endspaceless %}
</p>
{% endif %}
{% if park.operator %}
<p class="text-blue-600 dark:text-blue-400 mb-3">
{{ park.operator.name }}
</p>
{% endif %}
</div>
<div class="flex flex-col items-start md:items-end gap-2 mt-4 md:mt-0">
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
</span>
{% if park.average_rating %}
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
{{ park.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% empty %}
<div class="py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
</div>
{% endfor %}
</div>
{% else %}
<!-- Parks Grid View -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for park in parks %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
{% if park.photos.exists %}
<div class="aspect-w-16 aspect-h-9">
<img src="{{ park.photos.first.image.url }}"
alt="{{ park.name }}"
class="object-cover w-full">
class="object-cover w-full h-48">
</div>
{% endif %}
<div class="p-4">
@@ -21,7 +83,6 @@
<i class="mr-1 fas fa-map-marker-alt"></i>
{% spaceless %}
{% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %}
</p>
{% endspaceless %}
</p>
{% endif %}
@@ -48,19 +109,28 @@
</div>
</div>
{% empty %}
<div class="col-span-3 py-8 text-center">
<div class="col-span-full py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Pagination -->
{% if is_paginated %}
<div class="flex justify-center mt-6">
<div class="inline-flex rounded-md shadow-xs">
{% if page_obj.has_previous %}
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">&laquo; First</a>
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
<a href="?page=1&{{ request.GET.urlencode }}"
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
hx-get="?page=1&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true">&laquo; First</a>
<a href="?page={{ page_obj.previous_page_number }}&{{ request.GET.urlencode }}"
class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
hx-get="?page={{ page_obj.previous_page_number }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true">Previous</a>
{% endif %}
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
@@ -68,8 +138,16 @@
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last &raquo;</a>
<a href="?page={{ page_obj.next_page_number }}&{{ request.GET.urlencode }}"
class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
hx-get="?page={{ page_obj.next_page_number }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true">Next</a>
<a href="?page={{ page_obj.paginator.num_pages }}&{{ request.GET.urlencode }}"
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
hx-get="?page={{ page_obj.paginator.num_pages }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true">Last &raquo;</a>
{% endif %}
</div>
</div>

View File

@@ -1,6 +1,7 @@
c# Active Context
## Current Focus
- **✅ COMPLETED: Parks and Rides API 501 Error Fix**: Successfully resolved 501 errors in both parks and rides listing endpoints by fixing import paths from `apps.companies.models` to `apps.parks.models` and resolving annotation conflicts with existing model fields
- **✅ COMPLETED: Park Filter Endpoints Backend-Frontend Alignment**: Successfully resolved critical backend-frontend alignment issue where Django backend was filtering on non-existent model fields
- **✅ COMPLETED: Automatic Cloudflare Image Deletion**: Successfully implemented automatic Cloudflare image deletion across all photo upload systems (avatar, park photos, ride photos) when users change or remove images
- **✅ COMPLETED: Photo Upload System Consistency**: Successfully extended avatar upload fix to park and ride photo uploads, ensuring all photo upload systems work consistently with proper Cloudflare variants extraction

View File

@@ -0,0 +1,181 @@
# Parks and Rides API 501 Error Fix - Frontend Integration Prompt
## Project Context
ThrillWiki is a comprehensive theme park and ride database with Django REST API backend. The parks and rides listing endpoints were returning 501 errors due to incorrect model imports, preventing frontend integration.
## High-Level Objectives
- Fix 501 errors in both `/api/v1/parks/` and `/api/v1/rides/` endpoints
- Ensure proper model imports and database query functionality
- Maintain existing API contract and response formats
- Verify all filtering and search functionality works correctly
## Technical Implementation Details
### Root Cause Analysis
Both API endpoints were attempting to import the Company model from a non-existent `apps.companies.models` module. The Company model is actually located in `apps.parks.models`.
### Backend Changes Applied
#### Parks API (`backend/apps/api/v1/parks/park_views.py`)
- **Import Fix**: Changed from `from apps.companies.models import Company` to `from apps.parks.models import Park, Company`
- **Annotation Conflicts**: Resolved database annotation conflicts by using calculated field names to avoid conflicts with existing model fields
- **Query Optimization**: Maintained existing select_related and prefetch_related optimizations
#### Rides API (`backend/apps/api/v1/rides/views.py`)
- **Import Fix**: Simplified imports to use single Company model from parks app
- **Variable References**: Updated all Company references to use correct import
- **Maintained Functionality**: All filtering, search, and pagination features preserved
### API Endpoints Now Functional
#### Parks API Endpoints
- `GET /api/v1/parks/` - List parks with comprehensive filtering and pagination
- `GET /api/v1/parks/filter-options/` - Get filter metadata for frontend forms
- `GET /api/v1/parks/search/companies/?q={query}` - Company autocomplete search
- `GET /api/v1/parks/search-suggestions/?q={query}` - Park name suggestions
- `GET /api/v1/parks/{id}/` - Individual park details
- `PATCH /api/v1/parks/{id}/image-settings/` - Set banner/card images
#### Rides API Endpoints
- `GET /api/v1/rides/` - List rides with comprehensive filtering and pagination
- `GET /api/v1/rides/filter-options/` - Get filter metadata for frontend forms
- `GET /api/v1/rides/search/companies/?q={query}` - Company autocomplete search
- `GET /api/v1/rides/search/ride-models/?q={query}` - Ride model autocomplete
- `GET /api/v1/rides/search-suggestions/?q={query}` - Ride name suggestions
- `GET /api/v1/rides/{id}/` - Individual ride details
- `PATCH /api/v1/rides/{id}/image-settings/` - Set banner/card images
### Mandatory API Rules Compliance
- **Trailing Forward Slashes**: All API endpoints include mandatory trailing forward slashes
- **HTTP Methods**: Proper GET/POST/PATCH/DELETE method usage
- **Authentication**: Public endpoints use AllowAny permissions
- **Error Handling**: Proper 404/400/500 error responses with detailed messages
- **Pagination**: Standard pagination with count, next, previous fields
### Response Format Examples
#### Parks List Response
```json
{
"count": 7,
"next": "http://localhost:8000/api/v1/parks/?page=2&page_size=2",
"previous": null,
"results": [
{
"id": 99,
"name": "Cedar Point",
"slug": "cedar-point",
"status": "OPERATING",
"description": "Known as the \"Roller Coaster Capital of the World\".",
"average_rating": null,
"coaster_count": 4,
"ride_count": 4,
"location": {
"latitude": null,
"longitude": null,
"city": null,
"state": null,
"country": null,
"formatted_address": ""
},
"operator": {
"id": 114,
"name": "Cedar Fair Entertainment Company",
"slug": "cedar-fair-entertainment-company",
"roles": ["OPERATOR"],
"url": ""
},
"url": "http://www.thrillwiki.com/parks/cedar-point/",
"created_at": "2025-08-22T15:33:27.302477-04:00",
"updated_at": "2025-08-28T19:13:11.773038-04:00"
}
]
}
```
#### Rides List Response
```json
{
"count": 10,
"next": "http://localhost:8000/api/v1/rides/?page=2&page_size=2",
"previous": null,
"results": [
{
"id": 134,
"name": "Big Thunder Mountain Railroad",
"slug": "big-thunder-mountain-railroad",
"category": "RC",
"status": "OPERATING",
"description": "Mine train roller coaster themed as a runaway mining train.",
"park": {
"id": 97,
"name": "Magic Kingdom",
"slug": "magic-kingdom"
},
"average_rating": null,
"capacity_per_hour": null,
"opening_date": "1980-11-15",
"closing_date": null,
"url": "http://www.thrillwiki.com/parks/magic-kingdom/rides/big-thunder-mountain-railroad/",
"created_at": "2025-08-22T15:33:27.326714-04:00",
"updated_at": "2025-08-28T19:13:11.752830-04:00"
}
]
}
```
### Filter Options Response Structure
Both parks and rides filter options endpoints return comprehensive metadata including:
- **Categories**: Available ride/park categories with labels
- **Statuses**: Operational status options
- **Ordering Options**: Sort field options with human-readable labels
- **Filter Ranges**: Min/max/step values for numeric filters
- **Boolean Filters**: Toggle filter definitions
### Frontend Integration Requirements
#### TypeScript Integration
- All endpoints return properly typed responses
- Company model unified across parks and rides domains
- Consistent error handling patterns
- Proper pagination interface implementation
#### State Management Patterns
- Handle loading states during API calls
- Implement proper error boundaries for 404/500 responses
- Cache filter options to reduce API calls
- Debounce search/autocomplete queries
#### User Experience Recommendations
- Show loading indicators during data fetching
- Implement infinite scroll or pagination controls
- Provide clear error messages for failed requests
- Use autocomplete for company and ride model searches
### Performance Optimization Strategies
- **Database Queries**: All endpoints use optimized select_related and prefetch_related
- **Caching**: Filter options can be cached client-side
- **Pagination**: Use appropriate page sizes (default 20, max 1000)
- **Search Debouncing**: Implement 300ms debounce for search queries
### Testing Considerations
- Verify all endpoints return 200 status codes
- Test pagination with various page sizes
- Validate filter combinations work correctly
- Ensure search functionality returns relevant results
- Test error handling for invalid parameters
### Backend Compatibility Notes
- **Fully Supported**: All documented endpoints are fully functional
- **Real Database Queries**: All responses use actual database data, no mock responses
- **Consistent Response Format**: All endpoints follow DRF pagination standards
- **Error Handling**: Proper HTTP status codes and error messages
### Documentation Maintenance
This fix resolves the 501 errors and restores full functionality to both parks and rides API endpoints. All existing frontend integration patterns should continue to work without modification.
### Version Information
- **Fix Applied**: 2025-08-31
- **Django Version**: Compatible with current project setup
- **API Version**: v1 (stable)
- **Breaking Changes**: None - maintains existing API contract