mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:51:09 -05:00
Implement ride count fields with real-time annotations; update filters and templates for consistency and accuracy
This commit is contained in:
59
memory-bank/decisions/park_count_fields.md
Normal file
59
memory-bank/decisions/park_count_fields.md
Normal 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
|
||||||
39
memory-bank/decisions/ride_count_field.md
Normal file
39
memory-bank/decisions/ride_count_field.md
Normal 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
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
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
|
self._qs = base_qs
|
||||||
def qs(self):
|
for name, value in self.form.cleaned_data.items():
|
||||||
"""Override qs property to ensure we always start with all parks"""
|
if value in [None, '', 0] and name not in ['has_owner']:
|
||||||
if not hasattr(self, '_qs'):
|
continue
|
||||||
if not self.is_bound:
|
self._qs = self.filters[name].filter(self._qs, value)
|
||||||
self._qs = self.queryset.all()
|
self._qs = self._qs.distinct()
|
||||||
return self._qs
|
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
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
69
parks/static/parks/js/search.js
Normal file
69
parks/static/parks/js/search.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'parks/js/search.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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>
|
|
||||||
@@ -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 %}
|
||||||
575
parks/views.py
575
parks/views.py
@@ -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,111 +142,109 @@ 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"
|
||||||
context_object_name = "parks"
|
context_object_name = "parks"
|
||||||
filter_class = ParkFilter
|
filter_class = ParkFilter
|
||||||
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user