diff --git a/memory-bank/decisions/park_count_fields.md b/memory-bank/decisions/park_count_fields.md new file mode 100644 index 00000000..efeeda1c --- /dev/null +++ b/memory-bank/decisions/park_count_fields.md @@ -0,0 +1,59 @@ +# Park Count Fields Implementation + +## Context +While implementing park views, we encountered errors where `ride_count` and `coaster_count` annotations conflicted with existing model fields of the same names. Additionally, we discovered inconsistencies in how these counts were being used across different views. + +## Decision +We decided to use both approaches but with distinct names: + +1. **Model Fields**: + - `ride_count`: Stored count of all rides + - `coaster_count`: Stored count of roller coasters + - Used in models and database schema + - Required for backward compatibility + +2. **Annotations**: + - `current_ride_count`: Real-time count of all rides + - `current_coaster_count`: Real-time count of roller coasters + - Provide accurate, up-to-date counts + - Used in templates and filters + +This approach allows us to: +- Maintain existing database schema +- Show accurate, real-time counts in the UI +- Avoid name conflicts between fields and annotations +- Keep consistent naming pattern for both types of counts + +## Implementation +1. Views: + - Added base queryset method with annotations + - Used 'current_' prefix for annotated counts + - Ensured all views use the base queryset + +2. Filters: + - Updated filter fields to use annotated counts + - Configured filter class to always use base queryset + - Maintained filter functionality with new field names + +3. Templates: + - Updated templates to use computed counts + +## Why This Pattern +1. **Consistency**: Using the 'current_' prefix clearly indicates which values are computed in real-time +2. **Compatibility**: Maintains support for existing code that relies on the stored fields +3. **Flexibility**: Allows gradual migration from stored to computed counts if desired +4. **Performance Option**: Keeps the option to use stored counts for expensive queries + +## Future Considerations +We might want to: +1. Add periodic tasks to sync stored counts with computed values +2. Consider deprecating stored fields if they're not needed for performance +3. Add validation to ensure stored counts stay in sync with reality +4. Create a management command to update stored counts + +## Related Files +- parks/models.py +- parks/views.py +- parks/filters.py +- parks/templates/parks/partials/park_list_item.html +- parks/tests/test_filters.py \ No newline at end of file diff --git a/memory-bank/decisions/ride_count_field.md b/memory-bank/decisions/ride_count_field.md new file mode 100644 index 00000000..2e10067c --- /dev/null +++ b/memory-bank/decisions/ride_count_field.md @@ -0,0 +1,39 @@ +# Ride Count Field Implementation + +## Context +While implementing park views, we encountered an error where a `ride_count` annotation conflicted with an existing model field of the same name. This raised a question about how to handle real-time ride counts versus stored counts. + +## Decision +We decided to use both approaches but with distinct names: + +1. **Model Field (`ride_count`)**: + - Kept the original field for backward compatibility + - Used in test fixtures and filtering system + - Can serve as a cached/denormalized value + +2. **Annotation (`current_ride_count`)**: + - Added new annotation with a distinct name + - Provides real-time count of rides + - Used in templates for display purposes + +This approach allows us to: +- Maintain existing functionality in tests and filters +- Show accurate, real-time counts in the UI +- Avoid name conflicts between fields and annotations + +## Implementation +- Kept the `ride_count` IntegerField in the Park model +- Added `current_ride_count = Count('rides', distinct=True)` annotation in views +- Updated templates to use `current_ride_count` for display + +## Future Considerations +We might want to: +1. Add a periodic task to sync the stored `ride_count` with the computed value +2. Consider deprecating the stored field if it's not needed for performance +3. Add validation to ensure the stored count stays in sync with reality + +## Related Files +- parks/models.py +- parks/views.py +- parks/templates/parks/partials/park_list_item.html +- parks/tests/test_filters.py \ No newline at end of file diff --git a/parks/filters.py b/parks/filters.py index a172fe44..f45f40de 100644 --- a/parks/filters.py +++ b/parks/filters.py @@ -12,6 +12,7 @@ from django_filters import ( BooleanFilter ) from .models import Park +from .views import get_base_park_queryset from companies.models import Company def validate_positive_integer(value): @@ -24,7 +25,7 @@ def validate_positive_integer(value): except (TypeError, ValueError): raise ValidationError(_('Invalid number format')) -class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin): +class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, FilterSet): """Filter set for parks with search and validation capabilities""" class Meta: model = Park @@ -50,12 +51,12 @@ class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFil # Numeric filters min_rides = NumberFilter( - field_name='ride_count', + field_name='current_ride_count', lookup_expr='gte', validators=[validate_positive_integer] ) min_coasters = NumberFilter( - field_name='coaster_count', + field_name='current_coaster_count', lookup_expr='gte', validators=[validate_positive_integer] ) @@ -93,22 +94,25 @@ class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFil def filter_has_owner(self, queryset, name, value): """Filter parks based on whether they have an owner""" return queryset.filter(owner__isnull=not value) +@property +def qs(self): + """Override qs property to ensure we always use base queryset with annotations""" + if not hasattr(self, '_qs'): + # Start with the base queryset that includes annotations + base_qs = get_base_park_queryset() + + if not self.is_bound: + self._qs = base_qs + return self._qs + + if not self.form.is_valid(): + self._qs = base_qs.none() + return self._qs - @property - def qs(self): - """Override qs property to ensure we always start with all parks""" - if not hasattr(self, '_qs'): - if not self.is_bound: - self._qs = self.queryset.all() - return self._qs - if not self.form.is_valid(): - self._qs = self.queryset.none() - return self._qs - - self._qs = self.queryset.all() - for name, value in self.form.cleaned_data.items(): - if value in [None, '', 0] and name not in ['has_owner']: - continue - self._qs = self.filters[name].filter(self._qs, value) - self._qs = self._qs.distinct() - return self._qs \ No newline at end of file + self._qs = base_qs + for name, value in self.form.cleaned_data.items(): + if value in [None, '', 0] and name not in ['has_owner']: + continue + self._qs = self.filters[name].filter(self._qs, value) + self._qs = self._qs.distinct() + return self._qs \ No newline at end of file diff --git a/parks/static/parks/css/search.css b/parks/static/parks/css/search.css index 15cfba9a..f887f5ae 100644 --- a/parks/static/parks/css/search.css +++ b/parks/static/parks/css/search.css @@ -1,62 +1,134 @@ -/* Loading indicator */ +/* Loading states */ +.htmx-request .htmx-indicator { + opacity: 1; +} +.htmx-request.htmx-indicator { + opacity: 1; +} .htmx-indicator { opacity: 0; transition: opacity 200ms ease-in-out; } -.htmx-request .htmx-indicator { +/* Results container transitions */ +#park-results { + transition: opacity 200ms ease-in-out; +} +.htmx-request #park-results { + opacity: 0.7; +} +.htmx-settling #park-results { opacity: 1; } -/* Search results container */ -#search-results { - margin-top: 0.5rem; - border: 1px solid #e5e7eb; - border-radius: 0.5rem; +/* Grid/List transitions */ +.park-card { + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + position: relative; background-color: white; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - max-height: 400px; - overflow-y: auto; + border-radius: 0.5rem; + border: 1px solid #e5e7eb; } -#search-results:empty { - display: none; +/* Grid view styles */ +.park-card[data-view-mode="grid"] { + display: flex; + flex-direction: column; +} +.park-card[data-view-mode="grid"]:hover { + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); } -/* Search result items */ -#search-results .border-b { - border-color: #e5e7eb; +/* List view styles */ +.park-card[data-view-mode="list"] { + display: flex; + gap: 1rem; + padding: 1rem; +} +.park-card[data-view-mode="list"]:hover { + background-color: #f9fafb; } -#search-results a { - display: block; - transition: background-color 150ms ease-in-out; +/* Image containers */ +.park-card .image-container { + position: relative; + overflow: hidden; +} +.park-card[data-view-mode="grid"] .image-container { + aspect-ratio: 16 / 9; + width: 100%; +} +.park-card[data-view-mode="list"] .image-container { + width: 6rem; + height: 6rem; + flex-shrink: 0; } -#search-results a:hover { - background-color: #f3f4f6; +/* Content */ +.park-card .content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; /* Enables text truncation in flex child */ } -/* Dark mode adjustments */ +/* Status badges */ +.park-card .status-badge { + transition: all 150ms ease-in-out; +} +.park-card:hover .status-badge { + transform: scale(1.05); +} + +/* Images */ +.park-card img { + transition: transform 200ms ease-in-out; + object-fit: cover; + width: 100%; + height: 100%; +} +.park-card:hover img { + transform: scale(1.05); +} + +/* Placeholders for missing images */ +.park-card .placeholder { + background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%); + background-size: 200% 100%; + animation: shimmer 1.5s linear infinite; +} + +@keyframes shimmer { + to { + background-position: 200% center; + } +} + +/* Dark mode */ @media (prefers-color-scheme: dark) { - #search-results { + .park-card { background-color: #1f2937; border-color: #374151; } - #search-results .text-gray-900 { + .park-card[data-view-mode="list"]:hover { + background-color: #374151; + } + + .park-card .text-gray-900 { color: #f3f4f6; } - #search-results .text-gray-500 { + .park-card .text-gray-500 { color: #9ca3af; } - #search-results .border-b { - border-color: #374151; + .park-card .placeholder { + background: linear-gradient(110deg, #2d3748 8%, #374151 18%, #2d3748 33%); } - #search-results a:hover { + .park-card[data-view-mode="list"]:hover { background-color: #374151; } } \ No newline at end of file diff --git a/parks/static/parks/js/search.js b/parks/static/parks/js/search.js new file mode 100644 index 00000000..66895b08 --- /dev/null +++ b/parks/static/parks/js/search.js @@ -0,0 +1,69 @@ +// Handle view mode persistence across HTMX requests +document.addEventListener('htmx:configRequest', function(evt) { + // Preserve view mode + const parkResults = document.getElementById('park-results'); + if (parkResults) { + const viewMode = parkResults.getAttribute('data-view-mode'); + if (viewMode) { + evt.detail.parameters['view_mode'] = viewMode; + } + } + + // Preserve search terms + const searchInput = document.getElementById('search'); + if (searchInput && searchInput.value) { + evt.detail.parameters['search'] = searchInput.value; + } +}); + +// Handle loading states +document.addEventListener('htmx:beforeRequest', function(evt) { + const target = evt.detail.target; + if (target) { + target.classList.add('htmx-requesting'); + } +}); + +document.addEventListener('htmx:afterRequest', function(evt) { + const target = evt.detail.target; + if (target) { + target.classList.remove('htmx-requesting'); + } +}); + +// Handle history navigation +document.addEventListener('htmx:historyRestore', function(evt) { + const parkResults = document.getElementById('park-results'); + if (parkResults && evt.detail.path) { + const url = new URL(evt.detail.path, window.location.origin); + const viewMode = url.searchParams.get('view_mode'); + if (viewMode) { + parkResults.setAttribute('data-view-mode', viewMode); + } + } +}); + +// Initialize lazy loading for images +function initializeLazyLoading(container) { + if (!('IntersectionObserver' in window)) return; + + const imageObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const img = entry.target; + img.src = img.dataset.src; + img.removeAttribute('data-src'); + imageObserver.unobserve(img); + } + }); + }); + + container.querySelectorAll('img[data-src]').forEach(img => { + imageObserver.observe(img); + }); +} + +// Initialize lazy loading after HTMX content swaps +document.addEventListener('htmx:afterSwap', function(evt) { + initializeLazyLoading(evt.detail.target); +}); \ No newline at end of file diff --git a/parks/templates/parks/park_list.html b/parks/templates/parks/park_list.html index ecf867f9..7bb94828 100644 --- a/parks/templates/parks/park_list.html +++ b/parks/templates/parks/park_list.html @@ -2,136 +2,97 @@ {% load static %} {% load filter_utils %} -{% block page_title %}Parks{% endblock %} +{% block title %}Parks - ThrillWiki{% endblock %} -{% block filter_errors %} - {% if filter.errors %} -
-

Please correct the following errors:

- -
- {% endif %} -{% endblock %} - -{% block list_header %} -
- - -
+{% block list_actions %}
-

Parks

+
+

Parks

+ +
+ + +
+
+ {% if user.is_authenticated %} - + Add Park {% endif %}
{% endblock %} -{% block list_description %} -Browse and filter amusement parks, theme parks, and water parks from around the world. -{% endblock %} - {% block filter_section %}
- {# Quick Search #} -
-
- -
- -
-
- - - - -
-
+
+ + +
+
+
-
- {# Advanced Filters #}
-

Advanced Filters

-
- {% include "search/partials/filter_form.html" with filter=filter %} -
+

Filters

+
+ {% include "search/components/filter_form.html" with filter=filter %} +
{% endblock %} -{% block results_section_title %}All Parks{% endblock %} - -{% block no_results_message %} -
- No parks found matching your criteria. Try adjusting your filters or add a new park. -
-{% endblock %} - {% block results_list %} -
- {% include "parks/partials/park_list_item.html" with parks=parks %} +
+ {% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
+{% endblock %} + +{% block extra_js %} + {% endblock %} \ No newline at end of file diff --git a/parks/templates/parks/partials/park_card.html b/parks/templates/parks/partials/park_card.html deleted file mode 100644 index 96498cbd..00000000 --- a/parks/templates/parks/partials/park_card.html +++ /dev/null @@ -1,58 +0,0 @@ -{% load static %} -{% load filter_utils %} - -
- - -
- {% if park.photos.exists %} - {{ park.name }} - {% else %} -
- {{ park.name|first|upper }} -
- {% endif %} -
- -
-

- {{ park.name }} -

- -
- {% with location=park.location.first %} - {% if location %} - {{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}{% if location.country %}, {{ location.country }}{% endif %} - {% else %} - Location unknown - {% endif %} - {% endwith %} -
- -
- - {{ park.get_status_display }} - - - {% if park.opening_date %} - - Opened {{ park.opening_date|date:"Y" }} - - {% endif %} - - {% if park.ride_count %} - - {{ park.ride_count }} rides - - {% endif %} - - {% if park.coaster_count %} - - {{ park.coaster_count }} coasters - - {% endif %} -
-
-
diff --git a/parks/templates/parks/partials/park_list_item.html b/parks/templates/parks/partials/park_list_item.html index e19a9460..3a483eed 100644 --- a/parks/templates/parks/partials/park_list_item.html +++ b/parks/templates/parks/partials/park_list_item.html @@ -1,5 +1,8 @@ +{% load static %} +{% load filter_utils %} + {% if error %} -
+
@@ -8,13 +11,89 @@
{% else %} -
-{% for park in object_list|default:parks %} - {% include "parks/partials/park_card.html" with park=park view_mode=view_mode %} -{% empty %} -
- No parks found matching your search. -
-{% endfor %} +
+ {% for park in object_list|default:parks %} +
+ + + +
+ {% if park.photos.exists %} + Photo of {{ park.name }} + {% else %} + + {% endif %} +
+ +
+

+ {{ park.name }} +

+ +
+ {% with location=park.location.first %} + {% if location %} + {{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}{% if location.country %}, {{ location.country }}{% endif %} + {% else %} + Location unknown + {% endif %} + {% endwith %} +
+ +
+ + {{ park.get_status_display }} + + + {% if park.opening_date %} + + Opened {{ park.opening_date|date:"Y" }} + + {% endif %} + + {% if park.current_ride_count %} + + {{ park.current_ride_count }} ride{{ park.current_ride_count|pluralize }} + + {% endif %} + + {% if park.current_coaster_count %} + + {{ park.current_coaster_count }} coaster{{ park.current_coaster_count|pluralize }} + + {% endif %} +
+
+
+ {% empty %} +
+ {% if search_query %} + No parks found matching "{{ search_query }}". Try adjusting your search terms. + {% else %} + No parks found matching your criteria. Try adjusting your filters. + {% endif %} + {% if user.is_authenticated %} + You can also add a new park. + {% endif %} +
+ {% endfor %}
{% endif %} \ No newline at end of file diff --git a/parks/views.py b/parks/views.py index 13db9ad6..dff57dcc 100644 --- a/parks/views.py +++ b/parks/views.py @@ -1,36 +1,55 @@ -from decimal import Decimal, ROUND_DOWN, InvalidOperation -from typing import Any, Optional, cast, Type +from decimal import Decimal, ROUND_DOWN +from typing import Any, Optional, cast, Literal from django.views.generic import DetailView, ListView, CreateView, UpdateView from django.shortcuts import get_object_or_404, render -from django.core.serializers.json import DjangoJSONEncoder from django.urls import reverse -from django.db.models import Q, Avg, Count, QuerySet, Model -from search.mixins import HTMXFilterableMixin -from .filters import ParkFilter -from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Q, Count, QuerySet from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType from django.contrib import messages -from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest -import requests +from django.core.exceptions import ObjectDoesNotExist +from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse from .models import Park, ParkArea from .forms import ParkForm -from .location_utils import normalize_coordinate, normalize_osm_result +from .filters import ParkFilter from core.views import SlugRedirectMixin from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin from moderation.models import EditSubmission from media.models import Photo from location.models import Location from reviews.models import Review -from analytics.models import PageView +from search.mixins import HTMXFilterableMixin +ViewMode = Literal["grid", "list"] + + +def get_view_mode(request: HttpRequest) -> ViewMode: + """Get the current view mode from request, defaulting to grid""" + view_mode = request.GET.get('view_mode', 'grid') + return cast(ViewMode, 'list' if view_mode == 'list' else 'grid') + + +def get_base_park_queryset() -> QuerySet[Park]: + """Get base queryset with all needed annotations and prefetches""" + return ( + Park.objects.select_related('owner') + .prefetch_related('location', 'photos', 'rides') + .annotate( + current_ride_count=Count('rides', distinct=True), + current_coaster_count=Count('rides', filter=Q(rides__category="RC"), distinct=True) + ) + .order_by('name') + ) + +def add_park_button(request: HttpRequest) -> HttpResponse: + """Return the add park button partial template""" + return render(request, "parks/partials/add_park_button.html") def park_actions(request: HttpRequest, slug: str) -> HttpResponse: """Return the park actions partial template""" park = get_object_or_404(Park, slug=slug) return render(request, "parks/partials/park_actions.html", {"park": park}) - def get_park_areas(request: HttpRequest) -> HttpResponse: """Return park areas as options for a select element""" park_id = request.GET.get('park') @@ -49,53 +68,12 @@ def get_park_areas(request: HttpRequest) -> HttpResponse: except Park.DoesNotExist: return HttpResponse('') - -def search_parks(request: HttpRequest) -> HttpResponse: - """Search parks and return results for quick searches with auto-suggestions""" - try: - search_query = request.GET.get('search', '').strip() - if not search_query: - return HttpResponse('') # Keep empty string for clearing search results - - queryset = ( - Park.objects.select_related('owner') - .prefetch_related('location', 'photos') - .annotate( - ride_count=Count('rides'), - coaster_count=Count('rides', filter=Q(rides__category="RC")) - ) - .order_by('name') - ) - - # Use our existing filter but with search-specific configuration - park_filter = ParkFilter({ - 'search': search_query - }, queryset=queryset) - - parks = park_filter.qs[:8] # Limit to 8 suggestions - - response = render(request, "parks/park_list.html", { - "parks": parks - }) - response['HX-Trigger'] = 'searchComplete' - return response - - except Exception as e: - response = render(request, "parks/park_list.html", { - "parks": [], - "error": f"Error performing search: {str(e)}" - }) - response['HX-Trigger'] = 'searchError' - return response - - def location_search(request: HttpRequest) -> JsonResponse: """Search for locations using OpenStreetMap Nominatim API""" query = request.GET.get("q", "") if not query: return JsonResponse({"results": []}) - # Call Nominatim API response = requests.get( "https://nominatim.openstreetmap.org/search", params={ @@ -107,21 +85,20 @@ def location_search(request: HttpRequest) -> JsonResponse: "limit": 10, }, headers={"User-Agent": "ThrillWiki/1.0"}, - timeout=60) + timeout=60 + ) if response.status_code == 200: results = response.json() normalized_results = [normalize_osm_result(result) for result in results] valid_results = [ - r - for r in normalized_results + r for r in normalized_results if r["lat"] is not None and r["lon"] is not None ] return JsonResponse({"results": valid_results}) return JsonResponse({"results": []}) - def reverse_geocode(request: HttpRequest) -> JsonResponse: """Reverse geocode coordinates using OpenStreetMap Nominatim API""" try: @@ -137,13 +114,9 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse: lon = lon.quantize(Decimal("0.000001"), rounding=ROUND_DOWN) if lat < -90 or lat > 90: - return JsonResponse( - {"error": "Latitude must be between -90 and 90"}, status=400 - ) + return JsonResponse({"error": "Latitude must be between -90 and 90"}, status=400) if lon < -180 or lon > 180: - return JsonResponse( - {"error": "Longitude must be between -180 and 180"}, status=400 - ) + return JsonResponse({"error": "Longitude must be between -180 and 180"}, status=400) response = requests.get( "https://nominatim.openstreetmap.org/reverse", @@ -156,7 +129,8 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse: "accept-language": "en", }, headers={"User-Agent": "ThrillWiki/1.0"}, - timeout=60) + timeout=60 + ) if response.status_code == 200: result = response.json() @@ -168,111 +142,109 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse: return JsonResponse({"error": "Geocoding failed"}, status=500) -def add_park_button(request: HttpRequest) -> HttpResponse: - """Return the add park button partial template""" - return render(request, "parks/partials/add_park_button.html") - - class ParkListView(HTMXFilterableMixin, ListView): model = Park template_name = "parks/park_list.html" context_object_name = "parks" filter_class = ParkFilter paginate_by = 20 - + def get_template_names(self) -> list[str]: - """Override to use same template for HTMX and regular requests""" + """Return park_list_item.html for HTMX requests""" if self.request.htmx: return ["parks/partials/park_list_item.html"] return [self.template_name] + def get_view_mode(self) -> ViewMode: + """Get the current view mode (grid or list)""" + return get_view_mode(self.request) + def get_queryset(self) -> QuerySet[Park]: + """Get base queryset with annotations and apply filters""" try: - return ( - super() - .get_queryset() - .select_related("owner") - .prefetch_related( - "photos", - "location", - "rides", - "rides__manufacturer", - "areas" - ) - .annotate( - total_rides=Count("rides"), - total_coasters=Count("rides", filter=Q(rides__category="RC")), - ) - .order_by("name") # Ensure consistent ordering for pagination - ) + queryset = get_base_park_queryset() except Exception as e: messages.error(self.request, f"Error loading parks: {str(e)}") - return Park.objects.none() + queryset = self.model.objects.none() + + # Always initialize filterset, even if queryset failed + self.filterset = self.get_filter_class()(self.request.GET, queryset=queryset) + return self.filterset.qs def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + """Add view_mode and other context data""" try: + # Initialize filterset even if queryset fails + if not hasattr(self, 'filterset'): + self.filterset = self.get_filter_class()( + self.request.GET, + queryset=self.model.objects.none() + ) + context = super().get_context_data(**kwargs) - context['results_template'] = "parks/partials/park_list_item.html" + context.update({ + 'view_mode': self.get_view_mode(), + 'is_search': bool(self.request.GET.get('search')), + 'search_query': self.request.GET.get('search', '') + }) return context except Exception as e: messages.error(self.request, f"Error applying filters: {str(e)}") + # Ensure filterset exists in error case + if not hasattr(self, 'filterset'): + self.filterset = self.get_filter_class()( + self.request.GET, + queryset=self.model.objects.none() + ) return { - "filter": self.filterset, - "error": "Unable to apply filters. Please try adjusting your criteria.", - "results_template": "parks/partials/park_list_item.html" + 'filter': self.filterset, + 'error': "Unable to apply filters. Please try adjusting your criteria.", + 'view_mode': self.get_view_mode(), + 'is_search': bool(self.request.GET.get('search')), + 'search_query': self.request.GET.get('search', '') } -class ParkDetailView( - SlugRedirectMixin, - EditSubmissionMixin, - PhotoSubmissionMixin, - HistoryMixin, - DetailView, -): - model = Park - template_name = "parks/park_detail.html" - context_object_name = "park" +def search_parks(request: HttpRequest) -> HttpResponse: + """Search parks and return results using park_list_item.html""" + try: + search_query = request.GET.get('search', '').strip() + if not search_query: + return HttpResponse('') - def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park: - if queryset is None: - queryset = self.get_queryset() - slug = self.kwargs.get(self.slug_url_kwarg) - if slug is None: - raise ObjectDoesNotExist("No slug provided") - park, _ = Park.get_by_slug(slug) - return park + park_filter = ParkFilter({ + 'search': search_query + }, queryset=get_base_park_queryset()) - def get_queryset(self) -> QuerySet[Park]: - return cast( - QuerySet[Park], - super() - .get_queryset() - .prefetch_related( - "rides", "rides__manufacturer", "photos", "areas", "location" - ), + parks = park_filter.qs + if request.GET.get('quick_search'): + parks = parks[:8] # Limit quick search results + + response = render( + request, + "parks/partials/park_list_item.html", + { + "parks": parks, + "view_mode": get_view_mode(request), + "search_query": search_query, + "is_search": True + } ) + response['HX-Trigger'] = 'searchComplete' + return response - def get_context_data(self, **kwargs: Any) -> dict[str, Any]: - context = super().get_context_data(**kwargs) - park = cast(Park, self.object) - context["areas"] = park.areas.all() - context["rides"] = park.rides.all().order_by("-status", "name") - - if self.request.user.is_authenticated: - context["has_reviewed"] = Review.objects.filter( - user=self.request.user, - content_type=ContentType.objects.get_for_model(Park), - object_id=park.id, - ).exists() - else: - context["has_reviewed"] = False - - return context - - def get_redirect_url_pattern(self) -> str: - return "parks:park_detail" - + except Exception as e: + response = render( + request, + "parks/partials/park_list_item.html", + { + "parks": [], + "error": f"Error performing search: {str(e)}", + "is_search": True + } + ) + response['HX-Trigger'] = 'searchError' + return response class ParkCreateView(LoginRequiredMixin, CreateView): model = Park @@ -346,6 +318,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView): ) photos = self.request.FILES.getlist("photos") + uploaded_count = 0 for photo_file in photos: try: Photo.objects.create( @@ -354,6 +327,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView): content_type=ContentType.objects.get_for_model(Park), object_id=self.object.id, ) + uploaded_count += 1 except Exception as e: messages.error( self.request, @@ -363,7 +337,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView): messages.success( self.request, f"Successfully created {self.object.name}. " - f"Added {len(photos)} photo(s).", + f"Added {uploaded_count} photo(s).", ) return HttpResponseRedirect(self.get_success_url()) except Exception as e: @@ -530,6 +504,327 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView): return reverse("parks:park_detail", kwargs={"slug": self.object.slug}) +class ParkDetailView( + SlugRedirectMixin, + EditSubmissionMixin, + PhotoSubmissionMixin, + HistoryMixin, + DetailView +): + model = Park + template_name = "parks/park_detail.html" + context_object_name = "park" + + def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park: + if queryset is None: + queryset = self.get_queryset() + slug = self.kwargs.get(self.slug_url_kwarg) + if slug is None: + raise ObjectDoesNotExist("No slug provided") + park, _ = Park.get_by_slug(slug) + return park + + def get_queryset(self) -> QuerySet[Park]: + return cast( + QuerySet[Park], + super() + .get_queryset() + .prefetch_related( + "rides", + "rides__manufacturer", + "photos", + "areas", + "location" + ), + ) + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + park = cast(Park, self.object) + context["areas"] = park.areas.all() + context["rides"] = park.rides.all().order_by("-status", "name") + + if self.request.user.is_authenticated: + context["has_reviewed"] = Review.objects.filter( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Park), + object_id=park.id, + ).exists() + else: + context["has_reviewed"] = False + + return context + + def get_redirect_url_pattern(self) -> str: + return "parks:park_detail" + + +class ParkAreaDetailView( + SlugRedirectMixin, + EditSubmissionMixin, + PhotoSubmissionMixin, + HistoryMixin, + DetailView +): + model = ParkArea + template_name = "parks/area_detail.html" + context_object_name = "area" + slug_url_kwarg = "area_slug" + + def get_object(self, queryset: Optional[QuerySet[ParkArea]] = None) -> ParkArea: + if queryset is None: + queryset = self.get_queryset() + park_slug = self.kwargs.get("park_slug") + area_slug = self.kwargs.get("area_slug") + if park_slug is None or area_slug is None: + raise ObjectDoesNotExist("Missing slug") + area, _ = ParkArea.get_by_slug(area_slug) + if area.park.slug != park_slug: + raise ObjectDoesNotExist("Park slug doesn't match") + return area + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + return context + + def get_redirect_url_pattern(self) -> str: + return "parks:park_detail" + + def get_redirect_url_kwargs(self) -> dict[str, str]: + area = cast(ParkArea, self.object) + return {"park_slug": area.park.slug, "area_slug": area.slug} + + def form_valid(self, form: ParkForm) -> HttpResponse: + self.normalize_coordinates(form) + changes = self.prepare_changes_data(form.cleaned_data) + + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Park), + submission_type="CREATE", + changes=changes, + reason=self.request.POST.get("reason", ""), + source=self.request.POST.get("source", ""), + ) + + if hasattr(self.request.user, "role") and getattr( + self.request.user, "role", None + ) in ["MODERATOR", "ADMIN", "SUPERUSER"]: + try: + self.object = form.save() + submission.object_id = self.object.id + submission.status = "APPROVED" + submission.handled_by = self.request.user + submission.save() + + if form.cleaned_data.get("latitude") and form.cleaned_data.get( + "longitude" + ): + Location.objects.create( + content_type=ContentType.objects.get_for_model(Park), + object_id=self.object.id, + name=self.object.name, + location_type="park", + latitude=form.cleaned_data["latitude"], + longitude=form.cleaned_data["longitude"], + street_address=form.cleaned_data.get( + "street_address", ""), + city=form.cleaned_data.get("city", ""), + state=form.cleaned_data.get("state", ""), + country=form.cleaned_data.get("country", ""), + postal_code=form.cleaned_data.get("postal_code", ""), + ) + + photos = self.request.FILES.getlist("photos") + uploaded_count = 0 + for photo_file in photos: + try: + Photo.objects.create( + image=photo_file, + uploaded_by=self.request.user, + content_type=ContentType.objects.get_for_model( + Park), + object_id=self.object.id, + ) + uploaded_count += 1 + except Exception as e: + messages.error( + self.request, + f"Error uploading photo {photo_file.name}: {str(e)}", + ) + + messages.success( + self.request, + f"Successfully created {self.object.name}. " + f"Added {uploaded_count} photo(s).", + ) + return HttpResponseRedirect(self.get_success_url()) + except Exception as e: + messages.error( + self.request, + f"Error creating park: {str(e)}. Please check your input and try again.", + ) + return self.form_invalid(form) + + messages.success( + self.request, + "Your park submission has been sent for review. " + "You will be notified when it is approved.", + ) + return HttpResponseRedirect(reverse("parks:park_list")) + + def form_invalid(self, form: ParkForm) -> HttpResponse: + messages.error( + self.request, + "Please correct the errors below. Required fields are marked with an asterisk (*).", + ) + for field, errors in form.errors.items(): + for error in errors: + messages.error(self.request, f"{field}: {error}") + return super().form_invalid(form) + + def get_success_url(self) -> str: + return reverse("parks:park_detail", kwargs={"slug": self.object.slug}) + + +class ParkUpdateView(LoginRequiredMixin, UpdateView): + model = Park + form_class = ParkForm + template_name = "parks/park_form.html" + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + context["is_edit"] = True + return context + + def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]: + data = cleaned_data.copy() + if data.get("owner"): + data["owner"] = data["owner"].id + if data.get("opening_date"): + data["opening_date"] = data["opening_date"].isoformat() + if data.get("closing_date"): + data["closing_date"] = data["closing_date"].isoformat() + decimal_fields = ["latitude", "longitude", + "size_acres", "average_rating"] + for field in decimal_fields: + if data.get(field): + data[field] = str(data[field]) + return data + + def normalize_coordinates(self, form: ParkForm) -> None: + if form.cleaned_data.get("latitude"): + lat = Decimal(str(form.cleaned_data["latitude"])) + form.cleaned_data["latitude"] = lat.quantize( + Decimal("0.000001"), rounding=ROUND_DOWN + ) + if form.cleaned_data.get("longitude"): + lon = Decimal(str(form.cleaned_data["longitude"])) + form.cleaned_data["longitude"] = lon.quantize( + Decimal("0.000001"), rounding=ROUND_DOWN + ) + + def form_valid(self, form: ParkForm) -> HttpResponse: + self.normalize_coordinates(form) + changes = self.prepare_changes_data(form.cleaned_data) + + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Park), + object_id=self.object.id, + submission_type="EDIT", + changes=changes, + reason=self.request.POST.get("reason", ""), + source=self.request.POST.get("source", ""), + ) + + if hasattr(self.request.user, "role") and getattr( + self.request.user, "role", None + ) in ["MODERATOR", "ADMIN", "SUPERUSER"]: + try: + self.object = form.save() + submission.status = "APPROVED" + submission.handled_by = self.request.user + submission.save() + + location_data = { + "name": self.object.name, + "location_type": "park", + "latitude": form.cleaned_data.get("latitude"), + "longitude": form.cleaned_data.get("longitude"), + "street_address": form.cleaned_data.get("street_address", ""), + "city": form.cleaned_data.get("city", ""), + "state": form.cleaned_data.get("state", ""), + "country": form.cleaned_data.get("country", ""), + "postal_code": form.cleaned_data.get("postal_code", ""), + } + + if self.object.location.exists(): + location = self.object.location.first() + for key, value in location_data.items(): + setattr(location, key, value) + location.save() + else: + Location.objects.create( + content_type=ContentType.objects.get_for_model(Park), + object_id=self.object.id, + **location_data, + ) + + photos = self.request.FILES.getlist("photos") + uploaded_count = 0 + for photo_file in photos: + try: + Photo.objects.create( + image=photo_file, + uploaded_by=self.request.user, + content_type=ContentType.objects.get_for_model( + Park), + object_id=self.object.id, + ) + uploaded_count += 1 + except Exception as e: + messages.error( + self.request, + f"Error uploading photo {photo_file.name}: {str(e)}", + ) + + messages.success( + self.request, + f"Successfully updated {self.object.name}. " + f"Added {uploaded_count} new photo(s).", + ) + return HttpResponseRedirect(self.get_success_url()) + except Exception as e: + messages.error( + self.request, + f"Error updating park: {str(e)}. Please check your input and try again.", + ) + return self.form_invalid(form) + + messages.success( + self.request, + f"Your changes to {self.object.name} have been sent for review. " + "You will be notified when they are approved.", + ) + return HttpResponseRedirect( + reverse("parks:park_detail", kwargs={"slug": self.object.slug}) + ) + + def form_invalid(self, form: ParkForm) -> HttpResponse: + messages.error( + self.request, + "Please correct the errors below. Required fields are marked with an asterisk (*).", + ) + for field, errors in form.errors.items(): + for error in errors: + messages.error(self.request, f"{field}: {error}") + return super().form_invalid(form) + + def get_success_url(self) -> str: + return reverse("parks:park_detail", kwargs={"slug": self.object.slug}) + + class ParkAreaDetailView( SlugRedirectMixin, EditSubmissionMixin, diff --git a/static/css/tailwind.css b/static/css/tailwind.css index dab10d4b..1f4612e1 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -2181,6 +2181,18 @@ select { justify-content: center; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + .visible { visibility: visible; } @@ -2457,6 +2469,10 @@ select { display: none; } +.h-10 { + height: 2.5rem; +} + .h-16 { height: 4rem; } @@ -2485,6 +2501,10 @@ select { height: 1.25rem; } +.h-6 { + height: 1.5rem; +} + .h-8 { height: 2rem; } @@ -2533,6 +2553,10 @@ select { width: 1.25rem; } +.w-6 { + width: 1.5rem; +} + .w-64 { width: 16rem; } @@ -2646,6 +2670,16 @@ select { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } +@keyframes pulse { + 50% { + opacity: .5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + @keyframes spin { to { transform: rotate(360deg); @@ -3000,10 +3034,6 @@ select { background-color: rgb(17 24 39 / var(--tw-bg-opacity)); } -.bg-gray-900\/80 { - background-color: rgb(17 24 39 / 0.8); -} - .bg-green-100 { --tw-bg-opacity: 1; background-color: rgb(220 252 231 / var(--tw-bg-opacity)); @@ -3244,6 +3274,10 @@ select { padding-bottom: 1rem; } +.pt-2 { + padding-top: 0.5rem; +} + .text-left { text-align: left; } @@ -3335,6 +3369,11 @@ select { color: rgb(37 99 235 / var(--tw-text-opacity)); } +.text-blue-700 { + --tw-text-opacity: 1; + color: rgb(29 78 216 / var(--tw-text-opacity)); +} + .text-blue-800 { --tw-text-opacity: 1; color: rgb(30 64 175 / var(--tw-text-opacity)); @@ -3405,6 +3444,11 @@ select { color: rgb(79 70 229 / var(--tw-text-opacity)); } +.text-red-100 { + --tw-text-opacity: 1; + color: rgb(254 226 226 / var(--tw-text-opacity)); +} + .text-red-400 { --tw-text-opacity: 1; color: rgb(248 113 113 / var(--tw-text-opacity)); @@ -3507,6 +3551,11 @@ select { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.outline-none { + outline: 2px solid transparent; + outline-offset: 2px; +} + .ring-2 { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); @@ -3522,6 +3571,19 @@ select { --tw-ring-color: rgb(79 70 229 / 0.2); } +.ring-offset-2 { + --tw-ring-offset-width: 2px; +} + +.ring-offset-white { + --tw-ring-offset-color: #fff; +} + +.blur { + --tw-blur: blur(8px); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + .filter { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } @@ -3796,6 +3858,11 @@ select { border-color: rgb(59 130 246 / var(--tw-border-opacity)); } +.focus\:bg-gray-100:focus { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + .focus\:underline:focus { text-decoration-line: underline; } @@ -3824,6 +3891,10 @@ select { --tw-ring-offset-width: 2px; } +.active\:transform:active { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + .disabled\:opacity-50:disabled { opacity: 0.5; } @@ -3930,6 +4001,10 @@ select { background-color: rgb(185 28 28 / var(--tw-bg-opacity)); } +.dark\:bg-red-900\/40:is(.dark *) { + background-color: rgb(127 29 29 / 0.4); +} + .dark\:bg-yellow-200:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(254 240 138 / var(--tw-bg-opacity)); @@ -3968,6 +4043,11 @@ select { --tw-gradient-to: #3b0764 var(--tw-gradient-to-position); } +.dark\:text-blue-100:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(219 234 254 / var(--tw-text-opacity)); +} + .dark\:text-blue-200:is(.dark *) { --tw-text-opacity: 1; color: rgb(191 219 254 / var(--tw-text-opacity)); @@ -4190,6 +4270,11 @@ select { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.dark\:focus\:bg-gray-700:focus:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); +} + @media (min-width: 640px) { .sm\:col-span-3 { grid-column: span 3 / span 3; @@ -4297,10 +4382,26 @@ select { grid-column: span 2 / span 2; } + .md\:col-span-3 { + grid-column: span 3 / span 3; + } + .md\:mb-8 { margin-bottom: 2rem; } + .md\:block { + display: block; + } + + .md\:grid { + display: grid; + } + + .md\:hidden { + display: none; + } + .md\:h-\[140px\] { height: 140px; }