Implement ride count fields with real-time annotations; update filters and templates for consistency and accuracy

This commit is contained in:
pacnpal
2025-02-13 16:44:30 -05:00
parent 9d6f6dab2c
commit c19aaf2f4b
10 changed files with 988 additions and 367 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -12,6 +12,7 @@ from django_filters import (
BooleanFilter BooleanFilter
) )
from .models import Park from .models import Park
from .views import get_base_park_queryset
from companies.models import Company from companies.models import Company
def validate_positive_integer(value): def validate_positive_integer(value):
@@ -24,7 +25,7 @@ def validate_positive_integer(value):
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValidationError(_('Invalid number format')) 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""" """Filter set for parks with search and validation capabilities"""
class Meta: class Meta:
model = Park model = Park
@@ -50,12 +51,12 @@ class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFil
# Numeric filters # Numeric filters
min_rides = NumberFilter( min_rides = NumberFilter(
field_name='ride_count', field_name='current_ride_count',
lookup_expr='gte', lookup_expr='gte',
validators=[validate_positive_integer] validators=[validate_positive_integer]
) )
min_coasters = NumberFilter( min_coasters = NumberFilter(
field_name='coaster_count', field_name='current_coaster_count',
lookup_expr='gte', lookup_expr='gte',
validators=[validate_positive_integer] validators=[validate_positive_integer]
) )
@@ -93,22 +94,25 @@ class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFil
def filter_has_owner(self, queryset, name, value): def filter_has_owner(self, queryset, name, value):
"""Filter parks based on whether they have an owner""" """Filter parks based on whether they have an owner"""
return queryset.filter(owner__isnull=not value) 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()
@property if not self.is_bound:
def qs(self): self._qs = base_qs
"""Override qs property to ensure we always start with all parks""" return self._qs
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() if not self.form.is_valid():
for name, value in self.form.cleaned_data.items(): self._qs = base_qs.none()
if value in [None, '', 0] and name not in ['has_owner']: return self._qs
continue
self._qs = self.filters[name].filter(self._qs, value) self._qs = base_qs
self._qs = self._qs.distinct() for name, value in self.form.cleaned_data.items():
return self._qs 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

View File

@@ -1,62 +1,134 @@
/* Loading indicator */ /* Loading states */
.htmx-request .htmx-indicator {
opacity: 1;
}
.htmx-request.htmx-indicator {
opacity: 1;
}
.htmx-indicator { .htmx-indicator {
opacity: 0; opacity: 0;
transition: opacity 200ms ease-in-out; 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; opacity: 1;
} }
/* Search results container */ /* Grid/List transitions */
#search-results { .park-card {
margin-top: 0.5rem; transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid #e5e7eb; position: relative;
border-radius: 0.5rem;
background-color: white; 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); border-radius: 0.5rem;
max-height: 400px; border: 1px solid #e5e7eb;
overflow-y: auto;
} }
#search-results:empty { /* Grid view styles */
display: none; .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 */ /* List view styles */
#search-results .border-b { .park-card[data-view-mode="list"] {
border-color: #e5e7eb; display: flex;
gap: 1rem;
padding: 1rem;
}
.park-card[data-view-mode="list"]:hover {
background-color: #f9fafb;
} }
#search-results a { /* Image containers */
display: block; .park-card .image-container {
transition: background-color 150ms ease-in-out; 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 { /* Content */
background-color: #f3f4f6; .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) { @media (prefers-color-scheme: dark) {
#search-results { .park-card {
background-color: #1f2937; background-color: #1f2937;
border-color: #374151; 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; color: #f3f4f6;
} }
#search-results .text-gray-500 { .park-card .text-gray-500 {
color: #9ca3af; color: #9ca3af;
} }
#search-results .border-b { .park-card .placeholder {
border-color: #374151; background: linear-gradient(110deg, #2d3748 8%, #374151 18%, #2d3748 33%);
} }
#search-results a:hover { .park-card[data-view-mode="list"]:hover {
background-color: #374151; background-color: #374151;
} }
} }

View File

@@ -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);
});

View File

@@ -2,136 +2,97 @@
{% load static %} {% load static %}
{% load filter_utils %} {% load filter_utils %}
{% block page_title %}Parks{% endblock %} {% block title %}Parks - ThrillWiki{% endblock %}
{% block filter_errors %} {% block list_actions %}
{% if filter.errors %}
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-4">
<h3 class="text-sm font-medium text-red-800">Please correct the following errors:</h3>
<ul class="mt-2 text-sm text-red-700 list-disc pl-5 space-y-1">
{% for field, errors in filter.errors.items %}
{% for error in errors %}
<li>{{ field }}: {{ error }}</li>
{% endfor %}
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}
{% block list_header %}
<div class="flex items-center space-x-2">
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid" hx-target="#results-container" hx-push-url="true" class="p-2 rounded {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-gray-200{% endif %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<button hx-get="{% url 'parks:park_list' %}?view_mode=list" hx-target="#results-container" hx-push-url="true" class="p-2 rounded {% if request.GET.view_mode == 'list' %}bg-gray-200{% endif %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
</button>
</div>
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900 mr-4">Parks</h1> <div class="flex items-center space-x-4">
<h1 class="text-2xl font-bold text-gray-900">Parks</h1>
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1" role="group" aria-label="View mode selection">
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
hx-target="#park-results"
hx-push-url="true"
class="p-2 rounded transition-colors duration-200 {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white shadow-sm{% endif %}"
aria-label="Grid view"
aria-pressed="{% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}true{% else %}false{% endif %}">
<svg class="w-5 h-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"/>
</svg>
</button>
<button hx-get="{% url 'parks:park_list' %}?view_mode=list{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
hx-target="#park-results"
hx-push-url="true"
class="p-2 rounded transition-colors duration-200 {% if request.GET.view_mode == 'list' %}bg-white shadow-sm{% endif %}"
aria-label="List view"
aria-pressed="{% if request.GET.view_mode == 'list' %}true{% else %}false{% endif %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h7"/>
</svg>
</button>
</div>
</div>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="{% url 'parks:park_create' %}" class="btn btn-primary"> <a href="{% url 'parks:park_create' %}"
class="btn btn-primary"
data-testid="add-park-button">
Add Park Add Park
</a> </a>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block list_description %}
Browse and filter amusement parks, theme parks, and water parks from around the world.
{% endblock %}
{% block filter_section %} {% block filter_section %}
<div class="mb-6"> <div class="mb-6">
{# Quick Search #} <div class="max-w-3xl mx-auto relative mb-8">
<div class="mb-8"> <label for="search" class="sr-only">Search parks</label>
<div class="max-w-3xl mx-auto" <input type="search"
x-data="{ name="search"
selectedIndex: -1, id="search"
results: [], class="block w-full rounded-md border-gray-300 bg-white py-3 pl-4 pr-10 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm"
get hasResults() { return this.results.length > 0 }, placeholder="Search parks by name or location..."
init() { hx-get="{% url 'parks:search_parks' %}"
this.$watch('results', () => this.selectedIndex = -1) hx-trigger="input delay:300ms, search"
}, hx-target="#park-results"
onKeyDown(e) { hx-push-url="true"
if (!this.hasResults) return hx-indicator="#search-indicator"
if (e.key === 'ArrowDown') { value="{{ request.GET.search|default:'' }}"
e.preventDefault() aria-label="Search parks">
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1) <div class="absolute inset-y-0 right-0 flex items-center pr-3">
} <div id="search-indicator" class="htmx-indicator">
else if (e.key === 'ArrowUp') { <svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
e.preventDefault() <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
this.selectedIndex = Math.max(this.selectedIndex - 1, 0) <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
} </svg>
else if (e.key === 'Enter' && this.selectedIndex >= 0) {
e.preventDefault()
this.$refs[`result-${this.selectedIndex}`]?.click()
}
else if (e.key === 'Escape') {
this.results = []
this.$refs.input.value = ''
this.$refs.input.blur()
}
}
}"
@click.away="results = []">
<label for="search" class="sr-only">Search parks</label>
<div class="relative">
<input type="search"
name="search"
id="search"
x-ref="input"
class="block w-full rounded-md border-gray-300 bg-white py-3 pl-4 pr-10 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm"
placeholder="Search parks by name or location..."
hx-get="{% url 'parks:search_parks' %}"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#search-results"
hx-indicator="#search-indicator"
@keydown="onKeyDown($event)"
autocomplete="off"
role="combobox"
aria-expanded="false"
aria-controls="search-results"
aria-autocomplete="list">
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<div id="search-indicator" class="htmx-indicator">
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
</div>
</div> </div>
<div id="search-results"
class="mt-2"
role="listbox"
x-init="$watch('$el.children', value => results = Array.from(value))"></div>
</div> </div>
</div> </div>
{# Advanced Filters #}
<div class="bg-white shadow sm:rounded-lg"> <div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Advanced Filters</h3> <h3 class="text-lg font-medium leading-6 text-gray-900">Filters</h3>
<div class="mt-4"> <form id="filter-form"
{% include "search/partials/filter_form.html" with filter=filter %} hx-get="{% url 'parks:park_list' %}"
</div> hx-target="#park-results"
hx-push-url="true"
hx-trigger="change"
class="mt-4">
{% include "search/components/filter_form.html" with filter=filter %}
</form>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block results_section_title %}All Parks{% endblock %}
{% block no_results_message %}
<div class="text-center p-8 text-gray-500">
No parks found matching your criteria. Try adjusting your filters or <a href="{% url 'parks:park_create' %}" class="text-blue-600 hover:underline">add a new park</a>.
</div>
{% endblock %}
{% block results_list %} {% block results_list %}
<div class="bg-white rounded-lg shadow"> <div id="park-results"
{% include "parks/partials/park_list_item.html" with parks=parks %} class="bg-white rounded-lg shadow"
data-view-mode="{{ view_mode|default:'grid' }}">
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %}
<script src="{% static 'parks/js/search.js' %}"></script>
{% endblock %}

View File

@@ -1,58 +0,0 @@
{% load static %}
{% load filter_utils %}
<div class="park-card group relative border rounded-lg p-4 transition-all duration-200 ease-in-out {% if view_mode == 'grid' %}grid-item hover:shadow-lg{% else %}flex items-start space-x-4 hover:bg-gray-50{% endif %}">
<a href="{% url 'parks:park_detail' park.slug %}" class="absolute inset-0 z-0"></a>
<div class="relative z-10">
{% if park.photos.exists %}
<img src="{{ park.photos.first.image.url }}"
alt="{{ park.name }}"
class="{% if view_mode == 'grid' %}w-full h-48 object-cover rounded-lg mb-4{% else %}w-24 h-24 object-cover rounded-lg{% endif %}">
{% else %}
<div class="{% if view_mode == 'grid' %}w-full h-48 bg-gray-100 rounded-lg mb-4{% else %}w-24 h-24 bg-gray-100 rounded-lg{% endif %} flex items-center justify-center">
<span class="text-2xl font-medium text-gray-400">{{ park.name|first|upper }}</span>
</div>
{% endif %}
</div>
<div class="{% if view_mode != 'grid' %}flex-1 min-w-0{% endif %}">
<h3 class="text-lg font-semibold truncate">
{{ park.name }}
</h3>
<div class="mt-1 text-sm text-gray-500 truncate">
{% 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 %}
</div>
<div class="mt-2 flex flex-wrap gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }}">
{{ park.get_status_display }}
</span>
{% if park.opening_date %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Opened {{ park.opening_date|date:"Y" }}
</span>
{% endif %}
{% if park.ride_count %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ park.ride_count }} rides
</span>
{% endif %}
{% if park.coaster_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">
{{ park.coaster_count }} coasters
</span>
{% endif %}
</div>
</div>
</div>

View File

@@ -1,5 +1,8 @@
{% load static %}
{% load filter_utils %}
{% if error %} {% if error %}
<div class="p-4"> <div class="p-4" data-testid="park-list-error">
<div class="inline-flex items-center px-4 py-2 rounded-md bg-red-50 text-red-700"> <div class="inline-flex items-center px-4 py-2 rounded-md bg-red-50 text-red-700">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
@@ -8,13 +11,89 @@
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="{% if view_mode == 'grid' %}grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6{% else %}space-y-6{% endif %}"> <div class="{% if view_mode == 'grid' %}grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 p-4{% else %}flex flex-col gap-4 p-4{% endif %}"
{% for park in object_list|default:parks %} data-testid="park-list"
{% include "parks/partials/park_card.html" with park=park view_mode=view_mode %} data-view-mode="{{ view_mode|default:'grid' }}">
{% empty %} {% for park in object_list|default:parks %}
<div class="p-4 text-sm text-gray-500 text-center"> <article class="park-card group relative bg-white border rounded-lg transition-all duration-200 ease-in-out hover:shadow-lg {% if view_mode == 'list' %}flex gap-4 p-4{% endif %}"
No parks found matching your search. data-testid="park-card"
</div> data-park-id="{{ park.id }}"
{% endfor %} data-view-mode="{{ view_mode|default:'grid' }}">
<a href="{% url 'parks:park_detail' park.slug %}"
class="absolute inset-0 z-0"
aria-label="View details for {{ park.name }}"></a>
<div class="relative z-10 {% if view_mode == 'grid' %}aspect-video{% endif %}">
{% if park.photos.exists %}
<img src="{{ park.photos.first.image.url }}"
alt="Photo of {{ park.name }}"
class="{% if view_mode == 'grid' %}w-full h-full object-cover rounded-t-lg{% else %}w-24 h-24 object-cover rounded-lg flex-shrink-0{% endif %}"
loading="lazy">
{% else %}
<div class="{% if view_mode == 'grid' %}w-full h-full bg-gray-100 rounded-t-lg flex items-center justify-center{% else %}w-24 h-24 bg-gray-100 rounded-lg flex-shrink-0 flex items-center justify-center{% endif %}"
role="img"
aria-label="Park initial letter">
<span class="text-2xl font-medium text-gray-400">{{ park.name|first|upper }}</span>
</div>
{% endif %}
</div>
<div class="{% if view_mode == 'grid' %}p-4{% else %}flex-1 min-w-0{% endif %}">
<h3 class="text-lg font-semibold text-gray-900 truncate group-hover:text-blue-600">
{{ park.name }}
</h3>
<div class="mt-1 text-sm text-gray-500 truncate">
{% 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 %}
</div>
<div class="mt-2 flex flex-wrap gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} status-badge"
data-testid="park-status">
{{ park.get_status_display }}
</span>
{% if park.opening_date %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
data-testid="park-opening-date">
Opened {{ park.opening_date|date:"Y" }}
</span>
{% endif %}
{% if park.current_ride_count %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
data-testid="park-ride-count">
{{ park.current_ride_count }} ride{{ park.current_ride_count|pluralize }}
</span>
{% endif %}
{% if park.current_coaster_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"
data-testid="park-coaster-count">
{{ park.current_coaster_count }} coaster{{ park.current_coaster_count|pluralize }}
</span>
{% endif %}
</div>
</div>
</article>
{% empty %}
<div class="{% if view_mode == 'grid' %}col-span-full{% endif %} p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found">
{% 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 <a href="{% url 'parks:park_create' %}" class="text-blue-600 hover:underline">add a new park</a>.
{% endif %}
</div>
{% endfor %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -1,36 +1,55 @@
from decimal import Decimal, ROUND_DOWN, InvalidOperation from decimal import Decimal, ROUND_DOWN
from typing import Any, Optional, cast, Type from typing import Any, Optional, cast, Literal
from django.views.generic import DetailView, ListView, CreateView, UpdateView from django.views.generic import DetailView, ListView, CreateView, UpdateView
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.core.serializers.json import DjangoJSONEncoder
from django.urls import reverse from django.urls import reverse
from django.db.models import Q, Avg, Count, QuerySet, Model from django.db.models import Q, Count, QuerySet
from search.mixins import HTMXFilterableMixin
from .filters import ParkFilter
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib import messages from django.contrib import messages
from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest from django.core.exceptions import ObjectDoesNotExist
import requests from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
from .models import Park, ParkArea from .models import Park, ParkArea
from .forms import ParkForm from .forms import ParkForm
from .location_utils import normalize_coordinate, normalize_osm_result from .filters import ParkFilter
from core.views import SlugRedirectMixin from core.views import SlugRedirectMixin
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
from moderation.models import EditSubmission from moderation.models import EditSubmission
from media.models import Photo from media.models import Photo
from location.models import Location from location.models import Location
from reviews.models import Review 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: def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
"""Return the park actions partial template""" """Return the park actions partial template"""
park = get_object_or_404(Park, slug=slug) park = get_object_or_404(Park, slug=slug)
return render(request, "parks/partials/park_actions.html", {"park": park}) return render(request, "parks/partials/park_actions.html", {"park": park})
def get_park_areas(request: HttpRequest) -> HttpResponse: def get_park_areas(request: HttpRequest) -> HttpResponse:
"""Return park areas as options for a select element""" """Return park areas as options for a select element"""
park_id = request.GET.get('park') park_id = request.GET.get('park')
@@ -49,53 +68,12 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
except Park.DoesNotExist: except Park.DoesNotExist:
return HttpResponse('<option value="">Invalid park selected</option>') return HttpResponse('<option value="">Invalid park selected</option>')
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: def location_search(request: HttpRequest) -> JsonResponse:
"""Search for locations using OpenStreetMap Nominatim API""" """Search for locations using OpenStreetMap Nominatim API"""
query = request.GET.get("q", "") query = request.GET.get("q", "")
if not query: if not query:
return JsonResponse({"results": []}) return JsonResponse({"results": []})
# Call Nominatim API
response = requests.get( response = requests.get(
"https://nominatim.openstreetmap.org/search", "https://nominatim.openstreetmap.org/search",
params={ params={
@@ -107,21 +85,20 @@ def location_search(request: HttpRequest) -> JsonResponse:
"limit": 10, "limit": 10,
}, },
headers={"User-Agent": "ThrillWiki/1.0"}, headers={"User-Agent": "ThrillWiki/1.0"},
timeout=60) timeout=60
)
if response.status_code == 200: if response.status_code == 200:
results = response.json() results = response.json()
normalized_results = [normalize_osm_result(result) for result in results] normalized_results = [normalize_osm_result(result) for result in results]
valid_results = [ valid_results = [
r r for r in normalized_results
for r in normalized_results
if r["lat"] is not None and r["lon"] is not None if r["lat"] is not None and r["lon"] is not None
] ]
return JsonResponse({"results": valid_results}) return JsonResponse({"results": valid_results})
return JsonResponse({"results": []}) return JsonResponse({"results": []})
def reverse_geocode(request: HttpRequest) -> JsonResponse: def reverse_geocode(request: HttpRequest) -> JsonResponse:
"""Reverse geocode coordinates using OpenStreetMap Nominatim API""" """Reverse geocode coordinates using OpenStreetMap Nominatim API"""
try: try:
@@ -137,13 +114,9 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
lon = lon.quantize(Decimal("0.000001"), rounding=ROUND_DOWN) lon = lon.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
if lat < -90 or lat > 90: if lat < -90 or lat > 90:
return JsonResponse( return JsonResponse({"error": "Latitude must be between -90 and 90"}, status=400)
{"error": "Latitude must be between -90 and 90"}, status=400
)
if lon < -180 or lon > 180: if lon < -180 or lon > 180:
return JsonResponse( return JsonResponse({"error": "Longitude must be between -180 and 180"}, status=400)
{"error": "Longitude must be between -180 and 180"}, status=400
)
response = requests.get( response = requests.get(
"https://nominatim.openstreetmap.org/reverse", "https://nominatim.openstreetmap.org/reverse",
@@ -156,7 +129,8 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
"accept-language": "en", "accept-language": "en",
}, },
headers={"User-Agent": "ThrillWiki/1.0"}, headers={"User-Agent": "ThrillWiki/1.0"},
timeout=60) timeout=60
)
if response.status_code == 200: if response.status_code == 200:
result = response.json() result = response.json()
@@ -168,11 +142,6 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
return JsonResponse({"error": "Geocoding failed"}, status=500) 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): class ParkListView(HTMXFilterableMixin, ListView):
model = Park model = Park
template_name = "parks/park_list.html" template_name = "parks/park_list.html"
@@ -181,98 +150,101 @@ class ParkListView(HTMXFilterableMixin, ListView):
paginate_by = 20 paginate_by = 20
def get_template_names(self) -> list[str]: 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: if self.request.htmx:
return ["parks/partials/park_list_item.html"] return ["parks/partials/park_list_item.html"]
return [self.template_name] 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]: def get_queryset(self) -> QuerySet[Park]:
"""Get base queryset with annotations and apply filters"""
try: try:
return ( queryset = get_base_park_queryset()
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
)
except Exception as e: except Exception as e:
messages.error(self.request, f"Error loading parks: {str(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]: def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
"""Add view_mode and other context data"""
try: 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 = 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 return context
except Exception as e: except Exception as e:
messages.error(self.request, f"Error applying filters: {str(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 { return {
"filter": self.filterset, 'filter': self.filterset,
"error": "Unable to apply filters. Please try adjusting your criteria.", 'error': "Unable to apply filters. Please try adjusting your criteria.",
"results_template": "parks/partials/park_list_item.html" 'view_mode': self.get_view_mode(),
'is_search': bool(self.request.GET.get('search')),
'search_query': self.request.GET.get('search', '')
} }
class ParkDetailView( def search_parks(request: HttpRequest) -> HttpResponse:
SlugRedirectMixin, """Search parks and return results using park_list_item.html"""
EditSubmissionMixin, try:
PhotoSubmissionMixin, search_query = request.GET.get('search', '').strip()
HistoryMixin, if not search_query:
DetailView, return HttpResponse('')
):
model = Park
template_name = "parks/park_detail.html"
context_object_name = "park"
def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park: park_filter = ParkFilter({
if queryset is None: 'search': search_query
queryset = self.get_queryset() }, queryset=get_base_park_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]: parks = park_filter.qs
return cast( if request.GET.get('quick_search'):
QuerySet[Park], parks = parks[:8] # Limit quick search results
super()
.get_queryset() response = render(
.prefetch_related( request,
"rides", "rides__manufacturer", "photos", "areas", "location" "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]: except Exception as e:
context = super().get_context_data(**kwargs) response = render(
park = cast(Park, self.object) request,
context["areas"] = park.areas.all() "parks/partials/park_list_item.html",
context["rides"] = park.rides.all().order_by("-status", "name") {
"parks": [],
if self.request.user.is_authenticated: "error": f"Error performing search: {str(e)}",
context["has_reviewed"] = Review.objects.filter( "is_search": True
user=self.request.user, }
content_type=ContentType.objects.get_for_model(Park), )
object_id=park.id, response['HX-Trigger'] = 'searchError'
).exists() return response
else:
context["has_reviewed"] = False
return context
def get_redirect_url_pattern(self) -> str:
return "parks:park_detail"
class ParkCreateView(LoginRequiredMixin, CreateView): class ParkCreateView(LoginRequiredMixin, CreateView):
model = Park model = Park
@@ -346,6 +318,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
) )
photos = self.request.FILES.getlist("photos") photos = self.request.FILES.getlist("photos")
uploaded_count = 0
for photo_file in photos: for photo_file in photos:
try: try:
Photo.objects.create( Photo.objects.create(
@@ -354,6 +327,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
content_type=ContentType.objects.get_for_model(Park), content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id, object_id=self.object.id,
) )
uploaded_count += 1
except Exception as e: except Exception as e:
messages.error( messages.error(
self.request, self.request,
@@ -363,7 +337,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
messages.success( messages.success(
self.request, self.request,
f"Successfully created {self.object.name}. " f"Successfully created {self.object.name}. "
f"Added {len(photos)} photo(s).", f"Added {uploaded_count} photo(s).",
) )
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
except Exception as e: except Exception as e:
@@ -530,6 +504,327 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
return reverse("parks:park_detail", kwargs={"slug": self.object.slug}) 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( class ParkAreaDetailView(
SlugRedirectMixin, SlugRedirectMixin,
EditSubmissionMixin, EditSubmissionMixin,

View File

@@ -2181,6 +2181,18 @@ select {
justify-content: center; 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 { .visible {
visibility: visible; visibility: visible;
} }
@@ -2457,6 +2469,10 @@ select {
display: none; display: none;
} }
.h-10 {
height: 2.5rem;
}
.h-16 { .h-16 {
height: 4rem; height: 4rem;
} }
@@ -2485,6 +2501,10 @@ select {
height: 1.25rem; height: 1.25rem;
} }
.h-6 {
height: 1.5rem;
}
.h-8 { .h-8 {
height: 2rem; height: 2rem;
} }
@@ -2533,6 +2553,10 @@ select {
width: 1.25rem; width: 1.25rem;
} }
.w-6 {
width: 1.5rem;
}
.w-64 { .w-64 {
width: 16rem; 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)); 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 { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@@ -3000,10 +3034,6 @@ select {
background-color: rgb(17 24 39 / var(--tw-bg-opacity)); 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 { .bg-green-100 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(220 252 231 / var(--tw-bg-opacity)); background-color: rgb(220 252 231 / var(--tw-bg-opacity));
@@ -3244,6 +3274,10 @@ select {
padding-bottom: 1rem; padding-bottom: 1rem;
} }
.pt-2 {
padding-top: 0.5rem;
}
.text-left { .text-left {
text-align: left; text-align: left;
} }
@@ -3335,6 +3369,11 @@ select {
color: rgb(37 99 235 / var(--tw-text-opacity)); 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 { .text-blue-800 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity)); color: rgb(30 64 175 / var(--tw-text-opacity));
@@ -3405,6 +3444,11 @@ select {
color: rgb(79 70 229 / var(--tw-text-opacity)); 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 { .text-red-400 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity)); 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); 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 { .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-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); --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); --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 {
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: 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)); 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 { .focus\:underline:focus {
text-decoration-line: underline; text-decoration-line: underline;
} }
@@ -3824,6 +3891,10 @@ select {
--tw-ring-offset-width: 2px; --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 { .disabled\:opacity-50:disabled {
opacity: 0.5; opacity: 0.5;
} }
@@ -3930,6 +4001,10 @@ select {
background-color: rgb(185 28 28 / var(--tw-bg-opacity)); 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 *) { .dark\:bg-yellow-200:is(.dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(254 240 138 / var(--tw-bg-opacity)); background-color: rgb(254 240 138 / var(--tw-bg-opacity));
@@ -3968,6 +4043,11 @@ select {
--tw-gradient-to: #3b0764 var(--tw-gradient-to-position); --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 *) { .dark\:text-blue-200:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(191 219 254 / var(--tw-text-opacity)); color: rgb(191 219 254 / var(--tw-text-opacity));
@@ -4190,6 +4270,11 @@ select {
color: rgb(255 255 255 / var(--tw-text-opacity)); 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) { @media (min-width: 640px) {
.sm\:col-span-3 { .sm\:col-span-3 {
grid-column: span 3 / span 3; grid-column: span 3 / span 3;
@@ -4297,10 +4382,26 @@ select {
grid-column: span 2 / span 2; grid-column: span 2 / span 2;
} }
.md\:col-span-3 {
grid-column: span 3 / span 3;
}
.md\:mb-8 { .md\:mb-8 {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.md\:block {
display: block;
}
.md\:grid {
display: grid;
}
.md\:hidden {
display: none;
}
.md\:h-\[140px\] { .md\:h-\[140px\] {
height: 140px; height: 140px;
} }