mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 17:11:08 -05:00
Refactor API structure and add comprehensive user management features
- Restructure API v1 with improved serializers organization - Add user deletion requests and moderation queue system - Implement bulk moderation operations and permissions - Add user profile enhancements with display names and avatars - Expand ride and park API endpoints with better filtering - Add manufacturer API with detailed ride relationships - Improve authentication flows and error handling - Update frontend documentation and API specifications
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
from django import forms
|
||||
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
||||
from autocomplete import AutocompleteWidget
|
||||
from autocomplete.core import register
|
||||
from autocomplete.shortcuts import ModelAutocomplete
|
||||
from autocomplete.widgets import AutocompleteWidget
|
||||
from .models import Park
|
||||
from .models.location import ParkLocation
|
||||
from .querysets import get_base_park_queryset
|
||||
|
||||
|
||||
class ParkAutocomplete(forms.Form):
|
||||
@register
|
||||
class ParkAutocomplete(ModelAutocomplete):
|
||||
"""Autocomplete for searching parks.
|
||||
|
||||
Features:
|
||||
@@ -19,25 +21,6 @@ class ParkAutocomplete(forms.Form):
|
||||
model = Park
|
||||
search_attrs = ["name"] # We'll match on park names
|
||||
|
||||
def get_search_results(self, search):
|
||||
"""Return search results with related data."""
|
||||
return (
|
||||
get_base_park_queryset()
|
||||
.filter(name__icontains=search)
|
||||
.select_related("operator", "property_owner")
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
def format_result(self, park):
|
||||
"""Format each park result with status and location."""
|
||||
location = park.formatted_location
|
||||
location_text = f" • {location}" if location else ""
|
||||
return {
|
||||
"key": str(park.pk),
|
||||
"label": park.name,
|
||||
"extra": f"{park.get_status_display()}{location_text}",
|
||||
}
|
||||
|
||||
|
||||
class ParkSearchForm(forms.Form):
|
||||
"""Form for searching parks with autocomplete."""
|
||||
|
||||
@@ -35,8 +35,7 @@ class ParkPhoto(TrackedModel):
|
||||
)
|
||||
|
||||
image = CloudflareImagesField(
|
||||
variant="public",
|
||||
help_text="Park photo stored on Cloudflare Images"
|
||||
variant="public", help_text="Park photo stored on Cloudflare Images"
|
||||
)
|
||||
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
@@ -93,7 +92,9 @@ class ParkPhoto(TrackedModel):
|
||||
ParkPhoto.objects.filter(
|
||||
park=self.park,
|
||||
is_primary=True,
|
||||
).exclude(pk=self.pk).update(is_primary=False)
|
||||
).exclude(
|
||||
pk=self.pk
|
||||
).update(is_primary=False)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class Park(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="parks_using_as_banner",
|
||||
help_text="Photo to use as banner image for this park"
|
||||
help_text="Photo to use as banner image for this park",
|
||||
)
|
||||
card_image = models.ForeignKey(
|
||||
"ParkPhoto",
|
||||
@@ -71,7 +71,7 @@ class Park(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="parks_using_as_card",
|
||||
help_text="Photo to use as card image for this park"
|
||||
help_text="Photo to use as card image for this park",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
@@ -173,7 +173,7 @@ class Park(TrackedModel):
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
# Generate frontend URL
|
||||
frontend_domain = getattr(settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
|
||||
frontend_domain = getattr(settings, "FRONTEND_DOMAIN", "https://thrillwiki.com")
|
||||
self.url = f"{frontend_domain}/parks/{self.slug}/"
|
||||
|
||||
# Save the model
|
||||
|
||||
@@ -4,434 +4,45 @@
|
||||
{% block title %}Parks - ThrillWiki{% endblock %}
|
||||
|
||||
{% block list_actions %}
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
{# Enhanced View Mode Toggle with Modern Design #}
|
||||
<fieldset class="flex items-center space-x-1 bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-xl p-1 shadow-inner">
|
||||
<legend class="sr-only">View mode selection</legend>
|
||||
|
||||
{# Grid View Button #}
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'view_mode' and key != 'search' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#view-mode-indicator"
|
||||
class="group relative p-3 rounded-lg transition-all duration-300 {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white dark:bg-gray-800 shadow-lg text-blue-600 dark:text-blue-400 scale-105{% else %}text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-white/50 dark:hover:bg-gray-800/50{% 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 transition-transform duration-200 group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
{% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}
|
||||
<div class="absolute inset-0 rounded-lg bg-gradient-to-r from-blue-500/20 to-purple-500/20 animate-pulse"></div>
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
{# List View Button #}
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=list{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'view_mode' and key != 'search' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#view-mode-indicator"
|
||||
class="group relative p-3 rounded-lg transition-all duration-300 {% if request.GET.view_mode == 'list' %}bg-white dark:bg-gray-800 shadow-lg text-blue-600 dark:text-blue-400 scale-105{% else %}text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-white/50 dark:hover:bg-gray-800/50{% endif %}"
|
||||
aria-label="List view"
|
||||
aria-pressed="{% if request.GET.view_mode == 'list' %}true{% else %}false{% endif %}">
|
||||
<svg class="w-5 h-5 transition-transform duration-200 group-hover:scale-110" 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>
|
||||
{% if request.GET.view_mode == 'list' %}
|
||||
<div class="absolute inset-0 rounded-lg bg-gradient-to-r from-blue-500/20 to-purple-500/20 animate-pulse"></div>
|
||||
{% endif %}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
{# View Mode Loading Indicator #}
|
||||
<div id="view-mode-indicator" class="htmx-indicator">
|
||||
<div class="flex items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">Switching view...</span>
|
||||
</div>
|
||||
</div>
|
||||
{# Simple View Toggle #}
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded-md text-sm font-medium transition-all {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white dark:bg-gray-600 shadow-sm text-blue-600 dark:text-blue-400{% else %}text-gray-700 dark:text-gray-300{% endif %}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=list{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded-md text-sm font-medium transition-all {% if request.GET.view_mode == 'list' %}bg-white dark:bg-gray-600 shadow-sm text-blue-600 dark:text-blue-400{% else %}text-gray-700 dark:text-gray-300{% endif %}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Enhanced Add Park Button #}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'parks:park_create' %}"
|
||||
class="group inline-flex items-center px-6 py-3 border border-transparent text-sm font-semibold rounded-xl shadow-lg text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-300 transform hover:scale-105 hover:shadow-xl"
|
||||
data-testid="add-park-button">
|
||||
<svg class="w-5 h-5 mr-2 transition-transform duration-200 group-hover:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
Add Park
|
||||
<div class="absolute inset-0 rounded-xl bg-gradient-to-r from-white/20 to-white/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block filter_section %}
|
||||
<!-- DEBUG: park_list.html filter_section block is being rendered - timestamp: 2025-08-21 -->
|
||||
<div class="mb-6" x-data="parkListManager()" x-init="init()">
|
||||
{# Enhanced Search Section #}
|
||||
<div class="relative mb-8">
|
||||
<div class="w-full relative"
|
||||
x-data="{ query: '', selectedId: null }"
|
||||
@search-selected.window="
|
||||
query = $event.detail;
|
||||
selectedId = $event.target.value;
|
||||
$refs.filterForm.querySelector('input[name=search]').value = query;
|
||||
$refs.filterForm.submit();
|
||||
query = '';
|
||||
">
|
||||
<form hx-get="{% url 'parks:suggest_parks' %}"
|
||||
hx-target="#search-results"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-indicator="#search-indicator"
|
||||
x-ref="searchForm">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search"
|
||||
name="search"
|
||||
placeholder="Search parks by name, location, or description..."
|
||||
class="w-full pl-10 pr-12 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 focus:ring-blue-500 transition-colors duration-200"
|
||||
aria-label="Search parks"
|
||||
aria-controls="search-results"
|
||||
:aria-expanded="query !== ''"
|
||||
x-model="query"
|
||||
@keydown.escape="query = ''"
|
||||
@focus="$event.target.select()">
|
||||
|
||||
<!-- Clear search button -->
|
||||
<button type="button"
|
||||
x-show="query"
|
||||
@click="query = ''; $refs.searchForm.querySelector('input').value = ''; $refs.filterForm.submit();"
|
||||
class="absolute inset-y-0 right-8 flex items-center pr-1 text-gray-400 hover:text-gray-600 focus:outline-none">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="search-indicator"
|
||||
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
|
||||
role="status"
|
||||
aria-label="Loading search results">
|
||||
<svg class="w-5 h-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>
|
||||
<span class="sr-only">Searching...</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="search-results"
|
||||
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-600"
|
||||
role="listbox"
|
||||
x-show="query"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95">
|
||||
<!-- Search suggestions will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Active Filter Chips Section #}
|
||||
<div id="active-filters-section"
|
||||
x-show="hasActiveFilters"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
class="mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Active Filters</h3>
|
||||
<button type="button"
|
||||
@click="clearAllFilters()"
|
||||
class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 font-medium focus:outline-none focus:underline">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2" id="filter-chips-container">
|
||||
<!-- Filter chips will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Filter Panel #}
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">Filters</h3>
|
||||
<button type="button"
|
||||
x-data="{ collapsed: false }"
|
||||
@click="collapsed = !collapsed; toggleFilterCollapse()"
|
||||
class="lg:hidden text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium focus:outline-none">
|
||||
<span x-text="collapsed ? 'Show Filters' : 'Hide Filters'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="filter-form"
|
||||
x-ref="filterForm"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change, submit"
|
||||
hx-indicator="#main-loading-indicator"
|
||||
class="mt-4"
|
||||
@htmx:beforeRequest="onFilterRequest()"
|
||||
@htmx:afterRequest="onFilterResponse($event)">
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% include "core/search/components/filter_form.html" with filter=filter %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Main Loading Indicator #}
|
||||
<div id="main-loading-indicator" class="htmx-indicator fixed top-4 left-1/2 transform -translate-x-1/2 z-50">
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white px-6 py-3 rounded-lg shadow-lg flex items-center backdrop-blur-sm">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Updating results...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function parkListManager() {
|
||||
return {
|
||||
hasActiveFilters: false,
|
||||
filterCollapsed: false,
|
||||
lastResultCount: 0,
|
||||
|
||||
init() {
|
||||
this.updateActiveFilters();
|
||||
this.setupFilterChips();
|
||||
|
||||
// Listen for form changes to update filter chips
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target.closest('#filter-form')) {
|
||||
setTimeout(() => this.updateActiveFilters(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for HTMX responses to update result counts
|
||||
document.addEventListener('htmx:afterSwap', (e) => {
|
||||
if (e.detail.target.id === 'results-container') {
|
||||
this.updateResultInfo();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateActiveFilters() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const activeFilters = [];
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (this.isFilterActive(input)) {
|
||||
activeFilters.push(this.createFilterChip(input));
|
||||
}
|
||||
});
|
||||
|
||||
this.hasActiveFilters = activeFilters.length > 0;
|
||||
this.renderFilterChips(activeFilters);
|
||||
},
|
||||
|
||||
isFilterActive(input) {
|
||||
if (!input.name || input.type === 'hidden') return false;
|
||||
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
return input.checked;
|
||||
}
|
||||
|
||||
return input.value && input.value !== '' && input.value !== 'all' && input.value !== '0';
|
||||
},
|
||||
|
||||
createFilterChip(input) {
|
||||
let label = input.name;
|
||||
let value = input.value;
|
||||
|
||||
// Get human readable label from associated label element
|
||||
const labelElement = document.querySelector(`label[for="${input.id}"]`);
|
||||
if (labelElement) {
|
||||
label = labelElement.textContent.trim();
|
||||
}
|
||||
|
||||
// Format value for display
|
||||
if (input.type === 'checkbox') {
|
||||
value = 'Yes';
|
||||
} else if (input.tagName === 'SELECT') {
|
||||
const selectedOption = input.querySelector(`option[value="${input.value}"]`);
|
||||
if (selectedOption) {
|
||||
value = selectedOption.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: input.name,
|
||||
label: label,
|
||||
value: value,
|
||||
displayText: `${label}: ${value}`
|
||||
};
|
||||
},
|
||||
|
||||
renderFilterChips(chips) {
|
||||
const container = document.getElementById('filter-chips-container');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = chips.map(chip => `
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100">
|
||||
${chip.displayText}
|
||||
<button type="button"
|
||||
onclick="removeFilter('${chip.name}')"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100 focus:outline-none"
|
||||
aria-label="Remove ${chip.label} filter">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
`).join('');
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else if (input.type !== 'hidden') {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
this.hasActiveFilters = false;
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
toggleFilterCollapse() {
|
||||
this.filterCollapsed = !this.filterCollapsed;
|
||||
},
|
||||
|
||||
onFilterRequest() {
|
||||
// Add loading state to results
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.style.opacity = '0.6';
|
||||
resultsContainer.style.pointerEvents = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
onFilterResponse(event) {
|
||||
// Remove loading state
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.style.opacity = '1';
|
||||
resultsContainer.style.pointerEvents = 'auto';
|
||||
}
|
||||
|
||||
// Update active filters after response
|
||||
setTimeout(() => this.updateActiveFilters(), 100);
|
||||
},
|
||||
|
||||
updateResultInfo() {
|
||||
// This would update any result count information
|
||||
// Implementation depends on how results are structured
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global function to remove individual filters
|
||||
function removeFilter(filterName) {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
const input = form.querySelector(`[name="${filterName}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
htmx.trigger(form, 'submit');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block results_list %}
|
||||
<div id="park-results"
|
||||
class="overflow-hidden transition-all duration-300"
|
||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||
|
||||
{# Enhanced Results Header with Modern Design #}
|
||||
<div class="bg-gradient-to-r from-gray-50 to-white dark:from-gray-800 dark:to-gray-700 border-b border-gray-200/50 dark:border-gray-600/50 px-6 py-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h2 class="text-xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent">
|
||||
Parks
|
||||
{% if parks %}
|
||||
<span class="text-base font-normal text-gray-500 dark:text-gray-400">
|
||||
({{ parks|length }}{% if parks.has_other_pages %} of {{ parks.paginator.count }}{% endif %} found)
|
||||
</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{# Enhanced Results Status Indicator #}
|
||||
{% if request.GET.search or request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-blue-100 to-purple-100 text-blue-800 dark:from-blue-900/30 dark:to-purple-900/30 dark:text-blue-300 border border-blue-200 dark:border-blue-700/50">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.414A1 1 0 013 6.707V4z"/>
|
||||
</svg>
|
||||
Filtered Results
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Enhanced Sort Options #}
|
||||
<div class="flex items-center space-x-3">
|
||||
<label for="sort-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">Sort by:</label>
|
||||
<div class="relative">
|
||||
<select id="sort-select"
|
||||
name="ordering"
|
||||
form="filter-form"
|
||||
class="appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white text-sm rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 pl-3 pr-10 py-2 transition-colors duration-200"
|
||||
onchange="document.getElementById('filter-form').submit()">
|
||||
<option value="">Default</option>
|
||||
<option value="name" {% if request.GET.ordering == 'name' %}selected{% endif %}>Name (A-Z)</option>
|
||||
<option value="-name" {% if request.GET.ordering == '-name' %}selected{% endif %}>Name (Z-A)</option>
|
||||
<option value="-average_rating" {% if request.GET.ordering == '-average_rating' %}selected{% endif %}>Highest Rated</option>
|
||||
<option value="-coaster_count" {% if request.GET.ordering == '-coaster_count' %}selected{% endif %}>Most Coasters</option>
|
||||
<option value="-ride_count" {% if request.GET.ordering == '-ride_count' %}selected{% endif %}>Most Rides</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Results Content with Adaptive Grid #}
|
||||
{# Results Content with Adaptive Grid #}
|
||||
<div class="p-6">
|
||||
{% if parks %}
|
||||
{# Enhanced Responsive Grid Container #}
|
||||
@@ -550,4 +161,4 @@ document.addEventListener('htmx:afterSwap', function(event) {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,7 @@ from apps.core.mixins import HTMXFilterableMixin
|
||||
from .models.location import ParkLocation
|
||||
from .models.media import ParkPhoto
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.moderation.services import ModerationService
|
||||
from apps.moderation.mixins import (
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
@@ -501,88 +502,85 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
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",
|
||||
# Use the new queue routing service
|
||||
result = ModerationService.create_edit_submission_with_queue(
|
||||
content_object=None, # None for CREATE
|
||||
changes=changes,
|
||||
submitter=self.request.user,
|
||||
submission_type="CREATE",
|
||||
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 ALLOWED_ROLES
|
||||
):
|
||||
try:
|
||||
self.object = form.save()
|
||||
submission.object_id = self.object.id
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = self.request.user
|
||||
submission.save()
|
||||
if result['status'] == 'auto_approved':
|
||||
# Moderator submission was auto-approved
|
||||
self.object = result['created_object']
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
park=self.object,
|
||||
defaults={
|
||||
"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", "USA"),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
},
|
||||
)
|
||||
park_location.set_coordinates(
|
||||
form.cleaned_data["latitude"],
|
||||
form.cleaned_data["longitude"],
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get(
|
||||
"longitude"
|
||||
):
|
||||
# Create or update ParkLocation
|
||||
park_location, created = ParkLocation.objects.get_or_create(
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
park=self.object,
|
||||
defaults={
|
||||
"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", "USA"),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
},
|
||||
)
|
||||
park_location.set_coordinates(
|
||||
form.cleaned_data["latitude"],
|
||||
form.cleaned_data["longitude"],
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {photo_file.name}: {str(e)}",
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
park=self.object,
|
||||
)
|
||||
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(
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully created {self.object.name}. "
|
||||
f"Added {uploaded_count} photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
elif result['status'] == 'queued':
|
||||
# Regular user submission was queued
|
||||
messages.success(
|
||||
self.request,
|
||||
"Your park submission has been sent for review. "
|
||||
"You will be notified when it is approved.",
|
||||
)
|
||||
# Redirect to parks list since we don't have an object yet
|
||||
return HttpResponseRedirect(reverse("parks:park_list"))
|
||||
|
||||
elif result['status'] == 'failed':
|
||||
# Auto-approval failed
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error creating park: {result['message']}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Fallback error case
|
||||
messages.error(
|
||||
self.request,
|
||||
"Your park submission has been sent for review. "
|
||||
"You will be notified when it is approved.",
|
||||
"An unexpected error occurred. Please try again.",
|
||||
)
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
messages.error(self.request, f"{field}: {error}")
|
||||
return super().form_invalid(form)
|
||||
return self.form_invalid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
@@ -633,125 +631,129 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
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",
|
||||
# Use the new queue routing service
|
||||
result = ModerationService.create_edit_submission_with_queue(
|
||||
content_object=self.object,
|
||||
changes=changes,
|
||||
submitter=self.request.user,
|
||||
submission_type="EDIT",
|
||||
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 ALLOWED_ROLES
|
||||
):
|
||||
if result['status'] == 'auto_approved':
|
||||
# Moderator submission was auto-approved
|
||||
# The object was already updated by the service
|
||||
self.object = result['created_object']
|
||||
|
||||
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", ""),
|
||||
}
|
||||
|
||||
# Create or update ParkLocation
|
||||
try:
|
||||
self.object = form.save()
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = self.request.user
|
||||
submission.save()
|
||||
park_location = self.object.location
|
||||
# Update existing location
|
||||
for key, value in location_data.items():
|
||||
if key in ["latitude", "longitude"] and value:
|
||||
continue # Handle coordinates separately
|
||||
if hasattr(park_location, key):
|
||||
setattr(park_location, key, value)
|
||||
|
||||
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", ""),
|
||||
# Handle coordinates if provided
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
park_location.set_coordinates(
|
||||
float(location_data["latitude"]),
|
||||
float(location_data["longitude"]),
|
||||
)
|
||||
park_location.save()
|
||||
except ParkLocation.DoesNotExist:
|
||||
# Create new ParkLocation
|
||||
coordinates_data = {}
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
coordinates_data = {
|
||||
"latitude": float(location_data["latitude"]),
|
||||
"longitude": float(location_data["longitude"]),
|
||||
}
|
||||
|
||||
# Remove coordinate fields from location_data for creation
|
||||
creation_data = {
|
||||
k: v
|
||||
for k, v in location_data.items()
|
||||
if k not in ["latitude", "longitude"]
|
||||
}
|
||||
creation_data.setdefault("country", "USA")
|
||||
|
||||
# Create or update ParkLocation
|
||||
try:
|
||||
park_location = self.object.location
|
||||
# Update existing location
|
||||
for key, value in location_data.items():
|
||||
if key in ["latitude", "longitude"] and value:
|
||||
continue # Handle coordinates separately
|
||||
if hasattr(park_location, key):
|
||||
setattr(park_location, key, value)
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=self.object, **creation_data
|
||||
)
|
||||
|
||||
# Handle coordinates if provided
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
park_location.set_coordinates(
|
||||
float(location_data["latitude"]),
|
||||
float(location_data["longitude"]),
|
||||
)
|
||||
if coordinates_data:
|
||||
park_location.set_coordinates(
|
||||
coordinates_data["latitude"],
|
||||
coordinates_data["longitude"],
|
||||
)
|
||||
park_location.save()
|
||||
except ParkLocation.DoesNotExist:
|
||||
# Create new ParkLocation
|
||||
coordinates_data = {}
|
||||
if "latitude" in location_data and "longitude" in location_data:
|
||||
if location_data["latitude"] and location_data["longitude"]:
|
||||
coordinates_data = {
|
||||
"latitude": float(location_data["latitude"]),
|
||||
"longitude": float(location_data["longitude"]),
|
||||
}
|
||||
|
||||
# Remove coordinate fields from location_data for creation
|
||||
creation_data = {
|
||||
k: v
|
||||
for k, v in location_data.items()
|
||||
if k not in ["latitude", "longitude"]
|
||||
}
|
||||
creation_data.setdefault("country", "USA")
|
||||
|
||||
park_location = ParkLocation.objects.create(
|
||||
park=self.object, **creation_data
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.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)}",
|
||||
)
|
||||
|
||||
if coordinates_data:
|
||||
park_location.set_coordinates(
|
||||
coordinates_data["latitude"],
|
||||
coordinates_data["longitude"],
|
||||
)
|
||||
park_location.save()
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
ParkPhoto.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(
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully updated {self.object.name}. "
|
||||
f"Added {uploaded_count} new photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
elif result['status'] == 'queued':
|
||||
# Regular user submission was queued
|
||||
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(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
)
|
||||
|
||||
elif result['status'] == 'failed':
|
||||
# Auto-approval failed
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error updating park: {result['message']}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Fallback error case
|
||||
messages.error(
|
||||
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(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
"An unexpected error occurred. Please try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
||||
messages.error(self.request, REQUIRED_FIELDS_ERROR)
|
||||
|
||||
Reference in New Issue
Block a user