mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 09:31:09 -05:00
Add comprehensive tests for Parks API and models
- Implemented extensive test cases for the Parks API, covering endpoints for listing, retrieving, creating, updating, and deleting parks. - Added tests for filtering, searching, and ordering parks in the API. - Created tests for error handling in the API, including malformed JSON and unsupported methods. - Developed model tests for Park, ParkArea, Company, and ParkReview models, ensuring validation and constraints are enforced. - Introduced utility mixins for API and model testing to streamline assertions and enhance test readability. - Included integration tests to validate complete workflows involving park creation, retrieval, updating, and deletion.
This commit is contained in:
80
templates/components/card.html
Normal file
80
templates/components/card.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{% comment %}
|
||||
Reusable card component for consistent styling across the application.
|
||||
Usage: {% include 'components/card.html' with title="Card Title" content="Card content" %}
|
||||
{% endcomment %}
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 overflow-hidden {{ extra_classes|default:'' }}">
|
||||
{% if image_url %}
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<img
|
||||
src="{{ image_url }}"
|
||||
alt="{{ image_alt|default:title }}"
|
||||
class="w-full h-48 object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-6">
|
||||
{% if title %}
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">
|
||||
{% if link_url %}
|
||||
<a href="{{ link_url }}" class="hover:text-blue-600 transition-colors">
|
||||
{{ title }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ title }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
{% if subtitle %}
|
||||
<p class="text-sm text-gray-500 mb-3">{{ subtitle }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if content %}
|
||||
<div class="text-gray-700 mb-4">
|
||||
{{ content|truncatewords:30 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tags %}
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{% for tag in tags %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ tag }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if stats %}
|
||||
<div class="flex items-center justify-between text-sm text-gray-500 mb-4">
|
||||
{% for stat in stats %}
|
||||
<div class="flex items-center">
|
||||
{% if stat.icon %}
|
||||
<i class="{{ stat.icon }} mr-1"></i>
|
||||
{% endif %}
|
||||
<span>{{ stat.label }}: {{ stat.value }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if actions %}
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
{% for action in actions %}
|
||||
<a
|
||||
href="{{ action.url }}"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
{% if action.icon %}
|
||||
<i class="{{ action.icon }} mr-1"></i>
|
||||
{% endif %}
|
||||
{{ action.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
93
templates/components/pagination.html
Normal file
93
templates/components/pagination.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% comment %}
|
||||
Reusable pagination component with accessibility and responsive design.
|
||||
Usage: {% include 'components/pagination.html' with page_obj=page_obj %}
|
||||
{% endcomment %}
|
||||
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6" aria-label="Pagination">
|
||||
<div class="hidden sm:block">
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing
|
||||
<span class="font-medium">{{ page_obj.start_index }}</span>
|
||||
to
|
||||
<span class="font-medium">{{ page_obj.end_index }}</span>
|
||||
of
|
||||
<span class="font-medium">{{ page_obj.paginator.count }}</span>
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex justify-between sm:justify-end">
|
||||
{% if page_obj.has_previous %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Previous
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
|
||||
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Previous
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Page numbers for larger screens -->
|
||||
<div class="hidden md:flex">
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if num == page_obj.number %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-blue-500 bg-blue-50 text-sm font-medium text-blue-600 mx-1">
|
||||
{{ num }}
|
||||
</span>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 mx-1 transition-colors"
|
||||
aria-label="Go to page {{ num }}"
|
||||
>
|
||||
{{ num }}
|
||||
</a>
|
||||
{% elif num == 1 or num == page_obj.paginator.num_pages %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 mx-1 transition-colors"
|
||||
aria-label="Go to page {{ num }}"
|
||||
>
|
||||
{{ num }}
|
||||
</a>
|
||||
{% elif num == page_obj.number|add:'-4' or num == page_obj.number|add:'4' %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-500 mx-1">
|
||||
...
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
Next
|
||||
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
|
||||
Next
|
||||
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
142
templates/components/search_form.html
Normal file
142
templates/components/search_form.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% comment %}
|
||||
Reusable search form component with filtering capabilities.
|
||||
Usage: {% include 'components/search_form.html' with placeholder="Search parks..." filters=filter_options %}
|
||||
{% endcomment %}
|
||||
|
||||
<form method="get" class="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<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="text"
|
||||
name="search"
|
||||
id="search"
|
||||
value="{{ request.GET.search }}"
|
||||
placeholder="{{ placeholder|default:'Search...' }}"
|
||||
class="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if filters %}
|
||||
{% for filter in filters %}
|
||||
<div>
|
||||
<label for="{{ filter.name }}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{{ filter.label }}
|
||||
</label>
|
||||
|
||||
{% if filter.type == 'select' %}
|
||||
<select
|
||||
name="{{ filter.name }}"
|
||||
id="{{ filter.name }}"
|
||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All {{ filter.label }}</option>
|
||||
{% for option in filter.options %}
|
||||
<option
|
||||
value="{{ option.value }}"
|
||||
{% if request.GET|get_item:filter.name == option.value %}selected{% endif %}
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
{% elif filter.type == 'checkbox' %}
|
||||
<div class="space-y-2">
|
||||
{% for option in filter.options %}
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="{{ filter.name }}"
|
||||
value="{{ option.value }}"
|
||||
{% if option.value in request.GET|getlist:filter.name %}checked{% endif %}
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
>
|
||||
<span class="ml-2 text-sm text-gray-700">{{ option.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% elif filter.type == 'range' %}
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
name="{{ filter.name }}_min"
|
||||
value="{{ request.GET|get_item:filter.name_min }}"
|
||||
placeholder="Min"
|
||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
name="{{ filter.name }}_max"
|
||||
value="{{ request.GET|get_item:filter.name_max }}"
|
||||
placeholder="Max"
|
||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm 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="-ml-1 mr-2 h-4 w-4" 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>
|
||||
Search
|
||||
</button>
|
||||
|
||||
{% if request.GET.urlencode %}
|
||||
<a
|
||||
href="{{ request.path }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
<svg class="-ml-1 mr-2 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>
|
||||
Clear
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if show_sort %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<label for="ordering" class="text-sm font-medium text-gray-700">Sort by:</label>
|
||||
<select
|
||||
name="ordering"
|
||||
id="ordering"
|
||||
onchange="this.form.submit()"
|
||||
class="block px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
{% for option in sort_options|default:"name,Name (A-Z);-name,Name (Z-A);created_at,Newest First;-created_at,Oldest First" %}
|
||||
{% with option_parts=option|split:"," %}
|
||||
<option
|
||||
value="{{ option_parts.0 }}"
|
||||
{% if request.GET.ordering == option_parts.0 %}selected{% endif %}
|
||||
>
|
||||
{{ option_parts.1 }}
|
||||
</option>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
17
templates/components/status_badge.html
Normal file
17
templates/components/status_badge.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% comment %}
|
||||
Reusable status badge component with consistent styling.
|
||||
Usage: {% include 'components/status_badge.html' with status="OPERATING" %}
|
||||
{% endcomment %}
|
||||
|
||||
{% load park_tags %}
|
||||
|
||||
{% with status_config=status|get_status_config %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ status_config.classes }}">
|
||||
{% if status_config.icon %}
|
||||
<svg class="-ml-0.5 mr-1.5 h-2 w-2" fill="currentColor" viewBox="0 0 8 8">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ status_config.label }}
|
||||
</span>
|
||||
{% endwith %}
|
||||
Reference in New Issue
Block a user