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:
pacnpal
2025-08-29 16:03:51 -04:00
parent 7b9f64be72
commit bb7da85516
92 changed files with 19690 additions and 9076 deletions

View File

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