Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX

This commit is contained in:
pacnpal
2025-12-22 16:56:27 -05:00
parent 2e35f8c5d9
commit ae31e889d7
144 changed files with 25792 additions and 4440 deletions

View File

@@ -925,10 +925,7 @@ class MapBoundsAPIView(APIView):
except Exception as e: except Exception as e:
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True) logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
return Response( return Response(
{ {"status": "error", "message": "Failed to retrieve locations within bounds"},
"status": "error",
"message": "Failed to retrieve locations within bounds",
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
) )
@@ -961,20 +958,18 @@ class MapStatsAPIView(APIView):
return Response( return Response(
{ {
"status": "success", "status": "success",
"data": { "total_locations": total_locations,
"total_locations": total_locations, "parks_with_location": parks_with_location,
"parks_with_location": parks_with_location, "rides_with_location": rides_with_location,
"rides_with_location": rides_with_location, "cache_hits": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking
"cache_hits": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking "cache_misses": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking
"cache_misses": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking
},
} }
) )
except Exception as e: except Exception as e:
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True) logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
return Response( return Response(
{"error": f"Internal server error: {str(e)}"}, {"status": "error", "message": "Failed to retrieve map statistics"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
) )
@@ -1019,13 +1014,14 @@ class MapCacheAPIView(APIView):
{ {
"status": "success", "status": "success",
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.", "message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
"cleared_count": cleared_count,
} }
) )
except Exception as e: except Exception as e:
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True) logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
return Response( return Response(
{"error": f"Internal server error: {str(e)}"}, {"status": "error", "message": "Failed to clear map cache"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
) )
@@ -1046,13 +1042,14 @@ class MapCacheAPIView(APIView):
{ {
"status": "success", "status": "success",
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.", "message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
"invalidated_count": invalidated_count,
} }
) )
except Exception as e: except Exception as e:
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True) logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)
return Response( return Response(
{"error": f"Internal server error: {str(e)}"}, {"status": "error", "message": "Failed to invalidate cache"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
) )

View File

@@ -3,14 +3,29 @@ Context processors for the core app.
This module provides context processors that add useful utilities This module provides context processors that add useful utilities
and data to template contexts across the application. and data to template contexts across the application.
Available Context Processors:
- fsm_context: FSM state machine utilities
- breadcrumbs: Breadcrumb navigation data
- page_meta: Page metadata for SEO and social sharing
""" """
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from django_fsm import can_proceed from django_fsm import can_proceed
from .state_machine.exceptions import format_transition_error from .state_machine.exceptions import format_transition_error
from .state_machine.mixins import TRANSITION_METADATA from .state_machine.mixins import TRANSITION_METADATA
from .utils.breadcrumbs import Breadcrumb, BreadcrumbBuilder, breadcrumbs_to_schema
if TYPE_CHECKING:
from django.http import HttpRequest
def fsm_context(request): def fsm_context(request: HttpRequest) -> dict[str, Any]:
""" """
Add FSM utilities to template context. Add FSM utilities to template context.
@@ -31,7 +46,108 @@ def fsm_context(request):
Dictionary of FSM utilities Dictionary of FSM utilities
""" """
return { return {
'can_proceed': can_proceed, "can_proceed": can_proceed,
'format_transition_error': format_transition_error, "format_transition_error": format_transition_error,
'TRANSITION_METADATA': TRANSITION_METADATA, "TRANSITION_METADATA": TRANSITION_METADATA,
}
def breadcrumbs(request: HttpRequest) -> dict[str, Any]:
"""
Add breadcrumb utilities to template context.
This context processor provides breadcrumb-related utilities and data
to all templates. Views can override the default breadcrumbs by setting
`request.breadcrumbs` before the context processor runs.
Available context variables:
- breadcrumbs: List of Breadcrumb instances (from view or auto-generated)
- breadcrumbs_json: JSON-LD Schema.org BreadcrumbList for SEO
- BreadcrumbBuilder: Class for building breadcrumbs in templates
- build_breadcrumb: Function for creating single breadcrumb items
Usage in views:
def park_detail(request, slug):
park = get_object_or_404(Park, slug=slug)
request.breadcrumbs = [
build_breadcrumb('Home', '/', icon='fas fa-home'),
build_breadcrumb('Parks', reverse('parks:list')),
build_breadcrumb(park.name, is_current=True),
]
return render(request, 'parks/detail.html', {'park': park})
Usage in templates:
{% if breadcrumbs %}
{% include 'components/navigation/breadcrumbs.html' %}
{% endif %}
{# For Schema.org structured data #}
<script type="application/ld+json">{{ breadcrumbs_json|safe }}</script>
Returns:
Dictionary with breadcrumb utilities and data
"""
from .utils.breadcrumbs import build_breadcrumb
# Get breadcrumbs from request if set by view
crumbs: list[Breadcrumb] = getattr(request, "breadcrumbs", [])
# Generate Schema.org JSON-LD
breadcrumbs_json = ""
if crumbs:
schema = breadcrumbs_to_schema(crumbs, request)
breadcrumbs_json = json.dumps(schema)
return {
"breadcrumbs": crumbs,
"breadcrumbs_json": breadcrumbs_json,
"BreadcrumbBuilder": BreadcrumbBuilder,
"build_breadcrumb": build_breadcrumb,
}
def page_meta(request: HttpRequest) -> dict[str, Any]:
"""
Add page metadata utilities to template context.
This context processor provides default values and utilities for
page metadata including titles, descriptions, and social sharing tags.
Views can override defaults by setting attributes on the request.
Available context variables:
- site_name: Default site name ('ThrillWiki')
- default_description: Default meta description
- default_og_image: Default Open Graph image URL
Request attributes that views can set:
- request.page_title: Override page title
- request.meta_description: Override meta description
- request.og_image: Override Open Graph image
- request.og_type: Override Open Graph type
- request.canonical_url: Override canonical URL
Usage in views:
def park_detail(request, slug):
park = get_object_or_404(Park, slug=slug)
request.page_title = f'{park.name} - Parks - ThrillWiki'
request.meta_description = park.description[:160]
request.og_image = park.featured_image.url if park.featured_image else None
request.og_type = 'place'
return render(request, 'parks/detail.html', {'park': park})
Returns:
Dictionary with page metadata
"""
from django.templatetags.static import static
return {
"site_name": "ThrillWiki",
"default_description": "ThrillWiki - Your comprehensive guide to theme parks and roller coasters",
"default_og_image": static("images/og-default.jpg"),
# Pass through any request-level overrides
"page_title": getattr(request, "page_title", None),
"meta_description": getattr(request, "meta_description", None),
"og_image": getattr(request, "og_image", None),
"og_type": getattr(request, "og_type", "website"),
"canonical_url": getattr(request, "canonical_url", None),
} }

View File

@@ -1,9 +1,36 @@
"""Utilities for HTMX integration in Django views.""" """
Utilities for HTMX integration in Django views.
This module provides helper functions for creating standardized HTMX responses
with consistent patterns for success, error, and redirect handling.
Usage Examples:
Success with toast:
return htmx_success('Park saved successfully!')
Error with message:
return htmx_error('Validation failed', status=422)
Redirect with message:
return htmx_redirect_with_message('/parks/', 'Park created!')
Close modal and refresh:
return htmx_modal_close(refresh_target='#park-list')
"""
from __future__ import annotations
import json
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING, Any
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.template import TemplateDoesNotExist from django.template import TemplateDoesNotExist
from django.template.loader import render_to_string from django.template.loader import render_to_string
if TYPE_CHECKING:
from django.http import HttpRequest
def _resolve_context_and_template(resp, default_template): def _resolve_context_and_template(resp, default_template):
"""Extract context and template from view response.""" """Extract context and template from view response."""
@@ -55,31 +82,31 @@ def htmx_partial(template_name):
return decorator return decorator
def htmx_redirect(url): def htmx_redirect(url: str) -> HttpResponse:
"""Create a response that triggers a client-side redirect via HTMX.""" """Create a response that triggers a client-side redirect via HTMX."""
resp = HttpResponse("") resp = HttpResponse("")
resp["HX-Redirect"] = url resp["HX-Redirect"] = url
return resp return resp
def htmx_trigger(name: str, payload: dict = None): def htmx_trigger(name: str, payload: dict | None = None) -> HttpResponse:
"""Create a response that triggers a client-side event via HTMX.""" """Create a response that triggers a client-side event via HTMX."""
resp = HttpResponse("") resp = HttpResponse("")
if payload is None: if payload is None:
resp["HX-Trigger"] = name resp["HX-Trigger"] = name
else: else:
resp["HX-Trigger"] = JsonResponse({name: payload}).content.decode() resp["HX-Trigger"] = json.dumps({name: payload})
return resp return resp
def htmx_refresh(): def htmx_refresh() -> HttpResponse:
"""Create a response that triggers a client-side page refresh via HTMX.""" """Create a response that triggers a client-side page refresh via HTMX."""
resp = HttpResponse("") resp = HttpResponse("")
resp["HX-Refresh"] = "true" resp["HX-Refresh"] = "true"
return resp return resp
def htmx_swap_oob(target_id: str, html: str): def htmx_swap_oob(target_id: str, html: str) -> HttpResponse:
"""Return an out-of-band swap response by wrapping HTML and setting headers. """Return an out-of-band swap response by wrapping HTML and setting headers.
Note: For simple use cases this returns an HttpResponse containing the Note: For simple use cases this returns an HttpResponse containing the
@@ -88,3 +115,313 @@ def htmx_swap_oob(target_id: str, html: str):
resp = HttpResponse(html) resp = HttpResponse(html)
resp["HX-Trigger"] = f"oob:{target_id}" resp["HX-Trigger"] = f"oob:{target_id}"
return resp return resp
# =============================================================================
# Standardized HTMX Response Helpers
# =============================================================================
def htmx_success(
message: str,
html: str = "",
toast_type: str = "success",
duration: int = 5000,
title: str | None = None,
action: dict[str, Any] | None = None,
) -> HttpResponse:
"""
Create a standardized success response with toast notification.
Args:
message: Success message to display
html: Optional HTML content for the response body
toast_type: Toast type (success, info, warning)
duration: Toast display duration in ms (0 for persistent)
title: Optional toast title
action: Optional action button {label: str, onClick: str}
Returns:
HttpResponse with HX-Trigger header for toast
Examples:
return htmx_success('Park saved successfully!')
return htmx_success(
'Item deleted',
action={'label': 'Undo', 'onClick': 'undoDelete()'}
)
"""
resp = HttpResponse(html)
toast_data: dict[str, Any] = {
"type": toast_type,
"message": message,
"duration": duration,
}
if title:
toast_data["title"] = title
if action:
toast_data["action"] = action
resp["HX-Trigger"] = json.dumps({"showToast": toast_data})
return resp
def htmx_error(
message: str,
html: str = "",
status: int = 400,
duration: int = 0,
title: str | None = None,
show_retry: bool = False,
) -> HttpResponse:
"""
Create a standardized error response with toast notification.
Args:
message: Error message to display
html: Optional HTML content for the response body
status: HTTP status code (default: 400)
duration: Toast display duration in ms (0 for persistent)
title: Optional toast title
show_retry: Whether to show a retry action
Returns:
HttpResponse with HX-Trigger header for error toast
Examples:
return htmx_error('Validation failed. Please check your input.')
return htmx_error('Server error', status=500, show_retry=True)
"""
resp = HttpResponse(html, status=status)
toast_data: dict[str, Any] = {
"type": "error",
"message": message,
"duration": duration,
}
if title:
toast_data["title"] = title
if show_retry:
toast_data["action"] = {"label": "Retry", "onClick": "location.reload()"}
resp["HX-Trigger"] = json.dumps({"showToast": toast_data})
return resp
def htmx_warning(
message: str,
html: str = "",
duration: int = 8000,
title: str | None = None,
) -> HttpResponse:
"""
Create a standardized warning response with toast notification.
Args:
message: Warning message to display
html: Optional HTML content for the response body
duration: Toast display duration in ms
title: Optional toast title
Returns:
HttpResponse with HX-Trigger header for warning toast
Examples:
return htmx_warning('Your session will expire in 5 minutes.')
"""
resp = HttpResponse(html)
toast_data: dict[str, Any] = {
"type": "warning",
"message": message,
"duration": duration,
}
if title:
toast_data["title"] = title
resp["HX-Trigger"] = json.dumps({"showToast": toast_data})
return resp
def htmx_redirect_with_message(
url: str,
message: str,
toast_type: str = "success",
) -> HttpResponse:
"""
Create a redirect response with a message to show after redirect.
The message is passed via session to be displayed on the target page.
Args:
url: URL to redirect to
message: Message to display after redirect
toast_type: Toast type (success, info, warning, error)
Returns:
HttpResponse with HX-Redirect header
Examples:
return htmx_redirect_with_message('/parks/', 'Park created successfully!')
"""
resp = HttpResponse("")
resp["HX-Redirect"] = url
# Note: The toast will be shown via Django messages framework
# The view should add the message to the session before returning
return resp
def htmx_refresh_section(
target: str,
html: str = "",
message: str | None = None,
) -> HttpResponse:
"""
Create a response that refreshes a specific section.
Args:
target: CSS selector for the target element to refresh
html: HTML content for the response
message: Optional success message to show
Returns:
HttpResponse with retarget header
Examples:
return htmx_refresh_section('#park-list', parks_html, 'List updated')
"""
resp = HttpResponse(html)
resp["HX-Retarget"] = target
resp["HX-Reswap"] = "innerHTML"
if message:
toast_data = {"type": "success", "message": message, "duration": 3000}
resp["HX-Trigger"] = json.dumps({"showToast": toast_data})
return resp
def htmx_modal_close(
message: str | None = None,
refresh_target: str | None = None,
refresh_url: str | None = None,
) -> HttpResponse:
"""
Create a response that closes a modal and optionally refreshes content.
Args:
message: Optional success message to show
refresh_target: CSS selector for element to refresh
refresh_url: URL to fetch for refresh content
Returns:
HttpResponse with modal close trigger
Examples:
return htmx_modal_close('Item saved!', refresh_target='#items-list')
"""
resp = HttpResponse("")
triggers: dict[str, Any] = {"closeModal": True}
if message:
triggers["showToast"] = {
"type": "success",
"message": message,
"duration": 5000,
}
if refresh_target:
triggers["refreshSection"] = {
"target": refresh_target,
"url": refresh_url,
}
resp["HX-Trigger"] = json.dumps(triggers)
return resp
def htmx_validation_response(
field_name: str,
errors: list[str] | None = None,
success_message: str | None = None,
request: HttpRequest | None = None,
) -> HttpResponse:
"""
Create a response for inline field validation.
Args:
field_name: Name of the field being validated
errors: List of error messages (None = valid)
success_message: Message to show on successful validation
request: Optional request for rendering templates
Returns:
HttpResponse with validation feedback HTML
Examples:
# Validation error
return htmx_validation_response('email', errors=['Invalid email format'])
# Validation success
return htmx_validation_response('username', success_message='Username available')
"""
if errors:
html = render_to_string(
"forms/partials/field_error.html",
{"errors": errors},
request=request,
)
elif success_message:
html = render_to_string(
"forms/partials/field_success.html",
{"message": success_message},
request=request,
)
else:
html = render_to_string(
"forms/partials/field_success.html",
{},
request=request,
)
return HttpResponse(html)
def is_htmx_request(request: HttpRequest) -> bool:
"""
Check if the request is an HTMX request.
Args:
request: Django HttpRequest
Returns:
True if the request is from HTMX
"""
return request.headers.get("HX-Request") == "true"
def get_htmx_target(request: HttpRequest) -> str | None:
"""
Get the target element ID from an HTMX request.
Args:
request: Django HttpRequest
Returns:
Target element ID or None
"""
return request.headers.get("HX-Target")
def get_htmx_trigger(request: HttpRequest) -> str | None:
"""
Get the trigger element ID from an HTMX request.
Args:
request: Django HttpRequest
Returns:
Trigger element ID or None
"""
return request.headers.get("HX-Trigger")

View File

@@ -0,0 +1,417 @@
"""
Common Template Filters for ThrillWiki.
This module provides commonly used template filters for formatting,
text manipulation, and utility operations.
Usage:
{% load common_filters %}
{{ timedelta|humanize_timedelta }}
{{ text|truncate_smart:50 }}
{{ number|format_number }}
{{ dict|get_item:"key" }}
"""
from datetime import timedelta
from django import template
from django.template.defaultfilters import stringfilter
from django.utils import timezone
from django.utils.html import format_html
register = template.Library()
# =============================================================================
# Time and Date Filters
# =============================================================================
@register.filter
def humanize_timedelta(value):
"""
Convert a timedelta or datetime to a human-readable relative time.
Usage:
{{ last_updated|humanize_timedelta }}
Output: "2 hours ago", "3 days ago", "just now"
Args:
value: datetime, timedelta, or seconds (int)
Returns:
Human-readable string like "2 hours ago"
"""
if value is None:
return ''
# Convert datetime to timedelta from now
if hasattr(value, 'tzinfo'): # It's a datetime
now = timezone.now()
if value > now:
return 'in the future'
value = now - value
# Convert seconds to timedelta
if isinstance(value, (int, float)):
value = timedelta(seconds=value)
if not isinstance(value, timedelta):
return ''
seconds = int(value.total_seconds())
if seconds < 60:
return 'just now'
elif seconds < 3600:
minutes = seconds // 60
return f'{minutes} minute{"s" if minutes != 1 else ""} ago'
elif seconds < 86400:
hours = seconds // 3600
return f'{hours} hour{"s" if hours != 1 else ""} ago'
elif seconds < 604800:
days = seconds // 86400
return f'{days} day{"s" if days != 1 else ""} ago'
elif seconds < 2592000:
weeks = seconds // 604800
return f'{weeks} week{"s" if weeks != 1 else ""} ago'
elif seconds < 31536000:
months = seconds // 2592000
return f'{months} month{"s" if months != 1 else ""} ago'
else:
years = seconds // 31536000
return f'{years} year{"s" if years != 1 else ""} ago'
@register.filter
def time_until(value):
"""
Convert a future datetime to human-readable time until.
Usage:
{{ event_date|time_until }}
Output: "in 2 days", "in 3 hours"
"""
if value is None:
return ''
if hasattr(value, 'tzinfo'):
now = timezone.now()
if value <= now:
return 'now'
diff = value - now
return humanize_timedelta(diff).replace(' ago', '')
return ''
# =============================================================================
# Text Manipulation Filters
# =============================================================================
@register.filter
@stringfilter
def truncate_smart(value, max_length=50):
"""
Truncate text at word boundary, preserving whole words.
Usage:
{{ description|truncate_smart:100 }}
Args:
value: Text to truncate
max_length: Maximum length (default: 50)
Returns:
Truncated text with "..." if truncated
"""
max_length = int(max_length)
if len(value) <= max_length:
return value
# Find the last space before max_length
truncated = value[:max_length]
last_space = truncated.rfind(' ')
if last_space > max_length * 0.5: # Only use word boundary if reasonable
truncated = truncated[:last_space]
return truncated.rstrip('.,!?;:') + '...'
@register.filter
@stringfilter
def truncate_middle(value, max_length=50):
"""
Truncate text in the middle, showing start and end.
Usage:
{{ long_filename|truncate_middle:30 }}
Output: "very_long_fi...le_name.txt"
"""
max_length = int(max_length)
if len(value) <= max_length:
return value
keep_chars = (max_length - 3) // 2
return f'{value[:keep_chars]}...{value[-keep_chars:]}'
@register.filter
@stringfilter
def initials(value, max_initials=2):
"""
Get initials from a name.
Usage:
{{ user.full_name|initials }}
Output: "JD" for "John Doe"
"""
words = value.split()
return ''.join(word[0].upper() for word in words[:max_initials] if word)
# =============================================================================
# Number Formatting Filters
# =============================================================================
@register.filter
def format_number(value, decimals=0):
"""
Format number with thousand separators.
Usage:
{{ count|format_number }}
Output: "1,234,567"
{{ price|format_number:2 }}
Output: "1,234.56"
"""
if value is None:
return ''
try:
value = float(value)
decimals = int(decimals)
if decimals > 0:
return f'{value:,.{decimals}f}'
return f'{int(value):,}'
except (ValueError, TypeError):
return value
@register.filter
def format_compact(value):
"""
Format large numbers compactly (K, M, B).
Usage:
{{ view_count|format_compact }}
Output: "1.2K", "3.4M", "2.1B"
"""
if value is None:
return ''
try:
value = float(value)
if value >= 1_000_000_000:
return f'{value / 1_000_000_000:.1f}B'
elif value >= 1_000_000:
return f'{value / 1_000_000:.1f}M'
elif value >= 1_000:
return f'{value / 1_000:.1f}K'
return str(int(value))
except (ValueError, TypeError):
return value
@register.filter
def percentage(value, total):
"""
Calculate percentage of value from total.
Usage:
{{ completed|percentage:total }}
Output: "75%"
"""
try:
value = float(value)
total = float(total)
if total == 0:
return '0%'
return f'{(value / total * 100):.0f}%'
except (ValueError, TypeError, ZeroDivisionError):
return '0%'
# =============================================================================
# Dictionary/List Filters
# =============================================================================
@register.filter
def get_item(dictionary, key):
"""
Get item from dictionary safely in templates.
Usage:
{{ my_dict|get_item:"key" }}
{{ my_dict|get_item:variable_key }}
"""
if dictionary is None:
return None
return dictionary.get(key)
@register.filter
def getlist(querydict, key):
"""
Get list of values from QueryDict (request.GET/POST).
Usage:
{{ request.GET|getlist:"categories" }}
Args:
querydict: Django QueryDict (request.GET or request.POST)
key: Key to retrieve list for
Returns:
List of values for the key, or empty list if not found
"""
if querydict is None:
return []
if hasattr(querydict, 'getlist'):
return querydict.getlist(key)
return []
@register.filter
def get_attr(obj, attr):
"""
Get attribute from object safely in templates.
Usage:
{{ object|get_attr:"field_name" }}
"""
if obj is None:
return None
return getattr(obj, attr, None)
@register.filter
def index(sequence, i):
"""
Get item by index from list/tuple.
Usage:
{{ my_list|index:0 }}
"""
try:
return sequence[int(i)]
except (IndexError, TypeError, ValueError):
return None
# =============================================================================
# Pluralization Filters
# =============================================================================
@register.filter
def pluralize_custom(count, forms):
"""
Custom pluralization with specified singular/plural forms.
Usage:
{{ count|pluralize_custom:"item,items" }}
{{ count|pluralize_custom:"person,people" }}
{{ count|pluralize_custom:"goose,geese" }}
Args:
count: Number to check
forms: Comma-separated "singular,plural" forms
"""
try:
count = int(count)
singular, plural = forms.split(',')
return singular if count == 1 else plural
except (ValueError, AttributeError):
return forms
@register.filter
def count_with_label(count, forms):
"""
Format count with appropriate label.
Usage:
{{ rides|length|count_with_label:"ride,rides" }}
Output: "1 ride" or "5 rides"
"""
try:
count = int(count)
singular, plural = forms.split(',')
label = singular if count == 1 else plural
return f'{count} {label}'
except (ValueError, AttributeError):
return str(count)
# =============================================================================
# CSS Class Manipulation
# =============================================================================
@register.filter
def add_class(field, css_class):
"""
Add CSS class to form field widget.
Usage:
{{ form.email|add_class:"form-control" }}
"""
if hasattr(field, 'as_widget'):
existing = field.field.widget.attrs.get('class', '')
new_classes = f'{existing} {css_class}'.strip()
return field.as_widget(attrs={'class': new_classes})
return field
@register.filter
def set_attr(field, attr_value):
"""
Set attribute on form field widget.
Usage:
{{ form.email|set_attr:"placeholder:Enter email" }}
"""
if hasattr(field, 'as_widget'):
attr, value = attr_value.split(':')
return field.as_widget(attrs={attr: value})
return field
# =============================================================================
# Conditional Filters
# =============================================================================
@register.filter
def default_if_none(value, default):
"""
Return default if value is None (not just falsy).
Usage:
{{ value|default_if_none:"N/A" }}
"""
return default if value is None else value
@register.filter
def yesno_icon(value, icons="fa-check,fa-times"):
"""
Return icon class based on boolean value.
Usage:
{{ is_active|yesno_icon }}
Output: "fa-check" or "fa-times"
{{ has_feature|yesno_icon:"fa-star,fa-star-o" }}
"""
true_icon, false_icon = icons.split(',')
return true_icon if value else false_icon

View File

@@ -1 +1,75 @@
# Core utilities """
Core utilities for the ThrillWiki application.
This package provides utility functions and classes used across the application,
including breadcrumb generation, message helpers, and meta tag utilities.
"""
from .breadcrumbs import (
Breadcrumb,
BreadcrumbBuilder,
breadcrumbs_to_schema,
build_breadcrumb,
get_model_breadcrumb,
)
from .messages import (
confirm_delete,
error_network,
error_not_found,
error_permission,
error_server,
error_validation,
format_count_message,
info_loading,
info_message,
info_processing,
success_action,
success_created,
success_deleted,
success_updated,
warning_permission,
warning_rate_limit,
warning_unsaved_changes,
)
from .meta import (
build_canonical_url,
build_meta_context,
build_page_title,
generate_meta_description,
get_og_image,
get_twitter_card_type,
)
__all__ = [
# Breadcrumbs
"Breadcrumb",
"BreadcrumbBuilder",
"breadcrumbs_to_schema",
"build_breadcrumb",
"get_model_breadcrumb",
# Messages
"confirm_delete",
"error_network",
"error_not_found",
"error_permission",
"error_server",
"error_validation",
"format_count_message",
"info_loading",
"info_message",
"info_processing",
"success_action",
"success_created",
"success_deleted",
"success_updated",
"warning_permission",
"warning_rate_limit",
"warning_unsaved_changes",
# Meta
"build_canonical_url",
"build_meta_context",
"build_page_title",
"generate_meta_description",
"get_og_image",
"get_twitter_card_type",
]

View File

@@ -0,0 +1,415 @@
"""
Breadcrumb utilities for the ThrillWiki application.
This module provides functions and classes for building breadcrumb navigation
with support for dynamic breadcrumb generation from URL patterns and model instances.
Usage Examples:
Basic breadcrumb list:
breadcrumbs = [
build_breadcrumb('Home', '/'),
build_breadcrumb('Parks', '/parks/'),
build_breadcrumb('Cedar Point', is_current=True),
]
From a model instance:
park = Park.objects.get(slug='cedar-point')
breadcrumbs = get_model_breadcrumb(park)
# Returns: [Home, Parks, Cedar Point]
Using the builder pattern:
breadcrumbs = (
BreadcrumbBuilder()
.add_home()
.add('Parks', '/parks/')
.add_current('Cedar Point')
.build()
)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin
from django.urls import reverse
if TYPE_CHECKING:
from django.db.models import Model
from django.http import HttpRequest
@dataclass
class Breadcrumb:
"""
Represents a single breadcrumb item.
Attributes:
label: Display text for the breadcrumb
url: URL the breadcrumb links to (None for current page)
icon: Optional icon class (e.g., 'fas fa-home')
is_current: Whether this is the current page (last item)
schema_position: Position in Schema.org BreadcrumbList (1-indexed)
"""
label: str
url: str | None = None
icon: str | None = None
is_current: bool = False
schema_position: int = 1
def __post_init__(self) -> None:
"""Set is_current to True if no URL is provided."""
if self.url is None:
self.is_current = True
@property
def is_clickable(self) -> bool:
"""Return True if the breadcrumb should be a link."""
return self.url is not None and not self.is_current
def to_schema_dict(self) -> dict[str, Any]:
"""
Return Schema.org BreadcrumbList item format.
Returns:
Dictionary formatted for JSON-LD structured data
"""
item: dict[str, Any] = {
"@type": "ListItem",
"position": self.schema_position,
"name": self.label,
}
if self.url:
item["item"] = self.url
return item
def build_breadcrumb(
label: str,
url: str | None = None,
icon: str | None = None,
is_current: bool = False,
) -> Breadcrumb:
"""
Create a single breadcrumb item.
Args:
label: Display text for the breadcrumb
url: URL the breadcrumb links to (None for current page)
icon: Optional icon class (e.g., 'fas fa-home')
is_current: Whether this is the current page
Returns:
Breadcrumb instance
Examples:
>>> build_breadcrumb('Home', '/', icon='fas fa-home')
Breadcrumb(label='Home', url='/', icon='fas fa-home', is_current=False)
>>> build_breadcrumb('Cedar Point', is_current=True)
Breadcrumb(label='Cedar Point', url=None, icon=None, is_current=True)
"""
return Breadcrumb(label=label, url=url, icon=icon, is_current=is_current)
class BreadcrumbBuilder:
"""
Builder pattern for constructing breadcrumb lists.
Provides a fluent API for building breadcrumb navigation with
automatic position tracking and common patterns.
Examples:
>>> builder = BreadcrumbBuilder()
>>> breadcrumbs = (
... builder
... .add_home()
... .add('Parks', '/parks/')
... .add_current('Cedar Point')
... .build()
... )
"""
def __init__(self, base_url: str = "") -> None:
"""
Initialize the breadcrumb builder.
Args:
base_url: Base URL to prepend to all relative URLs
"""
self._items: list[Breadcrumb] = []
self._base_url = base_url
def add(
self,
label: str,
url: str | None = None,
icon: str | None = None,
) -> BreadcrumbBuilder:
"""
Add a breadcrumb item to the list.
Args:
label: Display text for the breadcrumb
url: URL the breadcrumb links to
icon: Optional icon class
Returns:
Self for method chaining
"""
position = len(self._items) + 1
full_url = urljoin(self._base_url, url) if url else url
self._items.append(
Breadcrumb(
label=label,
url=full_url,
icon=icon,
is_current=False,
schema_position=position,
)
)
return self
def add_home(
self,
label: str = "Home",
url: str = "/",
icon: str = "fas fa-home",
) -> BreadcrumbBuilder:
"""
Add the home breadcrumb (typically first item).
Args:
label: Home label (default: 'Home')
url: Home URL (default: '/')
icon: Home icon class (default: 'fas fa-home')
Returns:
Self for method chaining
"""
return self.add(label, url, icon)
def add_current(
self,
label: str,
icon: str | None = None,
) -> BreadcrumbBuilder:
"""
Add the current page breadcrumb (last item, non-clickable).
Args:
label: Display text for current page
icon: Optional icon class
Returns:
Self for method chaining
"""
position = len(self._items) + 1
self._items.append(
Breadcrumb(
label=label,
url=None,
icon=icon,
is_current=True,
schema_position=position,
)
)
return self
def add_from_url(
self,
url_name: str,
label: str,
url_kwargs: dict[str, Any] | None = None,
icon: str | None = None,
) -> BreadcrumbBuilder:
"""
Add a breadcrumb using Django URL name reverse lookup.
Args:
url_name: Django URL name to reverse
label: Display text for the breadcrumb
url_kwargs: Keyword arguments for URL reverse
icon: Optional icon class
Returns:
Self for method chaining
"""
url = reverse(url_name, kwargs=url_kwargs)
return self.add(label, url, icon)
def add_model(
self,
instance: Model,
url_attr: str = "get_absolute_url",
label_attr: str = "name",
icon: str | None = None,
) -> BreadcrumbBuilder:
"""
Add a breadcrumb from a model instance.
Args:
instance: Django model instance
url_attr: Method name to get URL (default: 'get_absolute_url')
label_attr: Attribute name for label (default: 'name')
icon: Optional icon class
Returns:
Self for method chaining
"""
url_method = getattr(instance, url_attr, None)
url = url_method() if callable(url_method) else None
label = getattr(instance, label_attr, str(instance))
return self.add(label, url, icon)
def add_model_current(
self,
instance: Model,
label_attr: str = "name",
icon: str | None = None,
) -> BreadcrumbBuilder:
"""
Add a model instance as the current page breadcrumb.
Args:
instance: Django model instance
label_attr: Attribute name for label (default: 'name')
icon: Optional icon class
Returns:
Self for method chaining
"""
label = getattr(instance, label_attr, str(instance))
return self.add_current(label, icon)
def build(self) -> list[Breadcrumb]:
"""
Build and return the breadcrumb list.
Returns:
List of Breadcrumb instances
"""
return self._items.copy()
def clear(self) -> BreadcrumbBuilder:
"""
Clear all breadcrumb items.
Returns:
Self for method chaining
"""
self._items = []
return self
def get_model_breadcrumb(
instance: Model,
include_home: bool = True,
parent_attr: str | None = None,
list_url_name: str | None = None,
list_label: str | None = None,
) -> list[Breadcrumb]:
"""
Generate breadcrumbs for a model instance with parent relationships.
This function automatically builds breadcrumbs by traversing parent
relationships and including model list pages.
Args:
instance: Django model instance
include_home: Include home breadcrumb (default: True)
parent_attr: Attribute name for parent relationship (e.g., 'park' for Ride)
list_url_name: URL name for the model's list page
list_label: Label for the list page breadcrumb
Returns:
List of Breadcrumb instances
Examples:
>>> ride = Ride.objects.get(slug='millennium-force')
>>> breadcrumbs = get_model_breadcrumb(
... ride,
... parent_attr='park',
... list_url_name='rides:list',
... list_label='Rides',
... )
# Returns: [Home, Parks, Cedar Point, Rides, Millennium Force]
"""
builder = BreadcrumbBuilder()
if include_home:
builder.add_home()
# Add parent breadcrumbs if parent_attr is specified
if parent_attr:
parent = getattr(instance, parent_attr, None)
if parent:
# Recursively get parent's model name for list URL
parent_model_name = parent.__class__.__name__.lower()
parent_list_url = f"{parent_model_name}s:list"
parent_list_label = f"{parent.__class__.__name__}s"
try:
builder.add_from_url(parent_list_url, parent_list_label)
except Exception:
pass
builder.add_model(parent)
# Add list page breadcrumb
if list_url_name and list_label:
try:
builder.add_from_url(list_url_name, list_label)
except Exception:
pass
# Add current model instance
builder.add_model_current(instance)
return builder.build()
def breadcrumbs_to_schema(
breadcrumbs: list[Breadcrumb],
request: HttpRequest | None = None,
) -> dict[str, Any]:
"""
Convert breadcrumbs to Schema.org BreadcrumbList JSON-LD format.
Args:
breadcrumbs: List of Breadcrumb instances
request: Optional HttpRequest for building absolute URLs
Returns:
Dictionary formatted for JSON-LD structured data
Examples:
>>> breadcrumbs = [
... build_breadcrumb('Home', '/'),
... build_breadcrumb('Parks', '/parks/'),
... ]
>>> schema = breadcrumbs_to_schema(breadcrumbs, request)
>>> print(json.dumps(schema, indent=2))
"""
base_url = ""
if request:
base_url = f"{request.scheme}://{request.get_host()}"
items = []
for i, crumb in enumerate(breadcrumbs, 1):
item: dict[str, Any] = {
"@type": "ListItem",
"position": i,
"name": crumb.label,
}
if crumb.url:
item["item"] = urljoin(base_url, crumb.url) if base_url else crumb.url
items.append(item)
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": items,
}

View File

@@ -0,0 +1,463 @@
"""
Standardized message utilities for the ThrillWiki application.
This module provides helper functions for creating consistent user-facing
messages across the application. These functions ensure standardized
messaging patterns for success, error, warning, and info notifications.
Usage Examples:
>>> from apps.core.utils.messages import success_created, error_validation
>>> message = success_created('Park', 'Cedar Point')
>>> # Returns: 'Cedar Point has been created successfully.'
>>> message = error_validation('email')
>>> # Returns: 'Please check the email field and try again.'
"""
from __future__ import annotations
from typing import Any
def success_created(
model_name: str,
object_name: str | None = None,
custom_message: str | None = None,
) -> str:
"""
Generate a success message for object creation.
Args:
model_name: The type of object created (e.g., 'Park', 'Ride')
object_name: Optional name of the created object
custom_message: Optional custom message to use instead of default
Returns:
Formatted success message
Examples:
>>> success_created('Park', 'Cedar Point')
'Cedar Point has been created successfully.'
>>> success_created('Review')
'Review has been created successfully.'
"""
if custom_message:
return custom_message
if object_name:
return f"{object_name} has been created successfully."
return f"{model_name} has been created successfully."
def success_updated(
model_name: str,
object_name: str | None = None,
custom_message: str | None = None,
) -> str:
"""
Generate a success message for object update.
Args:
model_name: The type of object updated (e.g., 'Park', 'Ride')
object_name: Optional name of the updated object
custom_message: Optional custom message to use instead of default
Returns:
Formatted success message
Examples:
>>> success_updated('Park', 'Cedar Point')
'Cedar Point has been updated successfully.'
>>> success_updated('Profile')
'Profile has been updated successfully.'
"""
if custom_message:
return custom_message
if object_name:
return f"{object_name} has been updated successfully."
return f"{model_name} has been updated successfully."
def success_deleted(
model_name: str,
object_name: str | None = None,
custom_message: str | None = None,
) -> str:
"""
Generate a success message for object deletion.
Args:
model_name: The type of object deleted (e.g., 'Park', 'Ride')
object_name: Optional name of the deleted object
custom_message: Optional custom message to use instead of default
Returns:
Formatted success message
Examples:
>>> success_deleted('Park', 'Old Park')
'Old Park has been deleted successfully.'
>>> success_deleted('Review')
'Review has been deleted successfully.'
"""
if custom_message:
return custom_message
if object_name:
return f"{object_name} has been deleted successfully."
return f"{model_name} has been deleted successfully."
def success_action(
action: str,
model_name: str,
object_name: str | None = None,
) -> str:
"""
Generate a success message for a custom action.
Args:
action: The action performed (e.g., 'approved', 'published')
model_name: The type of object
object_name: Optional name of the object
Returns:
Formatted success message
Examples:
>>> success_action('approved', 'Submission', 'New Cedar Point Photo')
'New Cedar Point Photo has been approved successfully.'
>>> success_action('published', 'Article')
'Article has been published successfully.'
"""
if object_name:
return f"{object_name} has been {action} successfully."
return f"{model_name} has been {action} successfully."
def error_validation(
field_name: str | None = None,
custom_message: str | None = None,
) -> str:
"""
Generate an error message for validation failures.
Args:
field_name: Optional field name that failed validation
custom_message: Optional custom message to use instead of default
Returns:
Formatted error message
Examples:
>>> error_validation('email')
'Please check the email field and try again.'
>>> error_validation()
'Please check the form and correct any errors.'
"""
if custom_message:
return custom_message
if field_name:
return f"Please check the {field_name} field and try again."
return "Please check the form and correct any errors."
def error_permission(
action: str | None = None,
custom_message: str | None = None,
) -> str:
"""
Generate an error message for permission denied.
Args:
action: Optional action that was denied
custom_message: Optional custom message to use instead of default
Returns:
Formatted error message
Examples:
>>> error_permission('edit this park')
'You do not have permission to edit this park.'
>>> error_permission()
'You do not have permission to perform this action.'
"""
if custom_message:
return custom_message
if action:
return f"You do not have permission to {action}."
return "You do not have permission to perform this action."
def error_not_found(
model_name: str,
identifier: str | None = None,
custom_message: str | None = None,
) -> str:
"""
Generate an error message for object not found.
Args:
model_name: The type of object not found
identifier: Optional identifier that was searched for
custom_message: Optional custom message to use instead of default
Returns:
Formatted error message
Examples:
>>> error_not_found('Park', 'cedar-point')
'Park "cedar-point" was not found.'
>>> error_not_found('Ride')
'Ride was not found.'
"""
if custom_message:
return custom_message
if identifier:
return f'{model_name} "{identifier}" was not found.'
return f"{model_name} was not found."
def error_server(
custom_message: str | None = None,
) -> str:
"""
Generate an error message for server errors.
Args:
custom_message: Optional custom message to use instead of default
Returns:
Formatted error message
Examples:
>>> error_server()
'An unexpected error occurred. Please try again later.'
"""
if custom_message:
return custom_message
return "An unexpected error occurred. Please try again later."
def error_network(
custom_message: str | None = None,
) -> str:
"""
Generate an error message for network errors.
Args:
custom_message: Optional custom message to use instead of default
Returns:
Formatted error message
Examples:
>>> error_network()
'Network error. Please check your connection and try again.'
"""
if custom_message:
return custom_message
return "Network error. Please check your connection and try again."
def warning_permission(
action: str | None = None,
custom_message: str | None = None,
) -> str:
"""
Generate a warning message for permission issues.
Args:
action: Optional action that requires permission
custom_message: Optional custom message to use instead of default
Returns:
Formatted warning message
Examples:
>>> warning_permission('edit')
'You may not have permission to edit. Please log in to continue.'
>>> warning_permission()
'Please log in to continue.'
"""
if custom_message:
return custom_message
if action:
return f"You may not have permission to {action}. Please log in to continue."
return "Please log in to continue."
def warning_unsaved_changes(
custom_message: str | None = None,
) -> str:
"""
Generate a warning message for unsaved changes.
Args:
custom_message: Optional custom message to use instead of default
Returns:
Formatted warning message
Examples:
>>> warning_unsaved_changes()
'You have unsaved changes. Are you sure you want to leave?'
"""
if custom_message:
return custom_message
return "You have unsaved changes. Are you sure you want to leave?"
def warning_rate_limit(
custom_message: str | None = None,
) -> str:
"""
Generate a warning message for rate limiting.
Args:
custom_message: Optional custom message to use instead of default
Returns:
Formatted warning message
Examples:
>>> warning_rate_limit()
'Too many requests. Please wait a moment before trying again.'
"""
if custom_message:
return custom_message
return "Too many requests. Please wait a moment before trying again."
def info_message(
message: str,
) -> str:
"""
Generate an info message.
Args:
message: The information message to display
Returns:
The message as-is (for consistency with other functions)
Examples:
>>> info_message('Your session will expire in 5 minutes.')
'Your session will expire in 5 minutes.'
"""
return message
def info_loading(
action: str | None = None,
) -> str:
"""
Generate an info message for loading states.
Args:
action: Optional action being performed
Returns:
Formatted info message
Examples:
>>> info_loading('parks')
'Loading parks...'
>>> info_loading()
'Loading...'
"""
if action:
return f"Loading {action}..."
return "Loading..."
def info_processing(
action: str | None = None,
) -> str:
"""
Generate an info message for processing states.
Args:
action: Optional action being processed
Returns:
Formatted info message
Examples:
>>> info_processing('your request')
'Processing your request...'
>>> info_processing()
'Processing...'
"""
if action:
return f"Processing {action}..."
return "Processing..."
def confirm_delete(
model_name: str,
object_name: str | None = None,
) -> str:
"""
Generate a confirmation message for deletion.
Args:
model_name: The type of object to delete
object_name: Optional name of the object
Returns:
Formatted confirmation message
Examples:
>>> confirm_delete('Park', 'Cedar Point')
'Are you sure you want to delete "Cedar Point"? This action cannot be undone.'
>>> confirm_delete('Review')
'Are you sure you want to delete this Review? This action cannot be undone.'
"""
if object_name:
return f'Are you sure you want to delete "{object_name}"? This action cannot be undone.'
return f"Are you sure you want to delete this {model_name}? This action cannot be undone."
def format_count_message(
count: int,
singular: str,
plural: str | None = None,
zero_message: str | None = None,
) -> str:
"""
Generate a count-aware message.
Args:
count: The count of items
singular: Singular form of the message
plural: Optional plural form (defaults to singular + 's')
zero_message: Optional message for zero count
Returns:
Formatted count message
Examples:
>>> format_count_message(1, 'park', 'parks')
'1 park'
>>> format_count_message(5, 'park', 'parks')
'5 parks'
>>> format_count_message(0, 'park', 'parks', 'No parks found')
'No parks found'
"""
if count == 0 and zero_message:
return zero_message
if plural is None:
plural = f"{singular}s"
return f"{count} {singular if count == 1 else plural}"

View File

@@ -0,0 +1,340 @@
"""
Meta tag utilities for the ThrillWiki application.
This module provides helper functions for generating consistent meta tags,
Open Graph data, and canonical URLs for SEO and social sharing.
Usage Examples:
>>> from apps.core.utils.meta import generate_meta_description, get_og_image
>>> description = generate_meta_description(park)
>>> # Returns: 'Cedar Point is a world-famous amusement park located in Sandusky, Ohio...'
>>> og_image = get_og_image(park)
>>> # Returns: URL to the park's featured image or default OG image
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin
from django.conf import settings
from django.templatetags.static import static
if TYPE_CHECKING:
from django.db.models import Model
from django.http import HttpRequest
def generate_meta_description(
instance: Model | None = None,
text: str | None = None,
max_length: int = 160,
fallback: str = "ThrillWiki - Your comprehensive guide to theme parks and roller coasters",
) -> str:
"""
Generate a meta description from a model instance or text.
Automatically truncates to max_length, preserving word boundaries
and adding ellipsis if truncated.
Args:
instance: Django model instance with description/content attribute
text: Direct text to use for description
max_length: Maximum length for the description (default: 160)
fallback: Fallback text if no description found
Returns:
Formatted meta description
Examples:
>>> park = Park.objects.get(slug='cedar-point')
>>> generate_meta_description(park)
'Cedar Point is a world-famous amusement park located in Sandusky, Ohio...'
>>> generate_meta_description(text='A very long description that needs to be truncated...')
'A very long description that needs to be...'
"""
description = ""
if text:
description = text
elif instance:
# Try common description attributes
for attr in ("description", "content", "bio", "summary", "overview"):
value = getattr(instance, attr, None)
if value and isinstance(value, str):
description = value
break
# If no description found, try to build from name and location
if not description:
name = getattr(instance, "name", None)
location = getattr(instance, "location", None)
if name and location:
description = f"{name} is located in {location}."
elif name:
description = f"Learn more about {name} on ThrillWiki."
if not description:
return fallback
# Clean up the description
description = _clean_text(description)
# Truncate if needed
if len(description) > max_length:
description = _truncate_text(description, max_length)
return description
def get_og_image(
instance: Model | None = None,
image_url: str | None = None,
request: HttpRequest | None = None,
) -> str:
"""
Get the Open Graph image URL for a model instance.
Attempts to find an image from the model instance, falls back to
the default OG image.
Args:
instance: Django model instance with image attribute
image_url: Direct image URL to use
request: HttpRequest for building absolute URLs
Returns:
Absolute URL to the Open Graph image
Examples:
>>> park = Park.objects.get(slug='cedar-point')
>>> get_og_image(park, request=request)
'https://thrillwiki.com/media/parks/cedar-point.jpg'
"""
base_url = ""
if request:
base_url = f"{request.scheme}://{request.get_host()}"
elif hasattr(settings, "FRONTEND_DOMAIN"):
base_url = settings.FRONTEND_DOMAIN
# Use provided image URL
if image_url:
if image_url.startswith(("http://", "https://")):
return image_url
return urljoin(base_url, image_url)
# Try to get image from model instance
if instance:
for attr in ("featured_image", "image", "photo", "cover_image", "thumbnail"):
image_field = getattr(instance, attr, None)
if image_field:
try:
if hasattr(image_field, "url"):
return urljoin(base_url, image_field.url)
except ValueError:
continue
# Try to get from related photos
if hasattr(instance, "photos"):
try:
first_photo = instance.photos.first()
if first_photo and hasattr(first_photo, "image"):
return urljoin(base_url, first_photo.image.url)
except Exception:
pass
# Fall back to default OG image
default_og = static("images/og-default.jpg")
return urljoin(base_url, default_og)
def build_canonical_url(
request: HttpRequest | None = None,
path: str | None = None,
instance: Model | None = None,
) -> str:
"""
Build the canonical URL for a page.
Args:
request: HttpRequest for building the URL from current request
path: Direct path to use for the canonical URL
instance: Model instance with get_absolute_url method
Returns:
Canonical URL
Examples:
>>> build_canonical_url(request=request)
'https://thrillwiki.com/parks/cedar-point/'
>>> build_canonical_url(path='/parks/')
'https://thrillwiki.com/parks/'
"""
base_url = ""
if hasattr(settings, "FRONTEND_DOMAIN"):
base_url = settings.FRONTEND_DOMAIN
elif request:
base_url = f"{request.scheme}://{request.get_host()}"
# Get path from various sources
url_path = ""
if path:
url_path = path
elif instance and hasattr(instance, "get_absolute_url"):
url_path = instance.get_absolute_url()
elif request:
url_path = request.path
# Remove query strings for canonical URL
if "?" in url_path:
url_path = url_path.split("?")[0]
return urljoin(base_url, url_path)
def build_page_title(
title: str,
section: str | None = None,
site_name: str = "ThrillWiki",
separator: str = " - ",
) -> str:
"""
Build a consistent page title.
Args:
title: Page-specific title
section: Optional section name (e.g., 'Parks', 'Rides')
site_name: Site name to append (default: 'ThrillWiki')
separator: Separator between parts (default: ' - ')
Returns:
Formatted page title
Examples:
>>> build_page_title('Cedar Point', 'Parks')
'Cedar Point - Parks - ThrillWiki'
>>> build_page_title('Search Results')
'Search Results - ThrillWiki'
"""
parts = [title]
if section:
parts.append(section)
parts.append(site_name)
return separator.join(parts)
def get_twitter_card_type(
instance: Model | None = None,
card_type: str | None = None,
) -> str:
"""
Determine the appropriate Twitter card type.
Args:
instance: Model instance to check for images
card_type: Explicit card type to use
Returns:
Twitter card type ('summary_large_image' or 'summary')
Examples:
>>> get_twitter_card_type(park)
'summary_large_image' # Park has featured image
>>> get_twitter_card_type()
'summary'
"""
if card_type:
return card_type
# Use large image card if instance has an image
if instance:
for attr in ("featured_image", "image", "photo", "cover_image"):
image_field = getattr(instance, attr, None)
if image_field:
try:
if hasattr(image_field, "url") and image_field.url:
return "summary_large_image"
except ValueError:
continue
return "summary"
def build_meta_context(
title: str,
description: str | None = None,
instance: Model | None = None,
request: HttpRequest | None = None,
section: str | None = None,
og_type: str = "website",
twitter_card: str | None = None,
) -> dict[str, Any]:
"""
Build a complete meta context dictionary for templates.
Args:
title: Page title
description: Meta description (auto-generated if None)
instance: Model instance for generating meta data
request: HttpRequest for URLs
section: Section name for title
og_type: Open Graph type (default: 'website')
twitter_card: Twitter card type (auto-detected if None)
Returns:
Dictionary with all meta tag values
Examples:
>>> context = build_meta_context(
... title='Cedar Point',
... instance=park,
... request=request,
... section='Parks',
... og_type='place',
... )
"""
return {
"page_title": build_page_title(title, section),
"meta_description": generate_meta_description(instance, description),
"canonical_url": build_canonical_url(request, instance=instance),
"og_type": og_type,
"og_title": title,
"og_description": generate_meta_description(instance, description),
"og_image": get_og_image(instance, request=request),
"twitter_card": get_twitter_card_type(instance, twitter_card),
"twitter_title": title,
"twitter_description": generate_meta_description(instance, description),
}
def _clean_text(text: str) -> str:
"""
Clean text for use in meta tags.
Removes HTML tags, extra whitespace, and normalizes line breaks.
"""
# Remove HTML tags
text = re.sub(r"<[^>]+>", "", text)
# Replace multiple whitespace with single space
text = re.sub(r"\s+", " ", text)
# Strip leading/trailing whitespace
text = text.strip()
return text
def _truncate_text(text: str, max_length: int) -> str:
"""
Truncate text at word boundary with ellipsis.
"""
if len(text) <= max_length:
return text
# Truncate at word boundary
truncated = text[: max_length - 3].rsplit(" ", 1)[0]
return f"{truncated}..."

View File

@@ -3,6 +3,74 @@ from django import template
register = template.Library() register = template.Library()
# Status configuration mapping for parks and rides
STATUS_CONFIG = {
'OPERATING': {
'label': 'Operating',
'classes': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
'icon': True,
},
'CLOSED_TEMP': {
'label': 'Temporarily Closed',
'classes': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
'icon': True,
},
'CLOSED_PERM': {
'label': 'Permanently Closed',
'classes': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
'icon': True,
},
'CONSTRUCTION': {
'label': 'Under Construction',
'classes': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
'icon': True,
},
'DEMOLISHED': {
'label': 'Demolished',
'classes': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
'icon': True,
},
'RELOCATED': {
'label': 'Relocated',
'classes': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
'icon': True,
},
'SBNO': {
'label': 'Standing But Not Operating',
'classes': 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
'icon': True,
},
}
# Default config for unknown statuses
DEFAULT_STATUS_CONFIG = {
'label': 'Unknown',
'classes': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
'icon': False,
}
@register.filter
def get_status_config(status):
"""
Get status badge configuration for a given status value.
Usage:
{% with config=status|get_status_config %}
<span class="{{ config.classes }}">{{ config.label }}</span>
{% endwith %}
Args:
status: Status string (e.g., 'OPERATING', 'CLOSED_TEMP')
Returns:
Dictionary with 'label', 'classes', and 'icon' keys
"""
if status is None:
return DEFAULT_STATUS_CONFIG
return STATUS_CONFIG.get(status, DEFAULT_STATUS_CONFIG)
@register.filter @register.filter
def has_reviewed_park(user, park): def has_reviewed_park(user, park):
"""Check if a user has reviewed a park""" """Check if a user has reviewed a park"""

View File

@@ -151,6 +151,8 @@ if TEMPLATES_ENABLED:
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"apps.moderation.context_processors.moderation_access", "apps.moderation.context_processors.moderation_access",
"apps.core.context_processors.fsm_context", "apps.core.context_processors.fsm_context",
"apps.core.context_processors.breadcrumbs",
"apps.core.context_processors.page_meta",
] ]
}, },
} }
@@ -170,6 +172,8 @@ else:
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"apps.moderation.context_processors.moderation_access", "apps.moderation.context_processors.moderation_access",
"apps.core.context_processors.fsm_context", "apps.core.context_processors.fsm_context",
"apps.core.context_processors.breadcrumbs",
"apps.core.context_processors.page_meta",
] ]
}, },
} }
@@ -337,6 +341,7 @@ REST_FRAMEWORK = {
], ],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20, "PAGE_SIZE": 20,
"MAX_PAGE_SIZE": 100,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning", "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning",
"DEFAULT_VERSION": "v1", "DEFAULT_VERSION": "v1",
"ALLOWED_VERSIONS": ["v1"], "ALLOWED_VERSIONS": ["v1"],
@@ -355,18 +360,59 @@ REST_FRAMEWORK = {
"rest_framework.filters.SearchFilter", "rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter", "rest_framework.filters.OrderingFilter",
], ],
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "60/minute",
"user": "1000/hour",
},
"TEST_REQUEST_DEFAULT_FORMAT": "json", "TEST_REQUEST_DEFAULT_FORMAT": "json",
"NON_FIELD_ERRORS_KEY": "non_field_errors", "NON_FIELD_ERRORS_KEY": "non_field_errors",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
} }
# CORS Settings for API # CORS Settings for API
# https://github.com/adamchainz/django-cors-headers
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = config( CORS_ALLOW_ALL_ORIGINS = config(
"CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool "CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool
) # type: ignore[arg-type] ) # type: ignore[arg-type]
# Allowed HTTP headers for CORS requests
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
"x-api-version",
]
# HTTP methods allowed for CORS requests
CORS_ALLOW_METHODS = [
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
]
# Expose rate limit headers to browsers
CORS_EXPOSE_HEADERS = [
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
"X-API-Version",
]
API_RATE_LIMIT_PER_MINUTE = config( API_RATE_LIMIT_PER_MINUTE = config(
"API_RATE_LIMIT_PER_MINUTE", default=60, cast=int "API_RATE_LIMIT_PER_MINUTE", default=60, cast=int
@@ -376,13 +422,43 @@ API_RATE_LIMIT_PER_HOUR = config(
) # type: ignore[arg-type] ) # type: ignore[arg-type]
SPECTACULAR_SETTINGS = { SPECTACULAR_SETTINGS = {
"TITLE": "ThrillWiki API", "TITLE": "ThrillWiki API",
"DESCRIPTION": "Comprehensive theme park and ride information API", "DESCRIPTION": """Comprehensive theme park and ride information API.
## API Conventions
### Response Format
All successful responses include a `success: true` field with data nested under `data`.
All error responses include an `error` object with `code` and `message` fields.
### Pagination
List endpoints support pagination with `page` and `page_size` parameters.
Default page size is 20, maximum is 100.
### Filtering
Range filters use `{field}_min` and `{field}_max` naming convention.
Search uses the `search` parameter.
Ordering uses the `ordering` parameter (prefix with `-` for descending).
### Field Naming
All field names use snake_case convention (e.g., `image_url`, `created_at`).
""",
"VERSION": "1.0.0", "VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False, "SERVE_INCLUDE_SCHEMA": False,
"COMPONENT_SPLIT_REQUEST": True, "COMPONENT_SPLIT_REQUEST": True,
"TAGS": [ "TAGS": [
{"name": "Parks", "description": "Theme park operations"}, {"name": "Parks", "description": "Theme park operations"},
{"name": "Rides", "description": "Ride information and management"}, {"name": "Rides", "description": "Ride information and management"},
{"name": "Park Media", "description": "Park photos and media management"},
{"name": "Ride Media", "description": "Ride photos and media management"},
{"name": "Authentication", "description": "User authentication and session management"},
{"name": "Social Authentication", "description": "Social provider login and account linking"},
{"name": "User Profile", "description": "User profile management"},
{"name": "User Settings", "description": "User preferences and settings"},
{"name": "User Notifications", "description": "User notification management"},
{"name": "User Content", "description": "User-generated content (top lists, reviews)"},
{"name": "User Management", "description": "Admin user management operations"},
{"name": "Self-Service Account Management", "description": "User account deletion and management"},
{"name": "Core", "description": "Core utility endpoints (search, suggestions)"},
{ {
"name": "Statistics", "name": "Statistics",
"description": "Statistical endpoints providing aggregated data and insights", "description": "Statistical endpoints providing aggregated data and insights",

View File

@@ -82,5 +82,72 @@ typeCheckingMode = "basic"
[tool.pylance] [tool.pylance]
stubPath = "stubs" stubPath = "stubs"
# =============================================================================
# Pytest Configuration
# =============================================================================
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.django.test"
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
"--tb=short",
]
markers = [
"unit: Unit tests (fast, isolated)",
"integration: Integration tests (may use database)",
"e2e: End-to-end browser tests (slow, requires server)",
"slow: Tests that take a long time to run",
"api: API endpoint tests",
]
filterwarnings = [
"ignore::DeprecationWarning",
"ignore::PendingDeprecationWarning",
]
# =============================================================================
# Coverage Configuration
# =============================================================================
[tool.coverage.run]
source = ["apps"]
branch = true
omit = [
"*/migrations/*",
"*/tests/*",
"*/__pycache__/*",
"*/admin.py",
"*/apps.py",
"manage.py",
"config/*",
]
parallel = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"def __str__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
"if typing.TYPE_CHECKING:",
"@abstractmethod",
"@abc.abstractmethod",
]
show_missing = true
skip_covered = false
fail_under = 70
[tool.coverage.html]
directory = "htmlcov"
[tool.coverage.xml]
output = "coverage.xml"
[tool.uv.sources] [tool.uv.sources]
python-json-logger = { url = "https://github.com/nhairs/python-json-logger/releases/download/v3.0.0/python_json_logger-3.0.0-py3-none-any.whl" } python-json-logger = { url = "https://github.com/nhairs/python-json-logger/releases/download/v3.0.0/python_json_logger-3.0.0-py3-none-any.whl" }

View File

@@ -1,44 +0,0 @@
/* Alert Styles */
.alert {
@apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4;
animation: slideIn 0.5s ease-out forwards;
}
.alert-success {
@apply text-white bg-green-500;
}
.alert-error {
@apply text-white bg-red-500;
}
.alert-info {
@apply text-white bg-blue-500;
}
.alert-warning {
@apply text-white bg-yellow-500;
}
/* Animation keyframes */
@keyframes slideIn {
0% {
transform: translateX(100%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,18 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { /**
--color-primary: #4f46e5; * ThrillWiki Tailwind Input CSS
--color-secondary: #e11d48; *
--color-accent: #8b5cf6; * This file imports Tailwind CSS and adds custom component styles.
--font-family-sans: Poppins, sans-serif; * Color definitions are inherited from design-tokens.css.
} * Do NOT define inline colors here - use design token variables instead.
*/
/* Base Component Styles */ /* Base Component Styles */
.site-logo { .site-logo {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); background: linear-gradient(135deg, var(--color-primary), var(--color-accent-500));
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
@@ -36,18 +37,21 @@
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
font-weight: 500; font-weight: 500;
color: #6b7280; color: var(--color-muted-foreground);
transition: all 0.2s ease; transition: all 0.2s ease;
text-decoration: none; text-decoration: none;
} }
.nav-link:hover { .nav-link:hover {
color: var(--color-primary); color: var(--color-primary);
background-color: rgba(79, 70, 229, 0.1); background-color: var(--color-primary-100);
} }
.nav-link i { .nav-link i,
.nav-link svg {
font-size: 1rem; font-size: 1rem;
width: 1rem;
height: 1rem;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -60,39 +64,38 @@
.form-input { .form-input {
width: 100%; width: 100%;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border: 1px solid #d1d5db; border: 1px solid var(--color-border);
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
background-color: white; background-color: var(--color-background);
color: var(--color-foreground);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.form-input:focus { .form-input:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); box-shadow: 0 0 0 3px var(--color-primary-100);
} }
.form-input::placeholder { .form-input::placeholder {
color: #9ca3af; color: var(--color-muted-foreground);
} }
/* Dark mode form styles */ /* Dark mode form styles */
@media (prefers-color-scheme: dark) { .dark .form-input {
.form-input { background-color: var(--color-secondary-800);
background-color: #374151; border-color: var(--color-secondary-700);
border-color: #4b5563; color: var(--color-foreground);
color: white; }
}
.form-input:focus { .dark .form-input:focus {
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2); box-shadow: 0 0 0 3px var(--color-primary-900);
} }
.form-input::placeholder { .dark .form-input::placeholder {
color: #6b7280; color: var(--color-secondary-500);
}
} }
/* Button Styles */ /* Button Styles */
@@ -106,18 +109,18 @@
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: white; color: var(--color-primary-foreground);
background: linear-gradient(135deg, var(--color-primary), #3730a3); background: linear-gradient(135deg, var(--color-primary), var(--color-primary-700));
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
transition: all 0.2s ease; transition: all 0.2s ease;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
} }
.btn-primary:hover { .btn-primary:hover {
background: linear-gradient(135deg, #3730a3, #312e81); background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-800));
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 6px 12px -2px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
.btn-primary:active { .btn-primary:active {
@@ -130,23 +133,23 @@
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: 1px solid #d1d5db; border: 1px solid var(--color-border);
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: #374151; color: var(--color-foreground);
background-color: white; background-color: var(--color-background);
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06); box-shadow: var(--shadow-sm);
transition: all 0.2s ease; transition: all 0.2s ease;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #f9fafb; background-color: var(--color-secondary-100);
border-color: #9ca3af; border-color: var(--color-secondary-400);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
} }
.btn-secondary:active { .btn-secondary:active {
@@ -154,17 +157,15 @@
} }
/* Dark mode button styles */ /* Dark mode button styles */
@media (prefers-color-scheme: dark) { .dark .btn-secondary {
.btn-secondary { border-color: var(--color-secondary-700);
border-color: #4b5563; color: var(--color-secondary-100);
color: #e5e7eb; background-color: var(--color-secondary-800);
background-color: #374151; }
}
.btn-secondary:hover { .dark .btn-secondary:hover {
background-color: #4b5563; background-color: var(--color-secondary-700);
border-color: #6b7280; border-color: var(--color-secondary-600);
}
} }
/* Menu Styles */ /* Menu Styles */
@@ -175,7 +176,7 @@
width: 100%; width: 100%;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
font-size: 0.875rem; font-size: 0.875rem;
color: #374151; color: var(--color-foreground);
background: none; background: none;
border: none; border: none;
text-align: left; text-align: left;
@@ -185,25 +186,24 @@
} }
.menu-item:hover { .menu-item:hover {
background-color: #f3f4f6; background-color: var(--color-secondary-100);
color: var(--color-primary); color: var(--color-primary);
} }
.menu-item i { .menu-item i,
.menu-item svg {
width: 1.25rem; width: 1.25rem;
text-align: center; text-align: center;
} }
/* Dark mode menu styles */ /* Dark mode menu styles */
@media (prefers-color-scheme: dark) { .dark .menu-item {
.menu-item { color: var(--color-secondary-100);
color: #e5e7eb; }
}
.menu-item:hover { .dark .menu-item:hover {
background-color: #4b5563; background-color: var(--color-secondary-700);
color: var(--color-primary); color: var(--color-primary);
}
} }
/* Theme Toggle Styles */ /* Theme Toggle Styles */
@@ -216,24 +216,18 @@
} }
.theme-toggle-btn:hover { .theme-toggle-btn:hover {
background-color: rgba(79, 70, 229, 0.1); background-color: var(--color-primary-100);
} }
.theme-toggle-btn i::before { .dark .theme-toggle-btn:hover {
content: "\f185"; /* sun icon */ background-color: var(--color-primary-900);
}
@media (prefers-color-scheme: dark) {
.theme-toggle-btn i::before {
content: "\f186"; /* moon icon */
}
} }
/* Mobile Menu Styles */ /* Mobile Menu Styles */
#mobileMenu { #mobileMenu {
display: none; display: none;
padding: 1rem 0; padding: 1rem 0;
border-top: 1px solid #e5e7eb; border-top: 1px solid var(--color-border);
margin-top: 1rem; margin-top: 1rem;
} }
@@ -241,10 +235,8 @@
display: block; display: block;
} }
@media (prefers-color-scheme: dark) { .dark #mobileMenu {
#mobileMenu { border-top-color: var(--color-secondary-700);
border-top-color: #4b5563;
}
} }
/* Grid Adaptive Styles */ /* Grid Adaptive Styles */
@@ -274,30 +266,28 @@
/* Card Styles */ /* Card Styles */
.card { .card {
background: white; background: var(--color-card);
border-radius: 0.75rem; border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
overflow: hidden; overflow: hidden;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.card:hover { .card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-lg);
} }
@media (prefers-color-scheme: dark) { .dark .card {
.card { background: var(--color-card);
background: #1f2937; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
.card:hover {
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.4);
}
} }
/* Alert Styles */ .dark .card:hover {
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.4);
}
/* Alert Styles - Using design tokens */
.alert { .alert {
position: fixed; position: fixed;
top: 1rem; top: 1rem;
@@ -305,28 +295,28 @@
z-index: 50; z-index: 50;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-lg);
transition: all 0.3s ease; transition: all 0.3s ease;
animation: slideInRight 0.3s ease-out; animation: slideInRight 0.3s ease-out;
} }
.alert-success { .alert-success {
background-color: #10b981; background-color: var(--color-success-500);
color: white; color: white;
} }
.alert-error { .alert-error {
background-color: #ef4444; background-color: var(--color-error-500);
color: white; color: white;
} }
.alert-info { .alert-info {
background-color: #3b82f6; background-color: var(--color-info-500);
color: white; color: white;
} }
.alert-warning { .alert-warning {
background-color: #f59e0b; background-color: var(--color-warning-500);
color: white; color: white;
} }
@@ -351,7 +341,7 @@
display: inline-block; display: inline-block;
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
border: 2px solid #f3f4f6; border: 2px solid var(--color-secondary-200);
border-radius: 50%; border-radius: 50%;
border-top-color: var(--color-primary); border-top-color: var(--color-primary);
animation: spin 1s ease-in-out infinite; animation: spin 1s ease-in-out infinite;
@@ -365,18 +355,18 @@
/* Utility Classes */ /* Utility Classes */
.text-gradient { .text-gradient {
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); background: linear-gradient(135deg, var(--color-primary), var(--color-accent-500));
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
} }
.bg-gradient-primary { .bg-gradient-primary {
background: linear-gradient(135deg, var(--color-primary), #3730a3); background: linear-gradient(135deg, var(--color-primary), var(--color-primary-700));
} }
.bg-gradient-secondary { .bg-gradient-secondary {
background: linear-gradient(135deg, var(--color-secondary), #be185d); background: linear-gradient(135deg, var(--color-accent-500), var(--color-accent-700));
} }
/* Responsive Utilities */ /* Responsive Utilities */
@@ -403,7 +393,11 @@
.focus-ring:focus { .focus-ring:focus {
outline: none; outline: none;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2); box-shadow: 0 0 0 3px var(--color-primary-200);
}
.dark .focus-ring:focus {
box-shadow: 0 0 0 3px var(--color-primary-800);
} }
/* Animation Classes */ /* Animation Classes */

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,551 @@
/**
* ThrillWiki Form Validation Module
*
* Provides client-side form validation helpers with HTMX integration.
* Works with Alpine.js form components and Django form fields.
*
* Features:
* - Debounced HTMX validation triggers
* - Field state management (pristine, dirty, valid, invalid)
* - Real-time validation feedback
* - Integration with Alpine.js stores
* - Accessible error announcements
*/
// =============================================================================
// Form Field State Management
// =============================================================================
/**
* Field state constants
*/
const FieldState = {
PRISTINE: 'pristine', // Field has not been touched
DIRTY: 'dirty', // Field has been modified
VALIDATING: 'validating', // Field is being validated
VALID: 'valid', // Field passed validation
INVALID: 'invalid', // Field failed validation
};
/**
* FormValidator class for managing form validation state
*/
class FormValidator {
constructor(formElement, options = {}) {
this.form = formElement;
this.options = {
validateOnBlur: true,
validateOnChange: true,
debounceMs: 500,
showSuccessState: true,
scrollToFirstError: true,
...options,
};
this.fields = new Map();
this.debounceTimers = new Map();
this.init();
}
/**
* Initialize form validation
*/
init() {
if (!this.form) return;
// Find all form fields
const inputs = this.form.querySelectorAll('input, textarea, select');
inputs.forEach(input => {
if (input.name) {
this.registerField(input);
}
});
// Prevent default form submission if validation fails
this.form.addEventListener('submit', (e) => {
if (!this.validateAll()) {
e.preventDefault();
if (this.options.scrollToFirstError) {
this.scrollToFirstError();
}
}
});
}
/**
* Register a field for validation
*/
registerField(input) {
const fieldName = input.name;
this.fields.set(fieldName, {
element: input,
state: FieldState.PRISTINE,
errors: [],
touched: false,
});
// Add event listeners
if (this.options.validateOnBlur) {
input.addEventListener('blur', () => this.onFieldBlur(fieldName));
}
if (this.options.validateOnChange) {
input.addEventListener('input', () => this.onFieldChange(fieldName));
}
}
/**
* Handle field blur event
*/
onFieldBlur(fieldName) {
const field = this.fields.get(fieldName);
if (!field) return;
field.touched = true;
this.validateField(fieldName);
}
/**
* Handle field change event with debouncing
*/
onFieldChange(fieldName) {
const field = this.fields.get(fieldName);
if (!field) return;
field.state = FieldState.DIRTY;
this.updateFieldUI(fieldName);
// Debounce validation
clearTimeout(this.debounceTimers.get(fieldName));
this.debounceTimers.set(fieldName, setTimeout(() => {
if (field.touched) {
this.validateField(fieldName);
}
}, this.options.debounceMs));
}
/**
* Validate a single field
*/
validateField(fieldName) {
const field = this.fields.get(fieldName);
if (!field) return true;
field.state = FieldState.VALIDATING;
this.updateFieldUI(fieldName);
const input = field.element;
const errors = [];
// Built-in HTML5 validation
if (!input.validity.valid) {
if (input.validity.valueMissing) {
errors.push(`${this.getFieldLabel(fieldName)} is required`);
}
if (input.validity.typeMismatch) {
errors.push(`Please enter a valid ${input.type}`);
}
if (input.validity.tooShort) {
errors.push(`Must be at least ${input.minLength} characters`);
}
if (input.validity.tooLong) {
errors.push(`Must be no more than ${input.maxLength} characters`);
}
if (input.validity.patternMismatch) {
errors.push(input.title || 'Please match the requested format');
}
if (input.validity.rangeUnderflow) {
errors.push(`Must be at least ${input.min}`);
}
if (input.validity.rangeOverflow) {
errors.push(`Must be no more than ${input.max}`);
}
}
// Custom validation rules from data attributes
if (input.dataset.validateUrl && input.value) {
try {
new URL(input.value);
} catch {
errors.push('Please enter a valid URL');
}
}
if (input.dataset.validateMatch) {
const matchField = this.form.querySelector(`[name="${input.dataset.validateMatch}"]`);
if (matchField && input.value !== matchField.value) {
errors.push(`Must match ${this.getFieldLabel(input.dataset.validateMatch)}`);
}
}
// Update field state
field.errors = errors;
field.state = errors.length > 0 ? FieldState.INVALID : FieldState.VALID;
this.updateFieldUI(fieldName);
return errors.length === 0;
}
/**
* Validate all fields
*/
validateAll() {
let isValid = true;
this.fields.forEach((field, fieldName) => {
field.touched = true;
if (!this.validateField(fieldName)) {
isValid = false;
}
});
return isValid;
}
/**
* Update field UI based on state
*/
updateFieldUI(fieldName) {
const field = this.fields.get(fieldName);
if (!field) return;
const input = field.element;
const wrapper = input.closest('.form-field');
const feedback = wrapper?.querySelector('.field-feedback');
// Remove all state classes
input.classList.remove(
'border-red-500', 'border-green-500', 'border-gray-300',
'focus:ring-red-500', 'focus:ring-green-500', 'focus:ring-blue-500'
);
// Set aria attributes
input.setAttribute('aria-invalid', field.state === FieldState.INVALID);
switch (field.state) {
case FieldState.VALIDATING:
input.classList.add('border-blue-300');
break;
case FieldState.INVALID:
input.classList.add('border-red-500', 'focus:ring-red-500');
if (feedback) {
feedback.innerHTML = this.renderErrors(field.errors);
}
break;
case FieldState.VALID:
if (this.options.showSuccessState && field.touched) {
input.classList.add('border-green-500', 'focus:ring-green-500');
if (feedback) {
feedback.innerHTML = this.renderSuccess();
}
}
break;
default:
input.classList.add('border-gray-300', 'focus:ring-blue-500');
}
}
/**
* Render error messages HTML
*/
renderErrors(errors) {
if (errors.length === 0) return '';
return `
<ul class="text-sm text-red-600 dark:text-red-400 space-y-1 animate-slide-down" role="alert" aria-live="assertive">
${errors.map(error => `
<li class="flex items-start gap-1.5">
<i class="fas fa-exclamation-circle mt-0.5 flex-shrink-0" aria-hidden="true"></i>
<span>${error}</span>
</li>
`).join('')}
</ul>
`;
}
/**
* Render success indicator HTML
*/
renderSuccess() {
return `
<div class="text-sm text-green-600 dark:text-green-400 flex items-center gap-1.5 animate-slide-down" role="status">
<i class="fas fa-check-circle flex-shrink-0" aria-hidden="true"></i>
</div>
`;
}
/**
* Get field label text
*/
getFieldLabel(fieldName) {
const field = this.fields.get(fieldName);
if (!field) return fieldName;
const wrapper = field.element.closest('.form-field');
const label = wrapper?.querySelector('label');
return label?.textContent?.replace('*', '').trim() || fieldName;
}
/**
* Scroll to first error
*/
scrollToFirstError() {
for (const [fieldName, field] of this.fields) {
if (field.state === FieldState.INVALID) {
field.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
field.element.focus();
break;
}
}
}
/**
* Set external errors (from server)
*/
setServerErrors(errors) {
Object.entries(errors).forEach(([fieldName, messages]) => {
const field = this.fields.get(fieldName);
if (field) {
field.errors = Array.isArray(messages) ? messages : [messages];
field.state = FieldState.INVALID;
field.touched = true;
this.updateFieldUI(fieldName);
}
});
}
/**
* Clear all errors
*/
clearErrors() {
this.fields.forEach((field, fieldName) => {
field.errors = [];
field.state = FieldState.PRISTINE;
this.updateFieldUI(fieldName);
});
}
/**
* Reset form to initial state
*/
reset() {
this.clearErrors();
this.fields.forEach(field => {
field.touched = false;
});
this.form.reset();
}
}
// =============================================================================
// HTMX Validation Integration
// =============================================================================
/**
* Setup HTMX validation for a field
* @param {HTMLElement} input - The input element
* @param {string} validateUrl - URL for validation endpoint
* @param {object} options - Configuration options
*/
function setupHTMXValidation(input, validateUrl, options = {}) {
const defaults = {
trigger: 'blur changed delay:500ms',
indicator: true,
targetSelector: null,
};
const config = { ...defaults, ...options };
// Generate target ID
const targetId = config.targetSelector || `#${input.name}-feedback`;
// Set HTMX attributes
input.setAttribute('hx-post', validateUrl);
input.setAttribute('hx-trigger', config.trigger);
input.setAttribute('hx-target', targetId);
input.setAttribute('hx-swap', 'innerHTML');
if (config.indicator) {
const indicatorId = `#${input.name}-indicator`;
input.setAttribute('hx-indicator', indicatorId);
// Create indicator if it doesn't exist
const wrapper = input.closest('.form-field');
if (wrapper && !wrapper.querySelector(indicatorId)) {
const indicator = document.createElement('span');
indicator.id = `${input.name}-indicator`;
indicator.className = 'htmx-indicator absolute right-3 top-1/2 -translate-y-1/2';
indicator.innerHTML = '<i class="fas fa-spinner fa-spin text-gray-400" aria-hidden="true"></i>';
const inputWrapper = wrapper.querySelector('.relative') || wrapper;
inputWrapper.appendChild(indicator);
}
}
// Process HTMX attributes
if (typeof htmx !== 'undefined') {
htmx.process(input);
}
}
// =============================================================================
// Alpine.js Integration
// =============================================================================
/**
* Alpine.js form validation component
* Usage: x-data="formValidation('/api/validate/')"
*/
document.addEventListener('alpine:init', () => {
if (typeof Alpine === 'undefined') return;
Alpine.data('formValidation', (validateUrl = null) => ({
fields: {},
errors: {},
touched: {},
validating: {},
submitted: false,
init() {
// Find all form fields within this component
this.$el.querySelectorAll('input, textarea, select').forEach(input => {
if (input.name) {
this.fields[input.name] = input.value;
this.errors[input.name] = [];
this.touched[input.name] = false;
this.validating[input.name] = false;
}
});
},
async validateField(fieldName) {
if (!validateUrl) return true;
this.validating[fieldName] = true;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ||
document.querySelector('[name=csrfmiddlewaretoken]')?.value;
const response = await fetch(validateUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
field: fieldName,
value: this.fields[fieldName],
}),
});
const data = await response.json();
if (data.errors && data.errors[fieldName]) {
this.errors[fieldName] = data.errors[fieldName];
return false;
} else {
this.errors[fieldName] = [];
return true;
}
} catch (error) {
console.error('Validation error:', error);
return true; // Don't block on network errors
} finally {
this.validating[fieldName] = false;
}
},
onBlur(fieldName) {
this.touched[fieldName] = true;
this.validateField(fieldName);
},
hasError(fieldName) {
return this.touched[fieldName] && this.errors[fieldName]?.length > 0;
},
isValid(fieldName) {
return this.touched[fieldName] && this.errors[fieldName]?.length === 0;
},
getErrors(fieldName) {
return this.errors[fieldName] || [];
},
setServerErrors(errors) {
Object.entries(errors).forEach(([field, messages]) => {
this.errors[field] = Array.isArray(messages) ? messages : [messages];
this.touched[field] = true;
});
},
clearErrors() {
Object.keys(this.errors).forEach(field => {
this.errors[field] = [];
});
},
async validateAll() {
let isValid = true;
for (const fieldName of Object.keys(this.fields)) {
this.touched[fieldName] = true;
const fieldValid = await this.validateField(fieldName);
if (!fieldValid) isValid = false;
}
return isValid;
},
async submitForm(url, options = {}) {
this.submitted = true;
const isValid = await this.validateAll();
if (!isValid) {
return { success: false, errors: this.errors };
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ||
document.querySelector('[name=csrfmiddlewaretoken]')?.value;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
...options.headers,
},
body: JSON.stringify(this.fields),
});
const data = await response.json();
if (!response.ok) {
if (data.errors) {
this.setServerErrors(data.errors);
}
return { success: false, errors: data.errors || data };
}
return { success: true, data };
} catch (error) {
console.error('Form submission error:', error);
return { success: false, error: error.message };
}
},
}));
});
// =============================================================================
// Global Exports
// =============================================================================
window.FormValidator = FormValidator;
window.setupHTMXValidation = setupHTMXValidation;
window.FieldState = FieldState;

View File

@@ -7,6 +7,7 @@
* - Mobile menu functionality * - Mobile menu functionality
* - Flash message handling * - Flash message handling
* - Tooltip initialization * - Tooltip initialization
* - Global HTMX loading state management
*/ */
// ============================================================================= // =============================================================================
@@ -183,3 +184,127 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
}); });
// =============================================================================
// HTMX Loading State Management
// =============================================================================
/**
* Global HTMX Loading State Management
*
* Provides consistent loading state handling across the application:
* - Adds 'htmx-loading' class to body during requests
* - Manages button disabled states during form submissions
* - Handles search input loading states with debouncing
* - Provides skeleton screen swap utilities
*/
document.addEventListener('DOMContentLoaded', () => {
// Track active HTMX requests
let activeRequests = 0;
/**
* Add global loading class to body during HTMX requests
*/
document.body.addEventListener('htmx:beforeRequest', (evt) => {
activeRequests++;
document.body.classList.add('htmx-loading');
// Disable submit buttons within the target element
const target = evt.target;
if (target.tagName === 'FORM' || target.closest('form')) {
const form = target.tagName === 'FORM' ? target : target.closest('form');
const submitBtn = form.querySelector('[type="submit"]');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.classList.add('htmx-request');
}
}
});
/**
* Remove global loading class when request completes
*/
document.body.addEventListener('htmx:afterRequest', (evt) => {
activeRequests--;
if (activeRequests <= 0) {
activeRequests = 0;
document.body.classList.remove('htmx-loading');
}
// Re-enable submit buttons
const target = evt.target;
if (target.tagName === 'FORM' || target.closest('form')) {
const form = target.tagName === 'FORM' ? target : target.closest('form');
const submitBtn = form.querySelector('[type="submit"]');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.classList.remove('htmx-request');
}
}
});
/**
* Handle search inputs with loading states
* Automatically adds loading indicator to search inputs during HTMX requests
*/
document.querySelectorAll('input[type="search"], input[data-search]').forEach(input => {
const wrapper = input.closest('.search-wrapper, .relative');
if (!wrapper) return;
// Create loading indicator if it doesn't exist
let indicator = wrapper.querySelector('.search-loading');
if (!indicator) {
indicator = document.createElement('span');
indicator.className = 'search-loading htmx-indicator absolute right-3 top-1/2 -translate-y-1/2';
indicator.innerHTML = '<i class="fas fa-spinner fa-spin text-muted-foreground"></i>';
wrapper.appendChild(indicator);
}
});
/**
* Swap skeleton with content utility
* Use data-skeleton-target to specify which skeleton to hide when content loads
*/
document.body.addEventListener('htmx:afterSwap', (evt) => {
const skeletonTarget = evt.target.dataset.skeletonTarget;
if (skeletonTarget) {
const skeleton = document.querySelector(skeletonTarget);
if (skeleton) {
skeleton.style.display = 'none';
}
}
});
});
/**
* Utility function to show skeleton and trigger HTMX load
* @param {string} targetId - ID of the target element
* @param {string} skeletonId - ID of the skeleton element
*/
window.showSkeletonAndLoad = function(targetId, skeletonId) {
const target = document.getElementById(targetId);
const skeleton = document.getElementById(skeletonId);
if (skeleton) {
skeleton.style.display = 'block';
}
if (target) {
htmx.trigger(target, 'load');
}
};
/**
* Utility function to replace content with skeleton during reload
* @param {string} targetId - ID of the target element
* @param {string} skeletonHtml - HTML string of skeleton to show
*/
window.reloadWithSkeleton = function(targetId, skeletonHtml) {
const target = document.getElementById(targetId);
if (target && skeletonHtml) {
// Store original content temporarily
target.dataset.originalContent = target.innerHTML;
target.innerHTML = skeletonHtml;
htmx.trigger(target, 'load');
}
};

421
backend/templates/README.md Normal file
View File

@@ -0,0 +1,421 @@
# ThrillWiki Template System
This document describes the template architecture, conventions, and best practices for ThrillWiki.
## Directory Structure
```
templates/
├── base/ # Base templates
│ └── base.html # Root template all pages extend
├── components/ # Reusable UI components
│ ├── ui/ # UI primitives (button, card, toast)
│ ├── modals/ # Modal components
│ ├── pagination.html # Pagination (supports HTMX)
│ ├── status_badge.html # Status badge (parks/rides)
│ ├── stats_card.html # Statistics card
│ └── history_panel.html # History/audit trail
├── forms/ # Form-related templates
│ ├── partials/ # Form field components
│ │ ├── form_field.html
│ │ ├── field_error.html
│ │ └── field_success.html
│ └── layouts/ # Form layout templates
│ ├── stacked.html # Vertical layout
│ ├── inline.html # Horizontal layout
│ └── grid.html # Multi-column grid
├── htmx/ # HTMX-specific templates
│ ├── components/ # HTMX components
│ └── README.md # HTMX documentation
├── {app}/ # App-specific templates
│ ├── {model}_list.html # List views
│ ├── {model}_detail.html # Detail views
│ ├── {model}_form.html # Form views
│ └── partials/ # App-specific partials
└── README.md # This file
```
## Template Inheritance
### Base Template
All pages extend `base/base.html`. Available blocks:
```django
{% extends "base/base.html" %}
{# Page title (appears in <title> and meta tags) #}
{% block title %}My Page - ThrillWiki{% endblock %}
{# Main page content #}
{% block content %}
<h1>Page Content</h1>
{% endblock %}
{# Additional CSS/meta in <head> #}
{% block extra_head %}
<link rel="stylesheet" href="...">
{% endblock %}
{# Additional JavaScript before </body> #}
{% block extra_js %}
<script src="..."></script>
{% endblock %}
{# Additional body classes #}
{% block body_class %}custom-page{% endblock %}
{# Additional main element classes #}
{% block main_class %}no-padding{% endblock %}
{# Override navigation (defaults to enhanced_header.html) #}
{% block navigation %}{% endblock %}
{# Override footer #}
{% block footer %}{% endblock %}
{# Meta tags for SEO #}
{% block meta_description %}Page description{% endblock %}
{% block og_title %}Open Graph title{% endblock %}
{% block og_description %}Open Graph description{% endblock %}
```
### Inheritance Example
```django
{% extends "base/base.html" %}
{% load static %}
{% load park_tags %}
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/park-detail.css' %}">
{% endblock %}
{% block content %}
<div class="container">
<h1>{{ park.name }}</h1>
{% include "parks/partials/park_header_badge.html" %}
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/park-map.js' %}"></script>
{% endblock %}
```
## Component Usage
### Pagination
```django
{# Standard pagination #}
{% include 'components/pagination.html' with page_obj=page_obj %}
{# HTMX-enabled pagination #}
{% include 'components/pagination.html' with page_obj=page_obj use_htmx=True hx_target='#results' %}
{# Small size #}
{% include 'components/pagination.html' with page_obj=page_obj size='sm' %}
```
### Status Badge
```django
{# Basic badge #}
{% include 'components/status_badge.html' with status=park.status %}
{# Interactive badge with HTMX refresh #}
{% include 'components/status_badge.html' with
status=park.status
badge_id='park-header-badge'
refresh_trigger='park-status-changed'
scroll_target='park-status-section'
can_edit=perms.parks.change_park
%}
```
### Stats Card
```django
{# Basic stat #}
{% include 'components/stats_card.html' with label='Total Rides' value=park.ride_count %}
{# Clickable stat #}
{% include 'components/stats_card.html' with label='Total Rides' value=42 link=rides_url %}
{# Priority stat (highlighted) #}
{% include 'components/stats_card.html' with label='Operator' value=park.operator.name priority=True %}
```
### History Panel
```django
{# Basic history #}
{% include 'components/history_panel.html' with history=history %}
{# With FSM toggle for moderators #}
{% include 'components/history_panel.html' with
history=history
show_fsm_toggle=True
fsm_history_url=fsm_url
model_type='park'
object_id=park.id
can_view_fsm=perms.parks.change_park
%}
```
### Loading Indicator
```django
{# Block indicator #}
{% include 'htmx/components/loading_indicator.html' with id='loading' message='Loading...' %}
{# Inline indicator (in buttons) #}
{% include 'htmx/components/loading_indicator.html' with id='btn-loading' inline=True size='sm' %}
{# Overlay indicator #}
{% include 'htmx/components/loading_indicator.html' with id='overlay' mode='overlay' %}
```
## Form Rendering
### Form Layouts
```django
{# Stacked layout (default) #}
{% include 'forms/layouts/stacked.html' with form=form %}
{# Inline/horizontal layout #}
{% include 'forms/layouts/inline.html' with form=form %}
{# 2-column grid #}
{% include 'forms/layouts/grid.html' with form=form cols=2 %}
{# With excluded fields #}
{% include 'forms/layouts/stacked.html' with form=form exclude='password2' %}
{# Custom submit text #}
{% include 'forms/layouts/stacked.html' with form=form submit_text='Save Changes' %}
```
### Individual Fields
```django
{# Standard field #}
{% include 'forms/partials/form_field.html' with field=form.email %}
{# Field with custom label #}
{% include 'forms/partials/form_field.html' with field=form.email label='Email Address' %}
{# Field with HTMX validation #}
{% include 'forms/partials/form_field.html' with
field=form.username
hx_validate=True
hx_validate_url='/api/validate/username/'
%}
```
## Template Tags
### Loading Order
Always load template tags in this order:
```django
{% load static %}
{% load i18n %}
{% load park_tags %} {# App-specific #}
{% load safe_html %} {# Sanitization #}
{% load common_filters %} {# Utility filters #}
{% load cache %} {# Caching (if used) #}
```
### Available Filters
**common_filters:**
```django
{{ datetime|humanize_timedelta }} {# "2 hours ago" #}
{{ text|truncate_smart:50 }} {# Truncate at word boundary #}
{{ number|format_number }} {# "1,234,567" #}
{{ number|format_compact }} {# "1.2K", "3.4M" #}
{{ dict|get_item:"key" }} {# Safe dict access #}
{{ count|pluralize_custom:"item,items" }}
{{ field|add_class:"form-control" }}
```
**safe_html:**
```django
{{ content|sanitize }} {# Full HTML sanitization #}
{{ comment|sanitize_minimal }} {# Basic text only #}
{{ text|strip_html }} {# Remove all HTML #}
{{ data|json_safe }} {# Safe JSON for JS #}
{% icon "check" class="w-4 h-4" %} {# SVG icon #}
```
## Context Variables
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Single object | Lowercase model name | `park`, `ride`, `user` |
| List/QuerySet | `{model}_list` | `park_list`, `ride_list` |
| Paginated | `page_obj` | Django standard |
| Single form | `form` | Standard |
| Multiple forms | `{purpose}_form` | `login_form`, `signup_form` |
### Avoid
- Generic names: `object`, `item`, `obj`, `data`
- Abbreviated names: `p`, `r`, `f`, `frm`
## HTMX Patterns
See `htmx/README.md` for detailed HTMX documentation.
### Quick Reference
```django
{# Swap strategies #}
hx-swap="innerHTML" {# Replace content inside #}
hx-swap="outerHTML" {# Replace entire element #}
hx-swap="beforeend" {# Append #}
hx-swap="afterbegin" {# Prepend #}
{# Target naming #}
hx-target="#park-123" {# Specific object #}
hx-target="#results" {# Page section #}
hx-target="this" {# Self #}
{# Event naming #}
hx-trigger="park-status-changed from:body"
hx-trigger="auth-changed from:body"
```
## Caching
Use fragment caching for expensive template sections:
```django
{% load cache %}
{# Cache navigation for 5 minutes per user #}
{% cache 300 nav user.id %}
{% include 'components/layout/enhanced_header.html' %}
{% endcache %}
{# Cache stats with object version #}
{% cache 600 park_stats park.id park.updated_at %}
{% include 'parks/partials/park_stats.html' %}
{% endcache %}
```
### Cache Keys
Include in cache key:
- User ID for personalized content
- Object ID for object-specific content
- `updated_at` for automatic invalidation
Do NOT cache:
- User-specific actions (edit buttons)
- Form CSRF tokens
- Real-time data
## Accessibility
### Checklist
- [ ] Single `<h1>` per page
- [ ] Heading hierarchy (h1 → h2 → h3)
- [ ] Labels for all form inputs
- [ ] `aria-label` for icon-only buttons
- [ ] `aria-describedby` for help text
- [ ] `aria-invalid` for fields with errors
- [ ] `role="alert"` for error messages
- [ ] `aria-live` for dynamic content
### Skip Links
Base template includes skip link to main content:
```html
<a href="#main-content" class="sr-only focus:not-sr-only ...">
Skip to main content
</a>
```
### Landmarks
```html
<nav role="navigation" aria-label="Main navigation">
<main role="main" aria-label="Main content">
<footer role="contentinfo">
```
## Security
### Safe HTML Rendering
```django
{# User content - ALWAYS sanitize #}
{{ user_description|sanitize }}
{# Comments - minimal formatting #}
{{ comment_text|sanitize_minimal }}
{# Remove all HTML #}
{{ raw_text|strip_html }}
{# NEVER use |safe for user content #}
{{ user_input|safe }} {# DANGEROUS! #}
```
### JSON in Templates
```django
{# Safe JSON for JavaScript #}
<script>
const data = {{ python_dict|json_safe }};
</script>
```
## Component Documentation Template
Each component should have a header comment:
```django
{% comment %}
Component Name
==============
Brief description of what the component does.
Purpose:
Detailed explanation of the component's purpose.
Usage Examples:
{% include 'components/example.html' with param='value' %}
Parameters:
- param1: Description (required/optional, default: value)
- param2: Description
Dependencies:
- Alpine.js for interactivity
- HTMX for dynamic updates
Security: Notes about sanitization, XSS prevention
{% endcomment %}
```
## File Naming
| Type | Pattern | Example |
|------|---------|---------|
| List view | `{model}_list.html` | `park_list.html` |
| Detail view | `{model}_detail.html` | `park_detail.html` |
| Form view | `{model}_form.html` | `park_form.html` |
| Partial | `{model}_{purpose}.html` | `park_header_badge.html` |
| Component | `{purpose}.html` | `pagination.html` |

View File

@@ -1,37 +1,112 @@
{% load static %} {% load static %}
<!DOCTYPE html> {% load cache %}
<html lang="en"> {# =============================================================================
<head> ThrillWiki Base Template
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token }}" />
<title>{% block title %}ThrillWiki{% endblock %}</title>
<!-- Google Fonts --> This is the root template that all pages extend. It provides:
<link - HTML5 document structure with accessibility features
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" - SEO meta tags and Open Graph/Twitter cards
rel="stylesheet" - CSS and JavaScript asset loading
/> - Navigation header and footer
- Django messages and toast notifications
- HTMX and Alpine.js configuration
Available Blocks:
----------------
Content Blocks:
- title: Page title (appears in <title> tag and meta)
- content: Main page content
- navigation: Navigation header (defaults to enhanced_header.html)
- footer: Page footer
Meta Blocks:
- meta_description: Page meta description for SEO
- meta_keywords: Page meta keywords
- og_type: Open Graph type (default: website)
- og_title: Open Graph title (defaults to title block)
- og_description: Open Graph description
- og_image: Open Graph image URL
- twitter_title: Twitter card title
- twitter_description: Twitter card description
Customization Blocks:
- extra_head: Additional CSS/meta tags in <head>
- extra_js: Additional JavaScript before </body>
- body_class: Additional classes for <body> tag
- main_class: Additional classes for <main> tag
Usage Example:
{% extends "base/base.html" %}
{% block title %}My Page - ThrillWiki{% endblock %}
{% block content %}
<h1>My Page Content</h1>
{% endblock %}
============================================================================= #}
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<meta name="description" content="{% block meta_description %}ThrillWiki - Your comprehensive guide to theme parks and roller coasters{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}theme parks, roller coasters, rides, amusement parks{% endblock %}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
<meta property="og:url" content="{{ request.build_absolute_uri }}">
<meta property="og:title" content="{% block og_title %}{% block title %}ThrillWiki{% endblock %}{% endblock %}">
<meta property="og:description" content="{% block og_description %}ThrillWiki - Your comprehensive guide to theme parks and roller coasters{% endblock %}">
<meta property="og:image" content="{% block og_image %}{% static 'images/og-default.jpg' %}{% endblock %}">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:title" content="{% block twitter_title %}ThrillWiki{% endblock %}">
<meta property="twitter:description" content="{% block twitter_description %}ThrillWiki - Your comprehensive guide to theme parks and roller coasters{% endblock %}">
{# Use title block directly #}
<title>{% block page_title %}{% block title %}ThrillWiki{% endblock %}{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="{% static 'favicon.ico' %}">
<!-- Fonts - Preconnect for performance -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Playfair+Display:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Font Awesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Prevent flash of wrong theme --> <!-- Prevent flash of wrong theme -->
<script> <script>
let theme = localStorage.getItem("theme"); (function() {
if (!theme) { let theme = localStorage.getItem('theme');
theme = window.matchMedia("(prefers-color-scheme: dark)").matches if (!theme) {
? "dark" theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
: "light"; }
localStorage.setItem("theme", theme); if (theme === 'dark') {
} document.documentElement.classList.add('dark');
if (theme === "dark") { }
document.documentElement.classList.add("dark"); })();
}
</script> </script>
<!-- HTMX --> <!-- Design System CSS - Load in correct order -->
<script src="https://unpkg.com/htmx.org@1.9.6"></script> <link href="{% static 'css/design-tokens.css' %}" rel="stylesheet">
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet">
<link href="{% static 'css/components.css' %}" rel="stylesheet">
<!-- Alpine.js --> <!-- HTMX -->
<script defer src="{% static 'js/alpine.min.js' %}"></script> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Alpine.js Plugins -->
<script defer src="https://unpkg.com/@alpinejs/intersect@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://unpkg.com/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<!-- Alpine.js Core -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- Alpine.js Stores (must load before alpine:init) -->
<script src="{% static 'js/stores/index.js' %}"></script>
<!-- Alpine.js Components --> <!-- Alpine.js Components -->
<script src="{% static 'js/alpine-components.js' %}"></script> <script src="{% static 'js/alpine-components.js' %}"></script>
@@ -39,93 +114,104 @@
<!-- Location Autocomplete --> <!-- Location Autocomplete -->
<script src="{% static 'js/location-autocomplete.js' %}"></script> <script src="{% static 'js/location-autocomplete.js' %}"></script>
<!-- Tailwind CSS -->
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
<link href="{% static 'css/components.css' %}" rel="stylesheet" />
<link href="{% static 'css/alerts.css' %}" rel="stylesheet" />
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
/>
<style> <style>
[x-cloak] { /* Hide elements until Alpine.js is ready */
display: none !important; [x-cloak] { display: none !important; }
}
.dropdown-menu { /* HTMX loading indicator styles */
position: absolute; .htmx-indicator { display: none; }
right: 0; .htmx-request .htmx-indicator { display: inline-block; }
margin-top: 0.5rem; .htmx-request.htmx-indicator { display: inline-block; }
width: 12rem;
border-radius: 0.375rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
z-index: 50;
overflow: hidden;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: block;
}
.htmx-request.htmx-indicator {
display: block;
}
</style> </style>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body
class="flex flex-col min-h-screen text-gray-900 bg-gradient-to-br from-white via-blue-50 to-indigo-50 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950 dark:text-white" <body class="flex flex-col min-h-screen font-sans antialiased bg-background text-foreground {% block body_class %}{% endblock %}"
> x-data
<!-- Enhanced Header --> x-init="$store.theme.init(); $store.auth.init()"
{% include 'components/layout/enhanced_header.html' %} :class="{ 'dark': $store.theme.isDark }">
<!-- Skip to main content link for accessibility -->
<a href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 z-50 px-4 py-2 rounded-md bg-primary text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
Skip to main content
</a>
<!-- HTMX CSRF Configuration -->
<div hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' style="display: none;"></div>
<!-- Navigation Header -->
{% block navigation %}
{% include 'components/layout/enhanced_header.html' %}
{% endblock navigation %}
<!-- Breadcrumb Navigation -->
{% block breadcrumbs %}
{% if breadcrumbs %}
<div class="container px-4 mx-auto md:px-6 lg:px-8">
{% include 'components/navigation/breadcrumbs.html' %}
</div>
{% endif %}
{% endblock breadcrumbs %}
<!-- Flash Messages --> <!-- Flash Messages -->
{% if messages %} {% if messages %}
<div class="fixed top-0 right-0 z-50 p-4 space-y-4"> <div class="fixed top-4 right-4 z-50 space-y-2" role="alert" aria-live="polite">
{% for message in messages %} {% for message in messages %}
<div <div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %} animate-slide-in"
class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}" x-data="{ show: true }"
> x-show="show"
{{ message }} x-init="setTimeout(() => show = false, 5000)"
</div> x-transition:leave="transition ease-in duration-300"
{% endfor %} x-transition:leave-start="opacity-100 transform translate-x-0"
x-transition:leave-end="opacity-0 transform translate-x-full">
<div class="flex items-center gap-2">
<span>{{ message }}</span>
<button type="button"
@click="show = false"
class="ml-auto opacity-70 hover:opacity-100 focus:outline-none"
aria-label="Dismiss">
<svg class="w-4 h-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>
</div>
</div>
{% endfor %}
</div> </div>
{% endif %} {% endif %}
<!-- Main Content --> <!-- Main Content -->
<main class="container flex-grow px-6 py-8 mx-auto"> <main id="main-content" class="container flex-grow px-4 py-8 mx-auto md:px-6 lg:px-8 {% block main_class %}{% endblock %}" role="main" aria-label="Main content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<!-- Footer --> <!-- Footer -->
<footer {% block footer %}
class="mt-auto border-t bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50" {# Cache footer for 1 hour - static content #}
> {% cache 3600 footer_content %}
<div class="container px-6 py-6 mx-auto"> <footer class="mt-auto border-t bg-card/50 backdrop-blur-sm border-border" role="contentinfo">
<div class="flex items-center justify-between"> <div class="container px-4 py-6 mx-auto md:px-6 lg:px-8">
<div class="text-gray-600 dark:text-gray-400"> <div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p>&copy; {% now "Y" %} ThrillWiki. All rights reserved.</p> <div class="text-sm text-muted-foreground">
</div> <p>&copy; {% now "Y" %} ThrillWiki. All rights reserved.</p>
<div class="space-x-4"> </div>
<a <nav class="flex items-center gap-4 text-sm" aria-label="Footer navigation">
href="{% url 'terms' %}" <a href="{% url 'terms' %}"
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary" class="text-muted-foreground hover:text-foreground transition-colors">
>Terms</a Terms
> </a>
<a <a href="{% url 'privacy' %}"
href="{% url 'privacy' %}" class="text-muted-foreground hover:text-foreground transition-colors">
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary" Privacy
>Privacy</a </a>
> </nav>
</div> </div>
</div> </div>
</div>
</footer> </footer>
{% endcache %}
{% endblock footer %}
<!-- Global Auth Modal --> <!-- Global Auth Modal -->
{% include 'components/auth/auth-modal.html' %} {% include 'components/auth/auth-modal.html' %}
@@ -133,30 +219,135 @@
<!-- Global Toast Container --> <!-- Global Toast Container -->
{% include 'components/ui/toast-container.html' %} {% include 'components/ui/toast-container.html' %}
<!-- Custom JavaScript --> <!-- Core JavaScript -->
<script src="{% static 'js/main.js' %}"></script> <script src="{% static 'js/main.js' %}"></script>
<script src="{% static 'js/alerts.js' %}"></script> <script src="{% static 'js/alerts.js' %}"></script>
<script src="{% static 'js/fsm-transitions.js' %}"></script> <script src="{% static 'js/fsm-transitions.js' %}"></script>
<!-- Handle HX-Trigger headers for toast notifications --> <!-- HTMX Configuration and Error Handling -->
<script> <script>
document.body.addEventListener('htmx:afterOnLoad', function(evt) { /**
const triggerHeader = evt.detail.xhr.getResponseHeader('HX-Trigger'); * HTMX Configuration
if (triggerHeader) { * ==================
try { * This section configures HTMX behavior and error handling.
const triggers = JSON.parse(triggerHeader); *
if (triggers.showToast && Alpine && Alpine.store('toast')) { * Swap Strategies Used:
Alpine.store('toast')[triggers.showToast.type || 'info']( * - innerHTML: Replace content inside container (default for lists)
triggers.showToast.message, * - outerHTML: Replace entire element (status badges, rows)
triggers.showToast.duration * - beforeend: Append items (infinite scroll)
); * - afterbegin: Prepend items (new items at top)
*
* Target Naming Conventions:
* - #object-type-id: For specific objects (e.g., #park-123)
* - #section-name: For page sections (e.g., #results, #filters)
* - #modal-container: For modals
* - this: For self-replacement
*
* Custom Event Naming:
* - {model}-status-changed: Status updates (park-status-changed)
* - auth-changed: Authentication state changes
* - {model}-created: New item created
* - {model}-updated: Item updated
* - {model}-deleted: Item deleted
*/
document.addEventListener('DOMContentLoaded', function() {
// Configure HTMX defaults
htmx.config.globalViewTransitions = true;
htmx.config.useTemplateFragments = true;
htmx.config.timeout = 30000; // 30 second timeout
htmx.config.historyCacheSize = 10;
htmx.config.refreshOnHistoryMiss = true;
// Add loading states
document.body.addEventListener('htmx:beforeRequest', function(evt) {
evt.target.classList.add('htmx-request');
});
document.body.addEventListener('htmx:afterRequest', function(evt) {
evt.target.classList.remove('htmx-request');
});
// Comprehensive HTMX error handling
document.body.addEventListener('htmx:responseError', function(evt) {
const xhr = evt.detail.xhr;
const showToast = (type, message) => {
if (Alpine && Alpine.store('toast')) {
Alpine.store('toast')[type](message);
}
};
// Handle different HTTP status codes
if (xhr.status >= 500) {
showToast('error', 'Server error. Please try again later.');
console.error('HTMX Server Error:', xhr.status, xhr.statusText);
} else if (xhr.status === 429) {
showToast('warning', 'Too many requests. Please wait a moment.');
} else if (xhr.status === 403) {
showToast('error', 'You do not have permission to perform this action.');
} else if (xhr.status === 401) {
showToast('warning', 'Please log in to continue.');
// Optionally trigger auth modal
document.body.dispatchEvent(new CustomEvent('show-login'));
} else if (xhr.status === 404) {
showToast('error', 'Resource not found.');
} else if (xhr.status === 422) {
showToast('error', 'Validation error. Please check your input.');
} else if (xhr.status === 0) {
showToast('error', 'Network error. Please check your connection.');
} else if (xhr.status >= 400) {
showToast('error', 'Request failed. Please try again.');
}
});
// Handle HTMX timeout
document.body.addEventListener('htmx:timeout', function(evt) {
if (Alpine && Alpine.store('toast')) {
Alpine.store('toast').error('Request timed out. Please try again.');
}
});
// Handle network errors (sendError)
document.body.addEventListener('htmx:sendError', function(evt) {
if (Alpine && Alpine.store('toast')) {
Alpine.store('toast').error('Network error. Please check your connection and try again.');
}
});
});
// Handle HX-Trigger headers for toast notifications
// Expected format: {"showToast": {"type": "success|error|warning|info", "message": "...", "duration": 5000}}
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
const triggerHeader = evt.detail.xhr.getResponseHeader('HX-Trigger');
if (triggerHeader) {
try {
const triggers = JSON.parse(triggerHeader);
if (triggers.showToast && Alpine && Alpine.store('toast')) {
const { type = 'info', message, duration } = triggers.showToast;
Alpine.store('toast')[type](message, duration);
}
} catch (e) {
// Ignore parsing errors for non-JSON triggers (e.g., simple event names)
} }
} catch (e) {
// Ignore parsing errors for non-JSON triggers
} }
} });
});
</script> </script>
<!-- Auth Context for Alpine.js -->
<script>
window.__AUTH_USER__ = {% if user.is_authenticated %}{
id: {{ user.id }},
username: "{{ user.username|escapejs }}",
email: "{{ user.email|escapejs }}",
avatar: "{{ user.profile.avatar.url|default:''|escapejs }}"
}{% else %}null{% endif %};
window.__AUTH_PERMISSIONS__ = [
{% for perm in perms %}
"{{ perm }}",
{% endfor %}
];
</script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -0,0 +1,172 @@
{% comment %}
History Panel Component
=======================
A reusable history panel component for displaying object change history and FSM transitions.
Purpose:
Displays both regular history records and FSM (Finite State Machine) transition
history for parks, rides, and other entities with historical tracking.
Usage Examples:
Basic history:
{% include 'components/history_panel.html' with history=history %}
With FSM toggle (for moderators):
{% include 'components/history_panel.html' with history=history show_fsm_toggle=True fsm_history_url=fsm_url model_type='park' object_id=park.id can_view_fsm=perms.parks.change_park %}
Ride history:
{% include 'components/history_panel.html' with history=history show_fsm_toggle=True fsm_history_url=fsm_url model_type='ride' object_id=ride.id can_view_fsm=perms.rides.change_ride %}
Parameters:
Required:
- history: QuerySet or list of history records
Optional (FSM):
- show_fsm_toggle: Show toggle button for FSM history (default: False)
- fsm_history_url: URL for loading FSM transition history via HTMX
- model_type: Model type for FSM history (e.g., 'park', 'ride')
- object_id: Object ID for FSM history
- can_view_fsm: Whether user can view FSM history (default: False)
Optional (styling):
- title: Panel title (default: 'History')
- panel_class: Additional CSS classes for panel
- max_height: Maximum height for scrollable area (default: 'max-h-96')
- collapsed: Start collapsed (default: False)
Dependencies:
- Tailwind CSS for styling
- Alpine.js for interactivity
- HTMX (optional, for FSM history lazy loading)
- Font Awesome icons
Accessibility:
- Uses heading structure for panel title
- Toggle button has accessible label
- History items use semantic structure
{% endcomment %}
{% with title=title|default:'History' show_fsm_toggle=show_fsm_toggle|default:False can_view_fsm=can_view_fsm|default:False max_height=max_height|default:'max-h-96' collapsed=collapsed|default:False %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 {{ panel_class }}"
x-data="{ showFsmHistory: false {% if collapsed %}, showHistory: false{% endif %} }">
{# Header with optional FSM toggle #}
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{% if collapsed %}
<button type="button"
@click="showHistory = !showHistory"
class="flex items-center gap-2 hover:text-gray-700 dark:hover:text-gray-300">
<i class="fas fa-chevron-right transition-transform"
:class="{ 'rotate-90': showHistory }"
aria-hidden="true"></i>
{{ title }}
</button>
{% else %}
{{ title }}
{% endif %}
</h2>
{% if show_fsm_toggle and can_view_fsm %}
<button type="button"
@click="showFsmHistory = !showFsmHistory"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-expanded="showFsmHistory"
aria-controls="{{ model_type }}-fsm-history-container">
<i class="mr-2 fas fa-history" aria-hidden="true"></i>
<span x-text="showFsmHistory ? 'Hide Transitions' : 'Show Transitions'"></span>
</button>
{% endif %}
</div>
{# Collapsible wrapper #}
<div {% if collapsed %}x-show="showHistory" x-cloak x-transition{% endif %}>
{# FSM Transition History (Moderators Only) #}
{% if show_fsm_toggle and can_view_fsm and fsm_history_url %}
<div x-show="showFsmHistory" x-cloak x-transition class="mb-4">
<div id="{{ model_type }}-fsm-history-container"
x-show="showFsmHistory"
x-init="$watch('showFsmHistory', value => { if(value && !$el.dataset.loaded) { htmx.trigger($el, 'load-history'); $el.dataset.loaded = 'true'; } })"
hx-get="{{ fsm_history_url }}{% if model_type and object_id %}?model_type={{ model_type }}&object_id={{ object_id }}{% endif %}"
hx-trigger="load-history"
hx-target="this"
hx-swap="innerHTML"
hx-indicator="#{{ model_type }}-fsm-loading"
class="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
{# Loading State #}
<div id="{{ model_type }}-fsm-loading" class="htmx-indicator flex items-center justify-center py-4">
<i class="mr-2 text-blue-500 fas fa-spinner fa-spin" aria-hidden="true"></i>
<span class="text-sm text-gray-600 dark:text-gray-400">Loading transitions...</span>
</div>
</div>
</div>
{% endif %}
{# Regular History #}
<div class="space-y-4 overflow-y-auto {{ max_height }}">
{% for record in history %}
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
{# Timestamp and user #}
<div class="mb-1 text-sm text-gray-500 dark:text-gray-400">
{# Support both simple_history and pghistory formats #}
{% if record.history_date %}
{{ record.history_date|date:"M d, Y H:i" }}
{% if record.history_user %}
by {{ record.history_user.username }}
{% endif %}
{% elif record.pgh_created_at %}
{{ record.pgh_created_at|date:"M d, Y H:i" }}
{% if record.pgh_context.user %}
by {{ record.pgh_context.user }}
{% endif %}
{% if record.pgh_label %}
- {{ record.pgh_label }}
{% endif %}
{% endif %}
</div>
{# Changes #}
{% if record.diff_against_previous %}
<div class="mt-2 space-y-2">
{# Support both dictionary and method formats #}
{% if record.get_display_changes %}
{% for field, change in record.get_display_changes.items %}
{% if field != "updated_at" %}
<div class="text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ field }}:</span>
<span class="text-red-600 dark:text-red-400">{{ change.old|default:"—" }}</span>
<span class="mx-1 text-gray-400"></span>
<span class="text-green-600 dark:text-green-400">{{ change.new|default:"—" }}</span>
</div>
{% endif %}
{% endfor %}
{% else %}
{% for field, changes in record.diff_against_previous.items %}
{% if field != "updated_at" %}
<div class="text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ field|title }}:</span>
<span class="text-red-600 dark:text-red-400">{{ changes.old|default:"—" }}</span>
<span class="mx-1 text-gray-400"></span>
<span class="text-green-600 dark:text-green-400">{{ changes.new|default:"—" }}</span>
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
{% endif %}
</div>
{% empty %}
<p class="text-gray-500 dark:text-gray-400 text-center py-4">
<i class="fas fa-history mr-2" aria-hidden="true"></i>
No history available.
</p>
{% endfor %}
</div>
</div>
</div>
{% endwith %}

View File

@@ -0,0 +1,141 @@
{% comment %}
Page Header Component
=====================
Standardized page header with title, subtitle, icon, and action buttons.
Purpose:
Provides consistent page header layout across the application with
responsive design and optional breadcrumb integration.
Usage Examples:
Basic header:
{% include 'components/layout/page_header.html' with title='Parks' %}
With subtitle:
{% include 'components/layout/page_header.html' with title='Cedar Point' subtitle='Sandusky, Ohio' %}
With icon:
{% include 'components/layout/page_header.html' with title='Parks' icon='fas fa-map-marker-alt' %}
With actions:
{% include 'components/layout/page_header.html' with title='Parks' %}
{% block page_header_actions %}
<a href="{% url 'parks:create' %}" class="btn btn-primary">
<i class="fas fa-plus mr-2"></i>Add Park
</a>
{% endblock %}
{% endinclude %}
Full example:
{% include 'components/layout/page_header.html' with
title=park.name
subtitle=park.location
icon='fas fa-building'
show_breadcrumbs=True
badge_text='Active'
badge_variant='success'
%}
Parameters:
Required:
- title: Page title text
Optional:
- subtitle: Subtitle or description
- icon: Icon class (e.g., 'fas fa-home')
- show_breadcrumbs: Include breadcrumbs (default: False)
- badge_text: Status badge text
- badge_variant: 'success', 'warning', 'error', 'info' (default: 'info')
- size: 'sm', 'md', 'lg' for title size (default: 'lg')
- align: 'left', 'center' (default: 'left')
- border: Show bottom border (default: True)
- actions_slot: HTML for action buttons
Blocks:
- page_header_actions: Action buttons area
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons (optional)
- breadcrumbs.html component (if show_breadcrumbs=True)
Accessibility:
- Uses semantic heading element
- Actions have proper button semantics
{% endcomment %}
{% with size=size|default:'lg' align=align|default:'left' border=border|default:True show_breadcrumbs=show_breadcrumbs|default:False %}
<header class="page-header mb-6 {% if border %}pb-6 border-b border-border{% endif %}">
{# Breadcrumbs (optional) #}
{% if show_breadcrumbs and breadcrumbs %}
<div class="mb-4">
{% include 'components/navigation/breadcrumbs.html' %}
</div>
{% endif %}
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between {% if align == 'center' %}sm:justify-center text-center{% endif %}">
{# Title Section #}
<div class="flex-1 min-w-0 {% if align == 'center' %}flex flex-col items-center{% endif %}">
<div class="flex items-center gap-3 {% if align == 'center' %}justify-center{% endif %}">
{# Icon #}
{% if icon %}
<div class="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<i class="{{ icon }} text-primary {% if size == 'sm' %}text-lg{% elif size == 'lg' %}text-xl sm:text-2xl{% else %}text-xl{% endif %}" aria-hidden="true"></i>
</div>
{% endif %}
{# Title and Subtitle #}
<div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="font-bold text-foreground truncate
{% if size == 'sm' %}text-xl sm:text-2xl
{% elif size == 'lg' %}text-2xl sm:text-3xl lg:text-4xl
{% else %}text-2xl sm:text-3xl{% endif %}">
{{ title }}
</h1>
{# Status Badge #}
{% if badge_text %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{% if badge_variant == 'success' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif badge_variant == 'warning' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
{% elif badge_variant == 'error' %}bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
{% else %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300{% endif %}">
{{ badge_text }}
</span>
{% endif %}
</div>
{# Subtitle #}
{% if subtitle %}
<p class="mt-1 text-muted-foreground truncate
{% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-base sm:text-lg{% else %}text-base{% endif %}">
{{ subtitle }}
</p>
{% endif %}
{# Meta info slot #}
{% if meta %}
<div class="mt-2 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
{{ meta }}
</div>
{% endif %}
</div>
</div>
</div>
{# Actions Section #}
{% if actions_slot or block.page_header_actions %}
<div class="flex-shrink-0 flex flex-wrap items-center gap-3 {% if align == 'center' %}justify-center{% else %}sm:justify-end{% endif %}">
{% if actions_slot %}
{{ actions_slot }}
{% endif %}
{% block page_header_actions %}{% endblock %}
</div>
{% endif %}
</div>
</header>
{% endwith %}

View File

@@ -1,5 +1,94 @@
<div id="modal-container" class="modal" role="dialog" aria-modal="true" tabindex="-1"> {% comment %}
<div class="modal-content"> Modal Base Component
{% block modal_content %}{% endblock %} ====================
</div>
</div> A flexible, accessible modal dialog component with Alpine.js integration.
Purpose:
Provides a base modal structure with backdrop, header, body, and footer
sections. Includes keyboard navigation (ESC to close), focus trapping,
and proper ARIA attributes for accessibility.
Usage Examples:
Basic modal:
{% include 'components/modals/modal_base.html' with modal_id='my-modal' title='Modal Title' %}
{% block modal_body %}
<p>Modal content here</p>
{% endblock %}
{% endinclude %}
Modal with footer:
<div x-data="{ showModal: false }">
<button @click="showModal = true">Open Modal</button>
{% include 'components/modals/modal_base.html' with modal_id='confirm-modal' title='Confirm Action' show_var='showModal' %}
{% block modal_body %}
<p>Are you sure?</p>
{% endblock %}
{% block modal_footer %}
<button @click="showModal = false" class="btn-secondary">Cancel</button>
<button @click="confirmAction(); showModal = false" class="btn-primary">Confirm</button>
{% endblock %}
{% endinclude %}
</div>
Different sizes:
{% include 'components/modals/modal_base.html' with modal_id='lg-modal' title='Large Modal' size='lg' %}
Parameters:
Required:
- modal_id: Unique identifier for the modal (used for ARIA and targeting)
Optional:
- title: Modal title text (if empty, header section is hidden)
- size: Size variant 'sm', 'md', 'lg', 'xl', 'full' (default: 'md')
- show_close_button: Show X button in header (default: True)
- show_var: Alpine.js variable name for show/hide state (default: 'show')
- close_on_backdrop: Close when clicking backdrop (default: True)
- close_on_escape: Close when pressing Escape (default: True)
- prevent_scroll: Prevent body scroll when open (default: True)
Blocks:
- modal_header: Custom header content (replaces default header)
- modal_body: Main modal content (required)
- modal_footer: Footer content (optional)
Dependencies:
- Alpine.js for interactivity
- Tailwind CSS for styling
- Font Awesome icons (for close button)
Accessibility:
- Uses dialog role with aria-modal="true"
- Focus is trapped within modal when open
- ESC key closes the modal
- aria-labelledby points to title
- aria-describedby available for body content
{% endcomment %}
{# Default values #}
{% with size=size|default:'md' show_close_button=show_close_button|default:True show_var=show_var|default:'show' close_on_backdrop=close_on_backdrop|default:True close_on_escape=close_on_escape|default:True prevent_scroll=prevent_scroll|default:True %}
{# Size classes mapping #}
{% if size == 'sm' %}
{% with size_class='max-w-sm' %}
{% include 'components/modals/modal_inner.html' %}
{% endwith %}
{% elif size == 'lg' %}
{% with size_class='max-w-2xl' %}
{% include 'components/modals/modal_inner.html' %}
{% endwith %}
{% elif size == 'xl' %}
{% with size_class='max-w-4xl' %}
{% include 'components/modals/modal_inner.html' %}
{% endwith %}
{% elif size == 'full' %}
{% with size_class='max-w-full mx-4' %}
{% include 'components/modals/modal_inner.html' %}
{% endwith %}
{% else %}
{% with size_class='max-w-lg' %}
{% include 'components/modals/modal_inner.html' %}
{% endwith %}
{% endif %}
{% endwith %}

View File

@@ -1,5 +1,184 @@
{% extends "components/modals/modal_base.html" %} {% comment %}
Confirmation Modal Component
============================
{% block modal_content %} Pre-styled confirmation dialog for destructive or important actions.
{% include "htmx/components/confirm_dialog.html" %}
{% endblock %} Purpose:
Provides a standardized confirmation dialog with customizable
title, message, and action buttons.
Usage Examples:
Basic confirmation:
<div x-data="{ showDeleteModal: false }">
<button @click="showDeleteModal = true">Delete</button>
{% include 'components/modals/modal_confirm.html' with
modal_id='delete-confirm'
show_var='showDeleteModal'
title='Delete Park'
message='Are you sure you want to delete this park? This action cannot be undone.'
confirm_text='Delete'
confirm_variant='destructive'
%}
</div>
With icon:
{% include 'components/modals/modal_confirm.html' with
modal_id='publish-confirm'
show_var='showPublishModal'
title='Publish Changes'
message='This will make your changes visible to all users.'
icon='fas fa-globe'
icon_variant='info'
confirm_text='Publish'
%}
With HTMX:
{% include 'components/modals/modal_confirm.html' with
modal_id='archive-confirm'
show_var='showArchiveModal'
title='Archive Item'
message='This will archive the item.'
confirm_hx_post='/api/archive/123/'
%}
Parameters:
Required:
- modal_id: Unique identifier for the modal
- show_var: Alpine.js variable name for show/hide state
- title: Modal title
- message: Confirmation message
Optional:
- icon: Icon class (default: auto based on variant)
- icon_variant: 'destructive', 'warning', 'info', 'success' (default: 'warning')
- confirm_text: Confirm button text (default: 'Confirm')
- confirm_variant: 'destructive', 'primary', 'warning' (default: 'primary')
- cancel_text: Cancel button text (default: 'Cancel')
- confirm_url: URL for confirm action (makes it a link)
- confirm_hx_post: HTMX post URL for confirm action
- confirm_hx_delete: HTMX delete URL for confirm action
- on_confirm: Alpine.js expression to run on confirm
Dependencies:
- modal_base.html component
- Tailwind CSS
- Alpine.js
- HTMX (optional)
{% endcomment %}
{% with icon_variant=icon_variant|default:'warning' confirm_variant=confirm_variant|default:'primary' confirm_text=confirm_text|default:'Confirm' cancel_text=cancel_text|default:'Cancel' %}
{# Determine icon based on variant if not specified #}
{% with default_icon=icon|default:'fas fa-exclamation-triangle' %}
<div id="{{ modal_id }}"
x-show="{{ show_var }}"
x-cloak
@keydown.escape.window="{{ show_var }} = false"
x-init="$watch('{{ show_var }}', value => { document.body.style.overflow = value ? 'hidden' : '' })"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
role="alertdialog"
aria-modal="true"
aria-labelledby="{{ modal_id }}-title"
aria-describedby="{{ modal_id }}-message">
{# Backdrop #}
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"
x-show="{{ show_var }}"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="{{ show_var }} = false"
aria-hidden="true">
</div>
{# Modal Content #}
<div class="relative w-full max-w-md bg-background rounded-xl shadow-2xl overflow-hidden border border-border"
x-show="{{ show_var }}"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.stop>
<div class="p-6">
{# Icon and Title #}
<div class="text-center">
{# Icon #}
<div class="mx-auto mb-4 w-14 h-14 rounded-full flex items-center justify-center
{% if icon_variant == 'destructive' or confirm_variant == 'destructive' %}bg-red-100 dark:bg-red-900/30
{% elif icon_variant == 'success' %}bg-green-100 dark:bg-green-900/30
{% elif icon_variant == 'info' %}bg-blue-100 dark:bg-blue-900/30
{% else %}bg-yellow-100 dark:bg-yellow-900/30{% endif %}">
<i class="{{ default_icon }} text-2xl
{% if icon_variant == 'destructive' or confirm_variant == 'destructive' %}text-red-600 dark:text-red-400
{% elif icon_variant == 'success' %}text-green-600 dark:text-green-400
{% elif icon_variant == 'info' %}text-blue-600 dark:text-blue-400
{% else %}text-yellow-600 dark:text-yellow-400{% endif %}"
aria-hidden="true"></i>
</div>
{# Title #}
<h3 id="{{ modal_id }}-title" class="text-lg font-semibold text-foreground mb-2">
{{ title }}
</h3>
{# Message #}
<p id="{{ modal_id }}-message" class="text-muted-foreground">
{{ message }}
</p>
</div>
{# Actions #}
<div class="mt-6 flex flex-col-reverse sm:flex-row gap-3 sm:justify-center">
{# Cancel button #}
<button type="button"
@click="{{ show_var }} = false"
class="btn btn-outline w-full sm:w-auto">
{{ cancel_text }}
</button>
{# Confirm button #}
{% if confirm_url %}
<a href="{{ confirm_url }}"
class="btn w-full sm:w-auto text-center
{% if confirm_variant == 'destructive' %}btn-destructive
{% elif confirm_variant == 'warning' %}bg-yellow-600 hover:bg-yellow-700 text-white
{% else %}btn-primary{% endif %}">
{{ confirm_text }}
</a>
{% elif confirm_hx_post or confirm_hx_delete %}
<button type="button"
{% if confirm_hx_post %}hx-post="{{ confirm_hx_post }}"{% endif %}
{% if confirm_hx_delete %}hx-delete="{{ confirm_hx_delete }}"{% endif %}
hx-swap="outerHTML"
@htmx:after-request="{{ show_var }} = false"
class="btn w-full sm:w-auto
{% if confirm_variant == 'destructive' %}btn-destructive
{% elif confirm_variant == 'warning' %}bg-yellow-600 hover:bg-yellow-700 text-white
{% else %}btn-primary{% endif %}">
{{ confirm_text }}
</button>
{% else %}
<button type="button"
@click="{% if on_confirm %}{{ on_confirm }};{% endif %} {{ show_var }} = false"
class="btn w-full sm:w-auto
{% if confirm_variant == 'destructive' %}btn-destructive
{% elif confirm_variant == 'warning' %}bg-yellow-600 hover:bg-yellow-700 text-white
{% else %}btn-primary{% endif %}">
{{ confirm_text }}
</button>
{% endif %}
</div>
</div>
</div>
</div>
{% endwith %}
{% endwith %}

View File

@@ -0,0 +1,142 @@
{# Inner modal template - do not use directly, use modal_base.html instead #}
{# Enhanced with animations, focus trap, and loading states #}
{% with animation=animation|default:'scale' loading=loading|default:False %}
<div id="{{ modal_id }}"
x-show="{{ show_var }}"
x-cloak
{% if close_on_escape %}@keydown.escape.window="{{ show_var }} = false"{% endif %}
x-init="
$watch('{{ show_var }}', value => {
{% if prevent_scroll %}document.body.style.overflow = value ? 'hidden' : '';{% endif %}
if (value) {
$nextTick(() => {
const firstFocusable = $el.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');
if (firstFocusable) firstFocusable.focus();
});
}
});
"
@keydown.tab.prevent="
const focusables = $el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');
const first = focusables[0];
const last = focusables[focusables.length - 1];
if ($event.shiftKey && document.activeElement === first) {
last.focus();
} else if (!$event.shiftKey && document.activeElement === last) {
first.focus();
} else {
return true;
}
"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
{% if title %}aria-labelledby="{{ modal_id }}-title"{% endif %}
aria-describedby="{{ modal_id }}-body">
{# Backdrop #}
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"
x-show="{{ show_var }}"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
{% if close_on_backdrop %}@click="{{ show_var }} = false"{% endif %}
aria-hidden="true">
</div>
{# Modal Content #}
<div class="relative w-full {{ size_class }} bg-background rounded-xl shadow-2xl overflow-hidden border border-border"
x-show="{{ show_var }}"
{% if animation == 'slide-up' %}
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-8"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-8"
{% elif animation == 'fade' %}
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
{% else %}
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
{% endif %}
@click.stop>
{# Loading Overlay #}
{% if loading %}
<div x-show="loading"
class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 border-4 border-primary rounded-full animate-spin border-t-transparent"></div>
<span class="text-sm text-muted-foreground">Loading...</span>
</div>
</div>
{% endif %}
{# Header #}
{% if title or show_close_button %}
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
{% block modal_header %}
{% if title %}
<div class="flex items-center gap-3">
{% if icon %}
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<i class="{{ icon }} text-primary" aria-hidden="true"></i>
</div>
{% endif %}
<div>
<h3 id="{{ modal_id }}-title" class="text-lg font-semibold text-foreground">
{{ title }}
</h3>
{% if subtitle %}
<p class="text-sm text-muted-foreground">{{ subtitle }}</p>
{% endif %}
</div>
</div>
{% else %}
<div></div>
{% endif %}
{% endblock modal_header %}
{% if show_close_button %}
<button type="button"
@click="{{ show_var }} = false"
class="p-2 -mr-2 text-muted-foreground hover:text-foreground rounded-lg hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring transition-colors"
aria-label="Close modal">
<i class="fas fa-times text-lg" aria-hidden="true"></i>
</button>
{% endif %}
</div>
{% endif %}
{# Body #}
<div id="{{ modal_id }}-body" class="px-6 py-4 overflow-y-auto max-h-[70vh]">
{% block modal_body %}{% endblock modal_body %}
</div>
{# Footer (optional) #}
{% block modal_footer_wrapper %}
{% if block.modal_footer %}
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-border bg-muted/30">
{% block modal_footer %}{% endblock modal_footer %}
</div>
{% endif %}
{% endblock modal_footer_wrapper %}
</div>
</div>
{% endwith %}

View File

@@ -0,0 +1,133 @@
# Navigation Components
This directory contains navigation-related template components.
## Components
### breadcrumbs.html
Semantic breadcrumb navigation with Schema.org structured data support.
#### Features
- Accessible navigation with proper ARIA attributes
- Schema.org BreadcrumbList JSON-LD for SEO
- Responsive design with mobile-friendly collapse
- Customizable separators and icons
- Truncation for long labels
#### Basic Usage
```django
{# Breadcrumbs are automatically included from context processor #}
{% include 'components/navigation/breadcrumbs.html' %}
```
#### Setting Breadcrumbs in Views
```python
from apps.core.utils.breadcrumbs import build_breadcrumb, BreadcrumbBuilder
from django.urls import reverse
def park_detail(request, slug):
park = get_object_or_404(Park, slug=slug)
# Option 1: Build breadcrumbs manually
request.breadcrumbs = [
build_breadcrumb('Home', '/', icon='fas fa-home'),
build_breadcrumb('Parks', reverse('parks:list')),
build_breadcrumb(park.name, is_current=True),
]
# Option 2: Use the builder pattern
request.breadcrumbs = (
BreadcrumbBuilder()
.add_home()
.add('Parks', reverse('parks:list'))
.add_current(park.name)
.build()
)
return render(request, 'parks/detail.html', {'park': park})
```
#### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `items` | list | `breadcrumbs` | List of Breadcrumb objects |
| `show_schema` | bool | `True` | Include Schema.org JSON-LD |
| `show_home_icon` | bool | `True` | Show icon on home breadcrumb |
| `separator` | str | chevron | Custom separator character |
| `max_visible` | int | `3` | Max items before mobile collapse |
| `container_class` | str | `""` | Additional CSS classes |
#### Accessibility
- Uses `<nav>` element with `aria-label="Breadcrumb"`
- Ordered list (`<ol>`) for semantic structure
- `aria-current="page"` on current page item
- Hidden separators for screen readers
#### Examples
**Custom separator:**
```django
{% include 'components/navigation/breadcrumbs.html' with separator='/' %}
```
**Without Schema.org:**
```django
{% include 'components/navigation/breadcrumbs.html' with show_schema=False %}
```
**Custom breadcrumbs:**
```django
{% include 'components/navigation/breadcrumbs.html' with items=custom_crumbs %}
```
## Breadcrumb Utilities
### BreadcrumbBuilder
Fluent builder for constructing breadcrumbs:
```python
from apps.core.utils.breadcrumbs import BreadcrumbBuilder
breadcrumbs = (
BreadcrumbBuilder()
.add_home()
.add_from_url('parks:list', 'Parks')
.add_model(park)
.add_from_url('rides:list', 'Rides', {'park_slug': park.slug})
.add_model_current(ride)
.build()
)
```
### get_model_breadcrumb
Generate breadcrumbs for model instances with parent relationships:
```python
from apps.core.utils.breadcrumbs import get_model_breadcrumb
# For a Ride that belongs to a Park
breadcrumbs = get_model_breadcrumb(
ride,
parent_attr='park',
list_url_name='rides:list',
list_label='Rides',
)
# Returns: [Home, Parks, Cedar Point, Rides, Millennium Force]
```
## Context Processor
The `breadcrumbs` context processor (`apps.core.context_processors.breadcrumbs`) provides:
- `breadcrumbs`: List of Breadcrumb objects from view
- `breadcrumbs_json`: Schema.org JSON-LD string
- `BreadcrumbBuilder`: Builder class for templates
- `build_breadcrumb`: Helper function for creating items

View File

@@ -0,0 +1,120 @@
{% comment %}
Breadcrumb Navigation Component
===============================
Semantic breadcrumb navigation with Schema.org structured data support.
Purpose:
Renders accessible breadcrumb navigation with proper ARIA attributes,
Schema.org BreadcrumbList markup, and responsive design.
Usage Examples:
Basic usage (breadcrumbs from context processor):
{% include 'components/navigation/breadcrumbs.html' %}
Custom breadcrumbs:
{% include 'components/navigation/breadcrumbs.html' with items=custom_breadcrumbs %}
Without Schema.org markup:
{% include 'components/navigation/breadcrumbs.html' with show_schema=False %}
Custom separator:
{% include 'components/navigation/breadcrumbs.html' with separator='>' %}
Without home icon:
{% include 'components/navigation/breadcrumbs.html' with show_home_icon=False %}
Parameters:
Optional:
- items: List of Breadcrumb objects (default: breadcrumbs from context)
- show_schema: Include Schema.org JSON-LD (default: True)
- show_home_icon: Show icon on home breadcrumb (default: True)
- separator: Separator character/icon (default: chevron icon)
- max_visible: Maximum items to show on mobile before collapsing (default: 3)
- container_class: Additional CSS classes for container
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons (for home icon and separator)
- breadcrumbs context processor for default breadcrumbs
Accessibility:
- Uses <nav> element with aria-label="Breadcrumb"
- Ordered list for semantic structure
- aria-current="page" on current page item
- Hidden separators (aria-hidden) for screen readers
{% endcomment %}
{% with items=items|default:breadcrumbs show_schema=show_schema|default:True show_home_icon=show_home_icon|default:True max_visible=max_visible|default:3 %}
{% if items %}
{# Main Navigation #}
<nav aria-label="Breadcrumb"
class="breadcrumb-nav py-3 {{ container_class }}"
data-breadcrumb>
<ol class="flex flex-wrap items-center gap-1 text-sm" role="list">
{% for crumb in items %}
<li class="flex items-center {% if not forloop.last %}{% if forloop.counter > 1 and forloop.counter < items|length|add:'-1' %}hidden sm:flex{% endif %}{% endif %}">
{# Separator (except for first item) #}
{% if not forloop.first %}
<span class="mx-2 text-muted-foreground/50" aria-hidden="true">
{% if separator %}
{{ separator }}
{% else %}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
{% endif %}
</span>
{% endif %}
{# Breadcrumb Item #}
{% if crumb.is_current %}
{# Current page (not a link) #}
<span class="font-medium text-foreground truncate max-w-[200px] sm:max-w-[300px]"
aria-current="page"
title="{{ crumb.label }}">
{% if crumb.icon %}
<i class="{{ crumb.icon }} mr-1.5" aria-hidden="true"></i>
{% endif %}
{{ crumb.label }}
</span>
{% else %}
{# Clickable breadcrumb #}
<a href="{{ crumb.url }}"
class="text-muted-foreground hover:text-foreground transition-colors truncate max-w-[150px] sm:max-w-[200px] inline-flex items-center"
title="{{ crumb.label }}">
{% if crumb.icon and show_home_icon %}
<i class="{{ crumb.icon }} mr-1.5" aria-hidden="true"></i>
<span class="sr-only sm:not-sr-only">{{ crumb.label }}</span>
{% else %}
{{ crumb.label }}
{% endif %}
</a>
{% endif %}
</li>
{# Mobile ellipsis for long breadcrumb trails #}
{% if forloop.counter == 1 and items|length > max_visible %}
<li class="flex items-center sm:hidden" aria-hidden="true">
<span class="mx-2 text-muted-foreground/50">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</span>
<span class="text-muted-foreground">...</span>
</li>
{% endif %}
{% endfor %}
</ol>
</nav>
{# Schema.org Structured Data #}
{% if show_schema and breadcrumbs_json %}
<script type="application/ld+json">{{ breadcrumbs_json|safe }}</script>
{% endif %}
{% endif %}
{% endwith %}

View File

@@ -1,93 +1,61 @@
{% comment %} {% comment %}
Reusable pagination component with accessibility and responsive design. Pagination Component
Usage: {% include 'components/pagination.html' with page_obj=page_obj %} ====================
A reusable pagination component with accessibility features and HTMX support.
Purpose:
Renders pagination controls for paginated querysets. Supports both
standard page navigation and HTMX-powered dynamic updates.
Usage Examples:
Standard pagination:
{% include 'components/pagination.html' with page_obj=page_obj %}
HTMX-enabled pagination:
{% include 'components/pagination.html' with page_obj=page_obj use_htmx=True hx_target='#results' %}
Custom styling:
{% include 'components/pagination.html' with page_obj=page_obj size='sm' %}
Parameters:
- page_obj: Django Page object from paginator (required)
- use_htmx: Enable HTMX for dynamic updates (optional, default: False)
- hx_target: HTMX target selector (optional, default: '#results')
- hx_swap: HTMX swap strategy (optional, default: 'innerHTML')
- hx_push_url: Whether to push URL to history (optional, default: 'true')
- size: Size variant 'sm', 'md', 'lg' (optional, default: 'md')
- show_info: Show "Showing X to Y of Z" info (optional, default: True)
- base_url: Base URL for pagination (optional, default: request.path)
Dependencies:
- Tailwind CSS for styling
- HTMX (optional, for dynamic pagination)
Accessibility:
- Uses nav element with aria-label="Pagination"
- Current page marked with aria-current="page"
- Previous/Next buttons have aria-labels
- Disabled buttons use aria-disabled
{% endcomment %} {% endcomment %}
{% if page_obj.has_other_pages %} {% 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"> {% with use_htmx=use_htmx|default:False hx_target=hx_target|default:'#results' hx_swap=hx_swap|default:'innerHTML' size=size|default:'md' show_info=show_info|default:True %}
<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"> {# Size-based classes #}
{% if page_obj.has_previous %} {% if size == 'sm' %}
<a {% with btn_padding='px-2 py-1 text-xs' info_class='text-xs' %}
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}" {% include 'components/pagination_inner.html' %}
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" {% endwith %}
aria-label="Go to previous page" {% elif size == 'lg' %}
> {% with btn_padding='px-5 py-3 text-base' info_class='text-base' %}
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> {% include 'components/pagination_inner.html' %}
<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" /> {% endwith %}
</svg> {% else %}
Previous {% with btn_padding='px-4 py-2 text-sm' info_class='text-sm' %}
</a> {% include 'components/pagination_inner.html' %}
{% else %} {% endwith %}
<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"> {% endif %}
<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" /> {% endwith %}
</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 %} {% endif %}

View File

@@ -0,0 +1,156 @@
{# Inner pagination template - do not use directly, use pagination.html instead #}
<nav class="bg-white dark:bg-gray-800 px-4 py-3 flex items-center justify-between border-t border-gray-200 dark:border-gray-700 sm:px-6 rounded-b-lg"
aria-label="Pagination"
role="navigation">
{# Results info - Hidden on mobile #}
{% if show_info %}
<div class="hidden sm:block">
<p class="{{ info_class }} text-gray-700 dark:text-gray-300">
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>
{% endif %}
<div class="flex-1 flex justify-between sm:justify-end gap-2">
{# Previous Button #}
{% if page_obj.has_previous %}
{% if use_htmx %}
<button type="button"
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
hx-target="{{ hx_target }}"
hx-swap="{{ hx_swap }}"
hx-push-url="{{ hx_push_url|default:'true' }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 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" aria-hidden="true">
<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
</button>
{% else %}
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 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" aria-hidden="true">
<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>
{% endif %}
{% else %}
<span class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 cursor-not-allowed"
aria-disabled="true">
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<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 - Hidden on mobile, visible on medium+ screens #}
<div class="hidden md:flex items-center gap-1">
{% for num in page_obj.paginator.page_range %}
{% if num == page_obj.number %}
{# Current page #}
<span class="relative inline-flex items-center {{ btn_padding }} border border-blue-500 bg-blue-50 dark:bg-blue-900/30 font-medium text-blue-600 dark:text-blue-400 rounded-md"
aria-current="page">
{{ num }}
</span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
{# Pages near current #}
{% if use_htmx %}
<button type="button"
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
hx-target="{{ hx_target }}"
hx-swap="{{ hx_swap }}"
hx-push-url="{{ hx_push_url|default:'true' }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
aria-label="Go to page {{ num }}">
{{ num }}
</button>
{% else %}
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
aria-label="Go to page {{ num }}">
{{ num }}
</a>
{% endif %}
{% elif num == 1 or num == page_obj.paginator.num_pages %}
{# First and last page always visible #}
{% if use_htmx %}
<button type="button"
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
hx-target="{{ hx_target }}"
hx-swap="{{ hx_swap }}"
hx-push-url="{{ hx_push_url|default:'true' }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
aria-label="Go to page {{ num }}">
{{ num }}
</button>
{% else %}
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
aria-label="Go to page {{ num }}">
{{ num }}
</a>
{% endif %}
{% elif num == page_obj.number|add:'-4' or num == page_obj.number|add:'4' %}
{# Ellipsis #}
<span class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-500 dark:text-gray-400 rounded-md"
aria-hidden="true">
</span>
{% endif %}
{% endfor %}
</div>
{# Mobile page indicator #}
<div class="flex md:hidden items-center">
<span class="{{ info_class }} text-gray-700 dark:text-gray-300">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
</div>
{# Next Button #}
{% if page_obj.has_next %}
{% if use_htmx %}
<button type="button"
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
hx-target="{{ hx_target }}"
hx-swap="{{ hx_swap }}"
hx-push-url="{{ hx_push_url|default:'true' }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 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" aria-hidden="true">
<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>
</button>
{% else %}
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 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" aria-hidden="true">
<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>
{% endif %}
{% else %}
<span class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 cursor-not-allowed"
aria-disabled="true">
Next
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<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>

View File

@@ -2,6 +2,7 @@
Reusable search form component with filtering capabilities. Reusable search form component with filtering capabilities.
Usage: {% include 'components/search_form.html' with placeholder="Search parks..." filters=filter_options %} Usage: {% include 'components/search_form.html' with placeholder="Search parks..." filters=filter_options %}
{% endcomment %} {% endcomment %}
{% load common_filters %}
<form method="get" class="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6"> <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"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">

View File

@@ -0,0 +1,108 @@
{% comment %}
Card Grid Skeleton Component
============================
Animated skeleton placeholder for card grid layouts while content loads.
Purpose:
Displays pulsing skeleton cards in a grid layout for pages like
parks list, rides list, and search results.
Usage Examples:
Basic card grid:
{% include 'components/skeletons/card_grid_skeleton.html' %}
Custom card count:
{% include 'components/skeletons/card_grid_skeleton.html' with cards=8 %}
Horizontal cards:
{% include 'components/skeletons/card_grid_skeleton.html' with layout='horizontal' %}
Custom columns:
{% include 'components/skeletons/card_grid_skeleton.html' with cols='4' %}
Parameters:
Optional:
- cards: Number of skeleton cards to display (default: 6)
- cols: Grid columns ('2', '3', '4', 'auto') (default: 'auto')
- layout: Card layout ('vertical', 'horizontal') (default: 'vertical')
- show_image: Show image placeholder (default: True)
- show_badge: Show badge placeholder (default: True)
- show_footer: Show footer with stats (default: True)
- image_aspect: Image aspect ratio ('video', 'square', 'portrait') (default: 'video')
- animate: Enable pulse animation (default: True)
Dependencies:
- Tailwind CSS for styling and animation
Accessibility:
- Uses role="status" and aria-busy="true" for screen readers
{% endcomment %}
{% with cards=cards|default:6 cols=cols|default:'auto' layout=layout|default:'vertical' show_image=show_image|default:True show_badge=show_badge|default:True show_footer=show_footer|default:True image_aspect=image_aspect|default:'video' animate=animate|default:True %}
<div class="skeleton-card-grid grid gap-4 sm:gap-6
{% if cols == '2' %}grid-cols-1 sm:grid-cols-2
{% elif cols == '3' %}grid-cols-1 sm:grid-cols-2 lg:grid-cols-3
{% elif cols == '4' %}grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4
{% else %}grid-cols-1 sm:grid-cols-2 lg:grid-cols-3{% endif %}"
role="status"
aria-busy="true"
aria-label="Loading cards...">
{% for i in "123456789012"|slice:cards %}
<div class="skeleton-card bg-card rounded-xl border border-border overflow-hidden
{% if layout == 'horizontal' %}flex flex-row{% else %}flex flex-col{% endif %}">
{# Image placeholder #}
{% if show_image %}
<div class="{% if layout == 'horizontal' %}w-1/3 flex-shrink-0{% else %}w-full{% endif %}">
<div class="{% if image_aspect == 'square' %}aspect-square{% elif image_aspect == 'portrait' %}aspect-[3/4]{% else %}aspect-video{% endif %} bg-muted {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter0 }}50ms;">
</div>
</div>
{% endif %}
{# Content area #}
<div class="flex-1 p-4 space-y-3">
{# Badge placeholder #}
{% if show_badge %}
<div class="flex items-center gap-2">
<div class="h-5 w-16 bg-muted rounded-full {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter0 }}75ms;"></div>
</div>
{% endif %}
{# Title #}
<div class="space-y-2">
<div class="h-5 bg-muted rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 1 3 %}5%; animation-delay: {{ forloop.counter }}00ms;">
</div>
<div class="h-4 w-3/4 bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter }}25ms;">
</div>
</div>
{# Description lines #}
<div class="space-y-2 pt-2">
<div class="h-3 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter }}50ms;"></div>
<div class="h-3 w-5/6 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter }}75ms;"></div>
</div>
{# Footer with stats #}
{% if show_footer %}
<div class="flex items-center justify-between pt-3 mt-auto border-t border-border">
<div class="flex items-center gap-4">
<div class="h-4 w-16 bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-4 w-12 bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
</div>
<div class="h-4 w-20 bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
<span class="sr-only">Loading cards, please wait...</span>
</div>
{% endwith %}

View File

@@ -0,0 +1,118 @@
{% comment %}
Detail Page Skeleton Component
==============================
Animated skeleton placeholder for detail pages while content loads.
Purpose:
Displays pulsing skeleton elements for detail page layouts including
header, image, and content sections.
Usage Examples:
Basic detail skeleton:
{% include 'components/skeletons/detail_skeleton.html' %}
With image placeholder:
{% include 'components/skeletons/detail_skeleton.html' with show_image=True %}
Custom content sections:
{% include 'components/skeletons/detail_skeleton.html' with sections=4 %}
Parameters:
Optional:
- show_image: Show large image placeholder (default: True)
- show_badge: Show status badge placeholder (default: True)
- show_meta: Show metadata row (default: True)
- show_actions: Show action buttons placeholder (default: True)
- sections: Number of content sections (default: 3)
- paragraphs_per_section: Lines per section (default: 4)
- animate: Enable pulse animation (default: True)
Dependencies:
- Tailwind CSS for styling and animation
Accessibility:
- Uses role="status" and aria-busy="true" for screen readers
{% endcomment %}
{% with show_image=show_image|default:True show_badge=show_badge|default:True show_meta=show_meta|default:True show_actions=show_actions|default:True sections=sections|default:3 paragraphs_per_section=paragraphs_per_section|default:4 animate=animate|default:True %}
<div class="skeleton-detail space-y-6"
role="status"
aria-busy="true"
aria-label="Loading page content...">
{# Header Section #}
<div class="skeleton-detail-header space-y-4">
{# Breadcrumb placeholder #}
<div class="flex items-center gap-2">
<div class="h-3 w-12 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-3 w-3 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-3 w-20 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-3 w-3 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-3 w-32 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
</div>
{# Title and badge row #}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="space-y-2">
{# Title #}
<div class="h-8 sm:h-10 w-64 sm:w-80 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
{# Subtitle/location #}
<div class="h-4 w-48 bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 100ms;"></div>
</div>
{% if show_badge %}
{# Status badge #}
<div class="h-7 w-24 bg-muted rounded-full {% if animate %}animate-pulse{% endif %}"></div>
{% endif %}
</div>
{# Meta row (date, author, etc.) #}
{% if show_meta %}
<div class="flex flex-wrap items-center gap-4 text-sm">
<div class="h-4 w-32 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 150ms;"></div>
<div class="h-4 w-24 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 200ms;"></div>
<div class="h-4 w-28 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 250ms;"></div>
</div>
{% endif %}
{# Action buttons #}
{% if show_actions %}
<div class="flex flex-wrap gap-3">
<div class="h-10 w-28 bg-muted rounded-lg {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-10 w-24 bg-muted/80 rounded-lg {% if animate %}animate-pulse{% endif %}" style="animation-delay: 50ms;"></div>
<div class="h-10 w-10 bg-muted/60 rounded-lg {% if animate %}animate-pulse{% endif %}" style="animation-delay: 100ms;"></div>
</div>
{% endif %}
</div>
{# Image Section #}
{% if show_image %}
<div class="skeleton-detail-image">
<div class="w-full aspect-video bg-muted rounded-xl {% if animate %}animate-pulse{% endif %}"></div>
</div>
{% endif %}
{# Content Sections #}
<div class="skeleton-detail-content space-y-8">
{% for s in "1234567890"|slice:sections %}
<div class="space-y-3">
{# Section heading #}
<div class="h-6 w-48 bg-muted rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter0 }}50ms;"></div>
{# Paragraph lines #}
{% for p in "12345678"|slice:paragraphs_per_section %}
<div class="h-4 bg-muted/{% if forloop.last %}50{% else %}70{% endif %} rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% if forloop.last %}65{% else %}{% widthratio forloop.counter0 1 5 %}5{% endif %}%; animation-delay: {{ forloop.counter }}00ms;">
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<span class="sr-only">Loading content, please wait...</span>
</div>
{% endwith %}

View File

@@ -0,0 +1,119 @@
{% comment %}
Form Skeleton Component
=======================
Animated skeleton placeholder for forms while content loads.
Purpose:
Displays pulsing skeleton form elements including labels, inputs,
and action buttons.
Usage Examples:
Basic form skeleton:
{% include 'components/skeletons/form_skeleton.html' %}
Custom field count:
{% include 'components/skeletons/form_skeleton.html' with fields=6 %}
Without textarea:
{% include 'components/skeletons/form_skeleton.html' with show_textarea=False %}
Compact form:
{% include 'components/skeletons/form_skeleton.html' with size='sm' %}
Parameters:
Optional:
- fields: Number of input fields (default: 4)
- show_textarea: Show a textarea field (default: True)
- show_checkbox: Show checkbox fields (default: False)
- show_select: Show select dropdown (default: True)
- checkbox_count: Number of checkboxes (default: 3)
- size: 'sm', 'md', 'lg' for field sizes (default: 'md')
- animate: Enable pulse animation (default: True)
Dependencies:
- Tailwind CSS for styling and animation
Accessibility:
- Uses role="status" and aria-busy="true" for screen readers
{% endcomment %}
{% with fields=fields|default:4 show_textarea=show_textarea|default:True show_checkbox=show_checkbox|default:False show_select=show_select|default:True checkbox_count=checkbox_count|default:3 size=size|default:'md' animate=animate|default:True %}
<div class="skeleton-form space-y-{% if size == 'sm' %}4{% elif size == 'lg' %}8{% else %}6{% endif %}"
role="status"
aria-busy="true"
aria-label="Loading form...">
{# Regular input fields #}
{% for i in "12345678"|slice:fields %}
<div class="skeleton-form-field space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}2{% else %}1.5{% endif %}">
{# Label #}
<div class="{% if size == 'sm' %}h-3 w-20{% elif size == 'lg' %}h-5 w-28{% else %}h-4 w-24{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter0 }}50ms;">
</div>
{# Input #}
<div class="{% if size == 'sm' %}h-8{% elif size == 'lg' %}h-12{% else %}h-10{% endif %} w-full bg-muted/70 rounded-lg border border-muted {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter0 }}75ms;">
</div>
{# Help text (occasionally) #}
{% if forloop.counter|divisibleby:2 %}
<div class="{% if size == 'sm' %}h-2 w-48{% elif size == 'lg' %}h-4 w-64{% else %}h-3 w-56{% endif %} bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter }}00ms;">
</div>
{% endif %}
</div>
{% endfor %}
{# Select dropdown #}
{% if show_select %}
<div class="skeleton-form-field space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}2{% else %}1.5{% endif %}">
<div class="{% if size == 'sm' %}h-3 w-24{% elif size == 'lg' %}h-5 w-32{% else %}h-4 w-28{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="{% if size == 'sm' %}h-8{% elif size == 'lg' %}h-12{% else %}h-10{% endif %} w-full bg-muted/70 rounded-lg border border-muted {% if animate %}animate-pulse{% endif %} relative">
{# Dropdown arrow indicator #}
<div class="absolute right-3 top-1/2 -translate-y-1/2">
<div class="{% if size == 'sm' %}w-3 h-3{% elif size == 'lg' %}w-5 h-5{% else %}w-4 h-4{% endif %} bg-muted/90 rounded"></div>
</div>
</div>
</div>
{% endif %}
{# Textarea #}
{% if show_textarea %}
<div class="skeleton-form-field space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}2{% else %}1.5{% endif %}">
<div class="{% if size == 'sm' %}h-3 w-28{% elif size == 'lg' %}h-5 w-36{% else %}h-4 w-32{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="{% if size == 'sm' %}h-20{% elif size == 'lg' %}h-40{% else %}h-32{% endif %} w-full bg-muted/70 rounded-lg border border-muted {% if animate %}animate-pulse{% endif %}"></div>
<div class="{% if size == 'sm' %}h-2 w-40{% elif size == 'lg' %}h-4 w-56{% else %}h-3 w-48{% endif %} bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
</div>
{% endif %}
{# Checkboxes #}
{% if show_checkbox %}
<div class="skeleton-form-checkboxes space-y-3">
<div class="{% if size == 'sm' %}h-3 w-32{% elif size == 'lg' %}h-5 w-40{% else %}h-4 w-36{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
{% for c in "12345"|slice:checkbox_count %}
<div class="flex items-center gap-3">
<div class="{% if size == 'sm' %}w-4 h-4{% elif size == 'lg' %}w-6 h-6{% else %}w-5 h-5{% endif %} bg-muted/70 rounded border border-muted {% if animate %}animate-pulse{% endif %}"></div>
<div class="{% if size == 'sm' %}h-3{% elif size == 'lg' %}h-5{% else %}h-4{% endif %} bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 1 4 %}0%;">
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# Form actions #}
<div class="skeleton-form-actions flex items-center justify-end gap-3 pt-{% if size == 'sm' %}3{% elif size == 'lg' %}6{% else %}4{% endif %} mt-{% if size == 'sm' %}3{% elif size == 'lg' %}6{% else %}4{% endif %} border-t border-border">
{# Cancel button #}
<div class="{% if size == 'sm' %}h-8 w-20{% elif size == 'lg' %}h-12 w-28{% else %}h-10 w-24{% endif %} bg-muted/60 rounded-lg {% if animate %}animate-pulse{% endif %}"></div>
{# Submit button #}
<div class="{% if size == 'sm' %}h-8 w-24{% elif size == 'lg' %}h-12 w-32{% else %}h-10 w-28{% endif %} bg-muted rounded-lg {% if animate %}animate-pulse{% endif %}"></div>
</div>
<span class="sr-only">Loading form, please wait...</span>
</div>
{% endwith %}

View File

@@ -0,0 +1,85 @@
{% comment %}
List Skeleton Component
=======================
Animated skeleton placeholder for list items while content loads.
Purpose:
Displays pulsing skeleton rows to indicate loading state for list views,
reducing perceived loading time and preventing layout shift.
Usage Examples:
Basic list skeleton:
{% include 'components/skeletons/list_skeleton.html' %}
Custom row count:
{% include 'components/skeletons/list_skeleton.html' with rows=10 %}
With avatar placeholder:
{% include 'components/skeletons/list_skeleton.html' with show_avatar=True %}
Compact variant:
{% include 'components/skeletons/list_skeleton.html' with size='sm' %}
Parameters:
Optional:
- rows: Number of skeleton rows to display (default: 5)
- show_avatar: Show circular avatar placeholder (default: False)
- show_meta: Show metadata line below title (default: True)
- show_action: Show action button placeholder (default: False)
- size: 'sm', 'md', 'lg' for padding/spacing (default: 'md')
- animate: Enable pulse animation (default: True)
Dependencies:
- Tailwind CSS for styling and animation
Accessibility:
- Uses role="status" and aria-busy="true" for screen readers
- aria-label describes loading state
{% endcomment %}
{% with rows=rows|default:5 show_avatar=show_avatar|default:False show_meta=show_meta|default:True show_action=show_action|default:False size=size|default:'md' animate=animate|default:True %}
<div class="skeleton-list space-y-{% if size == 'sm' %}2{% elif size == 'lg' %}6{% else %}4{% endif %}"
role="status"
aria-busy="true"
aria-label="Loading list items...">
{% for i in "12345678901234567890"|slice:rows %}
<div class="skeleton-list-item flex items-center gap-{% if size == 'sm' %}2{% elif size == 'lg' %}4{% else %}3{% endif %} {% if size == 'sm' %}p-2{% elif size == 'lg' %}p-5{% else %}p-4{% endif %} bg-card rounded-lg border border-border">
{# Avatar placeholder #}
{% if show_avatar %}
<div class="flex-shrink-0">
<div class="{% if size == 'sm' %}w-8 h-8{% elif size == 'lg' %}w-14 h-14{% else %}w-10 h-10{% endif %} rounded-full bg-muted {% if animate %}animate-pulse{% endif %}"></div>
</div>
{% endif %}
{# Content area #}
<div class="flex-1 min-w-0 space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}3{% else %}2{% endif %}">
{# Title line #}
<div class="{% if size == 'sm' %}h-3{% elif size == 'lg' %}h-5{% else %}h-4{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 1 7 %}0%;">
</div>
{# Meta line #}
{% if show_meta %}
<div class="{% if size == 'sm' %}h-2{% elif size == 'lg' %}h-4{% else %}h-3{% endif %} bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 1 5 %}0%; animation-delay: {{ forloop.counter0 }}00ms;">
</div>
{% endif %}
</div>
{# Action button placeholder #}
{% if show_action %}
<div class="flex-shrink-0">
<div class="{% if size == 'sm' %}w-16 h-6{% elif size == 'lg' %}w-24 h-10{% else %}w-20 h-8{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
</div>
{% endif %}
</div>
{% endfor %}
<span class="sr-only">Loading content, please wait...</span>
</div>
{% endwith %}

View File

@@ -0,0 +1,137 @@
{% comment %}
Table Skeleton Component
========================
Animated skeleton placeholder for data tables while content loads.
Purpose:
Displays pulsing skeleton table rows for data-heavy pages like
admin dashboards, moderation queues, and data exports.
Usage Examples:
Basic table skeleton:
{% include 'components/skeletons/table_skeleton.html' %}
Custom dimensions:
{% include 'components/skeletons/table_skeleton.html' with rows=10 cols=6 %}
With checkbox column:
{% include 'components/skeletons/table_skeleton.html' with show_checkbox=True %}
With action column:
{% include 'components/skeletons/table_skeleton.html' with show_actions=True %}
Parameters:
Optional:
- rows: Number of table rows (default: 5)
- cols: Number of data columns (default: 4)
- show_header: Show table header row (default: True)
- show_checkbox: Show checkbox column (default: False)
- show_actions: Show actions column (default: True)
- show_avatar: Show avatar in first column (default: False)
- striped: Use striped row styling (default: False)
- animate: Enable pulse animation (default: True)
Dependencies:
- Tailwind CSS for styling and animation
Accessibility:
- Uses role="status" and aria-busy="true" for screen readers
{% endcomment %}
{% with rows=rows|default:5 cols=cols|default:4 show_header=show_header|default:True show_checkbox=show_checkbox|default:False show_actions=show_actions|default:True show_avatar=show_avatar|default:False striped=striped|default:False animate=animate|default:True %}
<div class="skeleton-table overflow-hidden rounded-lg border border-border"
role="status"
aria-busy="true"
aria-label="Loading table data...">
<table class="w-full">
{# Table Header #}
{% if show_header %}
<thead class="bg-muted/30">
<tr>
{# Checkbox header #}
{% if show_checkbox %}
<th class="w-12 p-4">
<div class="w-5 h-5 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
</th>
{% endif %}
{# Data column headers #}
{% for c in "12345678"|slice:cols %}
<th class="p-4 text-left">
<div class="h-4 bg-muted rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 1 4 %}5%; animation-delay: {{ forloop.counter0 }}25ms;">
</div>
</th>
{% endfor %}
{# Actions header #}
{% if show_actions %}
<th class="w-28 p-4 text-right">
<div class="h-4 w-16 bg-muted rounded ml-auto {% if animate %}animate-pulse{% endif %}"></div>
</th>
{% endif %}
</tr>
</thead>
{% endif %}
{# Table Body #}
<tbody class="divide-y divide-border">
{% for r in "12345678901234567890"|slice:rows %}
<tr class="{% if striped and forloop.counter|divisibleby:2 %}bg-muted/10{% endif %}">
{# Checkbox cell #}
{% if show_checkbox %}
<td class="p-4">
<div class="w-5 h-5 bg-muted/70 rounded border border-muted {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter0 }}50ms;">
</div>
</td>
{% endif %}
{# Data cells #}
{% for c in "12345678"|slice:cols %}
<td class="p-4">
{% if forloop.first and show_avatar %}
{# First column with avatar #}
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-muted {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.parentloop.counter0 }}25ms;">
</div>
<div class="space-y-1">
<div class="h-4 w-24 bg-muted rounded {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.parentloop.counter0 }}50ms;">
</div>
<div class="h-3 w-32 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.parentloop.counter0 }}75ms;">
</div>
</div>
</div>
{% else %}
{# Regular data cell #}
<div class="h-4 bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 cols 100 %}%; min-width: 40%; animation-delay: {{ forloop.parentloop.counter0 }}{{ forloop.counter0 }}0ms;">
</div>
{% endif %}
</td>
{% endfor %}
{# Actions cell #}
{% if show_actions %}
<td class="p-4">
<div class="flex items-center justify-end gap-2">
<div class="w-8 h-8 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="w-8 h-8 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 50ms;"></div>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
<span class="sr-only">Loading table data, please wait...</span>
</div>
{% endwith %}

View File

@@ -0,0 +1,87 @@
{% comment %}
Statistics Card Component
=========================
A reusable card component for displaying statistics and metrics.
Purpose:
Renders a consistent statistics card with label, value, optional icon,
and optional link. Used for displaying metrics on detail pages.
Usage Examples:
Basic stat:
{% include 'components/stats_card.html' with label='Total Rides' value=park.ride_count %}
Stat with icon:
{% include 'components/stats_card.html' with label='Rating' value='4.5/5' icon='fas fa-star' %}
Clickable stat:
{% include 'components/stats_card.html' with label='Total Rides' value=42 link=rides_url %}
Priority stat (highlighted):
{% include 'components/stats_card.html' with label='Operator' value=park.operator.name priority=True %}
Stat with subtitle:
{% include 'components/stats_card.html' with label='Height' value='250 ft' subtitle='76 meters' %}
Parameters:
Required:
- label: Stat label/title
- value: Stat value to display
Optional:
- icon: Font Awesome icon class (e.g., 'fas fa-star')
- link: URL to link to (makes card clickable)
- subtitle: Secondary text below value
- priority: Boolean to highlight as priority card (default: False)
- size: Size variant 'sm', 'md', 'lg' (default: 'md')
- value_class: Additional CSS classes for value
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons (optional)
Accessibility:
- Uses semantic dt/dd structure for label/value
- Clickable cards use proper link semantics
- Priority cards use visual emphasis, not just color
{% endcomment %}
{% with priority=priority|default:False size=size|default:'md' %}
{% if link %}
<a href="{{ link }}"
class="block bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats transition-transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-blue-500 {% if priority %}card-stats-priority{% endif %}">
{% else %}
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats {% if priority %}card-stats-priority{% endif %}">
{% endif %}
<div class="text-center">
{# Label #}
<dt class="{% if size == 'sm' %}text-xs{% elif size == 'lg' %}text-base{% else %}text-sm{% endif %} font-semibold text-gray-900 dark:text-white">
{% if icon %}
<i class="{{ icon }} mr-1 text-gray-500 dark:text-gray-400" aria-hidden="true"></i>
{% endif %}
{{ label }}
</dt>
{# Value #}
<dd class="mt-1 {% if size == 'sm' %}text-lg{% elif size == 'lg' %}text-3xl{% else %}text-2xl{% endif %} font-bold text-sky-900 dark:text-sky-400 {{ value_class }}{% if link %} hover:text-sky-800 dark:hover:text-sky-300{% endif %}">
{{ value|default:"N/A" }}
</dd>
{# Subtitle (optional) #}
{% if subtitle %}
<dd class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ subtitle }}
</dd>
{% endif %}
</div>
{% if link %}
</a>
{% else %}
</div>
{% endif %}
{% endwith %}

View File

@@ -1,22 +1,86 @@
{% comment %} {% comment %}
Reusable status badge component with consistent styling. Status Badge Component
Usage: {% include 'components/status_badge.html' with status="OPERATING" %} ======================
Usage (clickable): {% include 'components/status_badge.html' with status="OPERATING" clickable=True %}
A unified, reusable status badge component for parks, rides, and other entities.
Purpose:
Displays a status badge with consistent styling across the application.
Supports both static display and interactive HTMX-powered refresh.
Usage Examples:
Basic badge (uses park_tags for config):
{% include 'components/status_badge.html' with status='OPERATING' %}
Clickable badge:
{% include 'components/status_badge.html' with status='OPERATING' clickable=True %}
Interactive badge with HTMX (for moderators):
{% include 'components/status_badge.html' with status=park.status badge_id='park-header-badge' refresh_url=park_badge_url refresh_trigger='park-status-changed' scroll_target='park-status-section' can_edit=perms.parks.change_park %}
Manual status display (without park_tags config lookup):
{% include 'components/status_badge.html' with status=obj.status status_display=obj.get_status_display manual_mode=True %}
Manual mode with custom classes:
{% include 'components/status_badge.html' with status=obj.status status_display=obj.get_status_display status_classes='bg-blue-100 text-blue-800' manual_mode=True %}
Parameters:
Required:
- status: The status value (e.g., 'OPERATING', 'CLOSED_TEMP')
Optional (auto mode - uses park_tags):
- clickable: Enable click interactions (default: False)
Optional (HTMX mode):
- badge_id: ID for HTMX targeting
- refresh_url: URL for HTMX refresh on trigger
- refresh_trigger: HTMX trigger event name (e.g., 'park-status-changed')
- scroll_target: Element ID to scroll to on click
- can_edit: Whether user can edit/click the badge (default: False)
Optional (manual mode):
- manual_mode: Use status_display instead of park_tags config lookup (default: False)
- status_display: Human-readable status text (used when manual_mode=True)
- status_classes: CSS classes for badge styling (default: 'bg-gray-100 text-gray-800')
Optional (styling):
- size: Size variant 'sm', 'md', 'lg' (default: 'md')
Status Classes (auto mode - defined in park_tags):
- OPERATING: Green (bg-green-100 text-green-800)
- CLOSED_TEMP: Yellow (bg-yellow-100 text-yellow-800)
- CLOSED_PERM: Red (bg-red-100 text-red-800)
- CONSTRUCTION: Orange (bg-orange-100 text-orange-800)
- DEMOLISHED: Gray (bg-gray-100 text-gray-800)
- RELOCATED: Purple (bg-purple-100 text-purple-800)
- SBNO: Amber (bg-amber-100 text-amber-800)
Dependencies:
- park_tags template tags (for get_status_config filter, only needed in auto mode)
- HTMX (optional, for interactive features)
- Font Awesome icons (for dropdown indicator)
Accessibility:
- Uses semantic button or span based on interactivity
- Provides appropriate focus states
- Uses color + text for status indication
{% endcomment %} {% endcomment %}
{% load park_tags %} {% load park_tags %}
{% with status_config=status|get_status_config %} {# Determine sizing classes #}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ status_config.classes }} {% with size=size|default:'md' %}
{% if clickable %}cursor-pointer transition-all hover:ring-2 hover:ring-blue-500{% endif %}"> {% if size == 'sm' %}
{% if status_config.icon %} {% with size_classes='px-2 py-0.5 text-xs' icon_size='h-1.5 w-1.5' %}
<svg class="-ml-0.5 mr-1.5 h-2 w-2" fill="currentColor" viewBox="0 0 8 8"> {% include 'components/status_badge_inner.html' %}
<circle cx="4" cy="4" r="3" /> {% endwith %}
</svg> {% elif size == 'lg' %}
{% endif %} {% with size_classes='px-4 py-1.5 text-sm' icon_size='h-2.5 w-2.5' %}
{{ status_config.label }} {% include 'components/status_badge_inner.html' %}
{% if clickable %} {% endwith %}
<i class="fas fa-chevron-down ml-1.5 text-xs"></i> {% else %}
{% endif %} {% with size_classes='px-2.5 py-0.5 text-xs' icon_size='h-2 w-2' %}
</span> {% include 'components/status_badge_inner.html' %}
{% endwith %}
{% endif %}
{% endwith %} {% endwith %}

View File

@@ -0,0 +1,99 @@
{# Inner status badge template - do not use directly, use status_badge.html instead #}
{# This template expects: status, size_classes, icon_size, and optionally other params #}
{# When manual_mode is true, use provided status_display and default classes #}
{# Otherwise use get_status_config filter from park_tags #}
{# Wrapper with optional HTMX refresh #}
{% if badge_id %}
<span id="{{ badge_id }}"
{% if refresh_url and refresh_trigger %}
hx-get="{{ refresh_url }}"
hx-trigger="{{ refresh_trigger }} from:body"
hx-swap="outerHTML"
{% endif %}>
{% endif %}
{% if manual_mode %}
{# Manual mode: use provided status_display and derive classes from status value #}
{% with badge_label=status_display|default:status badge_classes=status_classes|default:'bg-gray-100 text-gray-800' show_icon=True %}
{% if can_edit and scroll_target %}
<button type="button"
onclick="document.getElementById('{{ scroll_target }}').scrollIntoView({behavior: 'smooth'})"
class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 cursor-pointer {{ badge_classes }}"
aria-label="View status options for {{ badge_label }}">
{% if show_icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ badge_label }}
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
</button>
{% elif clickable %}
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer {{ badge_classes }}"
role="button"
tabindex="0">
{% if show_icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ badge_label }}
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
</span>
{% else %}
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium {{ badge_classes }}">
{% if show_icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ badge_label }}
</span>
{% endif %}
{% endwith %}
{% else %}
{# Auto mode: use get_status_config filter from park_tags #}
{% with status_config=status|get_status_config %}
{% if can_edit and scroll_target %}
<button type="button"
onclick="document.getElementById('{{ scroll_target }}').scrollIntoView({behavior: 'smooth'})"
class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 cursor-pointer {{ status_config.classes }}"
aria-label="View status options for {{ status_config.label }}">
{% if status_config.icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ status_config.label }}
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
</button>
{% elif clickable %}
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer {{ status_config.classes }}"
role="button"
tabindex="0">
{% if status_config.icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ status_config.label }}
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
</span>
{% else %}
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium {{ status_config.classes }}">
{% if status_config.icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ status_config.label }}
</span>
{% endif %}
{% endwith %}
{% endif %}
{% if badge_id %}
</span>
{% endif %}

View File

@@ -0,0 +1,169 @@
{% comment %}
Action Bar Component
====================
Standardized container for action buttons with consistent layout and spacing.
Purpose:
Provides a consistent action button container for page headers, card footers,
and section actions with responsive layout.
Usage Examples:
Basic action bar:
{% include 'components/ui/action_bar.html' %}
{% block actions %}
<a href="{% url 'item:edit' %}" class="btn btn-primary">Edit</a>
{% endblock %}
{% endinclude %}
With primary and secondary actions:
{% include 'components/ui/action_bar.html' with
primary_action_url='/create/'
primary_action_text='Create Park'
primary_action_icon='fas fa-plus'
secondary_action_url='/import/'
secondary_action_text='Import'
%}
Between alignment (cancel left, submit right):
{% include 'components/ui/action_bar.html' with align='between' %}
Multiple actions via slot:
{% include 'components/ui/action_bar.html' %}
{% block actions %}
<button class="btn btn-ghost">Preview</button>
<button class="btn btn-outline">Save Draft</button>
<button class="btn btn-primary">Publish</button>
{% endblock %}
{% endinclude %}
Parameters:
Optional:
- align: 'left', 'right', 'center', 'between' (default: 'right')
- mobile_stack: Stack vertically on mobile (default: True)
- show_border: Show top border (default: False)
- padding: Add padding (default: True)
Primary action:
- primary_action_url: URL for primary button
- primary_action_text: Primary button text
- primary_action_icon: Primary button icon class
- primary_action_class: Primary button CSS class (default: 'btn-primary')
Secondary action:
- secondary_action_url: URL for secondary button
- secondary_action_text: Secondary button text
- secondary_action_icon: Secondary button icon class
- secondary_action_class: Secondary button CSS class (default: 'btn-outline')
Tertiary action:
- tertiary_action_url: URL for tertiary button
- tertiary_action_text: Tertiary button text
- tertiary_action_class: Tertiary button CSS class (default: 'btn-ghost')
Blocks:
- actions: Custom action buttons slot
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons (optional)
- Button component styles from components.css
Accessibility:
- Uses proper button/link semantics
- Focus states for keyboard navigation
{% endcomment %}
{% with align=align|default:'right' mobile_stack=mobile_stack|default:True show_border=show_border|default:False padding=padding|default:True %}
<div class="action-bar flex flex-wrap items-center gap-3
{% if mobile_stack %}flex-col sm:flex-row{% endif %}
{% if padding %}py-4{% endif %}
{% if show_border %}pt-4 border-t border-border{% endif %}
{% if align == 'left' %}justify-start
{% elif align == 'center' %}justify-center
{% elif align == 'between' %}justify-between
{% else %}justify-end{% endif %}">
{# Left side actions (for 'between' alignment) #}
{% if align == 'between' %}
<div class="flex items-center gap-3 {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{# Tertiary action (left side) #}
{% if tertiary_action_url or tertiary_action_text %}
{% if tertiary_action_url %}
<a href="{{ tertiary_action_url }}"
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ tertiary_action_text }}
</a>
{% else %}
<button type="button"
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}"
onclick="history.back()">
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ tertiary_action_text|default:'Cancel' }}
</button>
{% endif %}
{% endif %}
</div>
{% endif %}
{# Main actions group #}
<div class="flex items-center gap-3 {% if mobile_stack %}w-full sm:w-auto {% if align == 'between' %}justify-end{% endif %}{% endif %}">
{# Custom actions slot #}
{% block actions %}{% endblock %}
{# Tertiary action (non-between alignment) #}
{% if tertiary_action_text and align != 'between' %}
{% if tertiary_action_url %}
<a href="{{ tertiary_action_url }}"
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ tertiary_action_text }}
</a>
{% else %}
<button type="button"
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ tertiary_action_text }}
</button>
{% endif %}
{% endif %}
{# Secondary action #}
{% if secondary_action_text %}
{% if secondary_action_url %}
<a href="{{ secondary_action_url }}"
class="{{ secondary_action_class|default:'btn btn-outline' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if secondary_action_icon %}<i class="{{ secondary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ secondary_action_text }}
</a>
{% else %}
<button type="button"
class="{{ secondary_action_class|default:'btn btn-outline' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if secondary_action_icon %}<i class="{{ secondary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ secondary_action_text }}
</button>
{% endif %}
{% endif %}
{# Primary action #}
{% if primary_action_text %}
{% if primary_action_url %}
<a href="{{ primary_action_url }}"
class="{{ primary_action_class|default:'btn btn-primary' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if primary_action_icon %}<i class="{{ primary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ primary_action_text }}
</a>
{% else %}
<button type="submit"
class="{{ primary_action_class|default:'btn btn-primary' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if primary_action_icon %}<i class="{{ primary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ primary_action_text }}
</button>
{% endif %}
{% endif %}
</div>
</div>
{% endwith %}

View File

@@ -1,63 +1,155 @@
{% comment %} {% comment %}
Button Component - Django Template Version of shadcn/ui Button Button Component - Unified Django Template Version of shadcn/ui Button
Usage: {% include 'components/ui/button.html' with variant='default' size='default' text='Click me' %}
A versatile button component that supports multiple variants, sizes, icons, and both
button/link elements. Compatible with HTMX and Alpine.js.
Usage Examples:
Basic button:
{% include 'components/ui/button.html' with text='Click me' %}
With variant and size:
{% include 'components/ui/button.html' with text='Submit' variant='default' size='lg' %}
Link button:
{% include 'components/ui/button.html' with href='/path' text='Go' type='link' %}
With HTMX:
{% include 'components/ui/button.html' with text='Load' hx_get='/api/data' hx_target='#target' %}
With Alpine.js:
{% include 'components/ui/button.html' with text='Toggle' x_on_click='open = !open' %}
With SVG icon (preferred):
{% include 'components/ui/button.html' with icon=search_icon_svg text='Search' %}
Icon-only button:
{% include 'components/ui/button.html' with icon=icon_svg size='icon' aria_label='Close' %}
Parameters:
- variant: 'default', 'destructive', 'outline', 'secondary', 'ghost', 'link' (default: 'default')
- size: 'default', 'sm', 'lg', 'icon' (default: 'default')
- type: 'button', 'submit', 'reset', 'link' (default: 'button')
- text: Button text content
- label: Alias for text (for backwards compatibility)
- content: Alias for text (for backwards compatibility)
- href: URL for link buttons (required when type='link')
- icon: SVG icon content (will be sanitized)
- icon_left: Font Awesome class for left icon (deprecated, prefer icon)
- icon_right: Font Awesome class for right icon (deprecated)
- disabled: Boolean to disable the button
- class: Additional CSS classes
- id: Element ID
- aria_label: Accessibility label (required for icon-only buttons)
- onclick: JavaScript click handler
- hx_get, hx_post, hx_target, hx_swap, hx_trigger, hx_indicator, hx_include: HTMX attributes
- x_data, x_on_click, x_bind, x_show: Alpine.js attributes
- attrs: Additional HTML attributes as string
Security: Icon SVGs are sanitized using the sanitize_svg filter to prevent XSS attacks.
{% endcomment %} {% endcomment %}
{% load static %} {% load static safe_html %}
{% with variant=variant|default:'default' size=size|default:'default' %} {% with variant=variant|default:'default' size=size|default:'default' btn_type=type|default:'button' btn_text=text|default:label|default:content %}
{% if btn_type == 'link' or href %}
{# Link element styled as button #}
<a
href="{{ href|default:'#' }}"
{% if id %}id="{{ id }}"{% endif %}
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
{% if variant == 'destructive' %}bg-destructive text-destructive-foreground hover:bg-destructive/90
{% elif variant == 'outline' %}border border-input bg-background hover:bg-accent hover:text-accent-foreground
{% elif variant == 'secondary' %}bg-secondary text-secondary-foreground hover:bg-secondary/80
{% elif variant == 'ghost' %}hover:bg-accent hover:text-accent-foreground
{% elif variant == 'link' %}text-primary underline-offset-4 hover:underline
{% else %}bg-primary text-primary-foreground hover:bg-primary/90{% endif %}
{% if size == 'sm' %}h-9 rounded-md px-3
{% elif size == 'lg' %}h-11 rounded-md px-8
{% elif size == 'icon' %}h-10 w-10
{% else %}h-10 px-4 py-2{% endif %}
{{ class|default:'' }}"
{% if disabled %}aria-disabled="true" tabindex="-1"{% endif %}
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
{% if x_data %}x-data="{{ x_data }}"{% endif %}
{% if x_on_click %}@click="{{ x_on_click }}"{% endif %}
{% if x_bind %}x-bind="{{ x_bind }}"{% endif %}
{% if x_show %}x-show="{{ x_show }}"{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
{% if hx_indicator %}hx-indicator="{{ hx_indicator }}"{% endif %}
{{ attrs|default:'' }}>
{% if icon %}
<span class="w-4 h-4 flex items-center justify-center">{{ icon|sanitize_svg }}</span>
{% if btn_text %}<span>{{ btn_text }}</span>{% endif %}
{% elif icon_left %}
<i class="{{ icon_left }} w-4 h-4" aria-hidden="true"></i>
{% if btn_text %}{{ btn_text }}{% endif %}
{% else %}
{{ btn_text }}
{% endif %}
{% if icon_right %}
<i class="{{ icon_right }} w-4 h-4" aria-hidden="true"></i>
{% endif %}
{% block link_content %}{% endblock %}
</a>
{% else %}
{# Button element #}
<button <button
class=" type="{{ btn_type }}"
inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium {% if id %}id="{{ id }}"{% endif %}
ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 {% if variant == 'destructive' %}bg-destructive text-destructive-foreground hover:bg-destructive/90
{% if variant == 'default' %} {% elif variant == 'outline' %}border border-input bg-background hover:bg-accent hover:text-accent-foreground
bg-primary text-primary-foreground hover:bg-primary/90 {% elif variant == 'secondary' %}bg-secondary text-secondary-foreground hover:bg-secondary/80
{% elif variant == 'destructive' %} {% elif variant == 'ghost' %}hover:bg-accent hover:text-accent-foreground
bg-destructive text-destructive-foreground hover:bg-destructive/90 {% elif variant == 'link' %}text-primary underline-offset-4 hover:underline
{% elif variant == 'outline' %} {% else %}bg-primary text-primary-foreground hover:bg-primary/90{% endif %}
border border-input bg-background hover:bg-accent hover:text-accent-foreground {% if size == 'sm' %}h-9 rounded-md px-3
{% elif variant == 'secondary' %} {% elif size == 'lg' %}h-11 rounded-md px-8
bg-secondary text-secondary-foreground hover:bg-secondary/80 {% elif size == 'icon' %}h-10 w-10
{% elif variant == 'ghost' %} {% else %}h-10 px-4 py-2{% endif %}
hover:bg-accent hover:text-accent-foreground {{ class|default:'' }}"
{% elif variant == 'link' %} {% if disabled %}disabled{% endif %}
text-primary underline-offset-4 hover:underline {% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
{% endif %} {% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if size == 'default' %} {% if x_data %}x-data="{{ x_data }}"{% endif %}
h-10 px-4 py-2 {% if x_on_click %}@click="{{ x_on_click }}"{% endif %}
{% elif size == 'sm' %} {% if x_bind %}x-bind="{{ x_bind }}"{% endif %}
h-9 rounded-md px-3 {% if x_show %}x-show="{{ x_show }}"{% endif %}
{% elif size == 'lg' %} {% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
h-11 rounded-md px-8 {% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% elif size == 'icon' %} {% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
h-10 w-10 {% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% endif %} {% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
{{ class|default:'' }} {% if hx_indicator %}hx-indicator="{{ hx_indicator }}"{% endif %}
" {% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
{% if type %}type="{{ type }}"{% endif %} {{ attrs|default:'' }}>
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if x_data %}x-data="{{ x_data }}"{% endif %}
{% if x_on %}{{ x_on }}{% endif %}
{% if disabled %}disabled{% endif %}
{{ attrs|default:'' }}
>
{% if icon_left %}
<i class="{{ icon_left }} w-4 h-4"></i>
{% endif %}
{% if text %} {% if icon %}
{{ text }} <span class="w-4 h-4 flex items-center justify-center">{{ icon|sanitize_svg }}</span>
{% else %} {% if btn_text %}<span>{{ btn_text }}</span>{% endif %}
{{ content|default:'' }} {% elif icon_left %}
{% endif %} <i class="{{ icon_left }} w-4 h-4" aria-hidden="true"></i>
{% if btn_text %}{{ btn_text }}{% endif %}
{% else %}
{{ btn_text }}
{% endif %}
{% if icon_right %} {% if icon_right %}
<i class="{{ icon_right }} w-4 h-4"></i> <i class="{{ icon_right }} w-4 h-4" aria-hidden="true"></i>
{% endif %} {% endif %}
{% block button_content %}{% endblock %}
</button> </button>
{% endif %}
{% endwith %} {% endwith %}

View File

@@ -1,40 +1,92 @@
{% comment %} {% comment %}
Card Component - Django Template Version of shadcn/ui Card Card Component - Unified Django Template Version of shadcn/ui Card
Usage: {% include 'components/ui/card.html' with title='Card Title' content='Card content' %}
Security: All content variables are sanitized to prevent XSS attacks. A flexible card container with optional header, content, and footer sections.
Uses design tokens for consistent styling.
Usage Examples:
Basic card with title:
{% include 'components/ui/card.html' with title='Card Title' content='Card content here' %}
Card with all sections:
{% include 'components/ui/card.html' with title='Title' description='Subtitle' body_content='<p>Content</p>' footer_content='<button>Action</button>' %}
Card with custom header:
{% include 'components/ui/card.html' with header_content='<div>Custom header</div>' content='Content' %}
Card with block content (for more complex layouts):
{% include 'components/ui/card.html' with title='Title' %}
{% block card_content %}
Complex content here
{% endblock %}
Parameters:
- title: Card title text
- description: Card subtitle/description text
- header_content: HTML content for the header area (sanitized)
- content: Main content (sanitized)
- body_content: Alias for content (sanitized)
- footer_content: Footer content (sanitized)
- footer: Alias for footer_content (sanitized)
- header: Alias for header_content (sanitized)
- class: Additional CSS classes for the card container
- id: Element ID
Security: All content variables are sanitized using the sanitize filter to prevent XSS attacks.
Only trusted HTML elements and attributes are allowed.
{% endcomment %} {% endcomment %}
{% load safe_html %} {% load safe_html %}
<div class="rounded-lg border bg-card text-card-foreground shadow-sm {{ class|default:'' }}"> <div
{% if title or header_content %} {% if id %}id="{{ id }}"{% endif %}
<div class="flex flex-col space-y-1.5 p-6"> class="rounded-lg border bg-card text-card-foreground shadow-sm {{ class|default:'' }}">
{% if title %}
<h3 class="text-2xl font-semibold leading-none tracking-tight">{{ title }}</h3>
{% endif %}
{% if description %}
<p class="text-sm text-muted-foreground">{{ description }}</p>
{% endif %}
{% if header_content %}
{{ header_content|sanitize }}
{% endif %}
</div>
{% endif %}
{% if content or body_content %} {# Header Section #}
<div class="p-6 pt-0"> {% if title or description or header_content or header %}
{% if content %} <div class="flex flex-col space-y-1.5 p-6">
{{ content|sanitize }} {% if title %}
{% endif %} <h3 class="text-2xl font-semibold leading-none tracking-tight">{{ title }}</h3>
{% if body_content %} {% endif %}
{{ body_content|sanitize }}
{% endif %}
</div>
{% endif %}
{% if footer_content %} {% if description %}
<div class="flex items-center p-6 pt-0"> <p class="text-sm text-muted-foreground">{{ description }}</p>
{{ footer_content|sanitize }} {% endif %}
</div>
{% endif %} {% if header_content %}
{{ header_content|sanitize }}
{% elif header %}
{{ header|sanitize }}
{% endif %}
</div>
{% endif %}
{# Content Section #}
{% if content or body_content %}
<div class="p-6 pt-0">
{% if content %}
{{ content|sanitize }}
{% endif %}
{% if body_content %}
{{ body_content|sanitize }}
{% endif %}
{% block card_content %}{% endblock %}
</div>
{% else %}
{# Allow block content even without content parameter #}
<div class="p-6 pt-0">
{% block card_content_fallback %}{% endblock %}
</div>
{% endif %}
{# Footer Section #}
{% if footer_content or footer %}
<div class="flex items-center p-6 pt-0">
{% if footer_content %}
{{ footer_content|sanitize }}
{% elif footer %}
{{ footer|sanitize }}
{% endif %}
</div>
{% endif %}
</div> </div>

View File

@@ -0,0 +1,140 @@
{% comment %}
Dialog/Modal Component - Unified Django Template
A flexible dialog/modal component that supports both HTMX-triggered and Alpine.js-controlled modals.
Includes proper accessibility attributes (ARIA) and keyboard navigation support.
Usage Examples:
Alpine.js controlled modal:
{% include 'components/ui/dialog.html' with title='Confirm Action' content='Are you sure?' id='confirm-modal' %}
<button @click="$store.ui.openModal('confirm-modal')">Open Modal</button>
HTMX triggered modal (loads content dynamically):
<button hx-get="/modal/content" hx-target="#modal-container">Load Modal</button>
<div id="modal-container">
{% include 'components/ui/dialog.html' with title='Dynamic Content' %}
</div>
With footer actions:
{% include 'components/ui/dialog.html' with title='Delete Item' description='This cannot be undone.' footer='<button class="btn">Cancel</button><button class="btn btn-destructive">Delete</button>' %}
Parameters:
- id: Modal ID (used for Alpine.js state management)
- title: Dialog title
- description: Dialog subtitle/description
- content: Main content (sanitized)
- footer: Footer content with actions (sanitized)
- open: Boolean to control initial open state (default: true for HTMX-loaded content)
- closable: Boolean to allow closing (default: true)
- size: 'sm', 'default', 'lg', 'xl', 'full' (default: 'default')
- class: Additional CSS classes for the dialog panel
Accessibility:
- role="dialog" and aria-modal="true" for screen readers
- Focus trap within modal when open
- Escape key closes the modal
- Click outside closes the modal (backdrop click)
Security: Content and footer are sanitized to prevent XSS attacks.
{% endcomment %}
{% load safe_html %}
{% with modal_id=id|default:'dialog' is_open=open|default:True %}
<div class="fixed inset-0 z-50 flex items-start justify-center sm:items-center"
role="dialog"
aria-modal="true"
{% if title %}aria-labelledby="{{ modal_id }}-title"{% endif %}
{% if description %}aria-describedby="{{ modal_id }}-description"{% endif %}
x-data="{ open: {{ is_open|yesno:'true,false' }} }"
x-show="open"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@keydown.escape.window="open = false">
{# Backdrop #}
<div class="fixed inset-0 transition-all bg-black/50 backdrop-blur-sm"
x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
{% if closable|default:True %}
@click="open = false; setTimeout(() => { if ($el.closest('[hx-history-elt]')) $el.closest('[hx-history-elt]').innerHTML = ''; }, 200)"
{% endif %}
aria-hidden="true"></div>
{# Dialog Panel #}
<div class="fixed z-50 grid w-full gap-4 p-6 duration-200 border shadow-lg bg-background sm:rounded-lg
{% if size == 'sm' %}sm:max-w-sm
{% elif size == 'lg' %}sm:max-w-2xl
{% elif size == 'xl' %}sm:max-w-4xl
{% elif size == 'full' %}sm:max-w-[90vw] sm:max-h-[90vh]
{% else %}sm:max-w-lg{% endif %}
{{ class|default:'' }}"
x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.stop>
{# Header #}
{% if title or description %}
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
{% if title %}
<h2 id="{{ modal_id }}-title" class="text-lg font-semibold leading-none tracking-tight">
{{ title }}
</h2>
{% endif %}
{% if description %}
<p id="{{ modal_id }}-description" class="text-sm text-muted-foreground">
{{ description }}
</p>
{% endif %}
</div>
{% endif %}
{# Content #}
<div class="py-4">
{% if content %}
{{ content|sanitize }}
{% endif %}
{% block dialog_content %}{% endblock %}
</div>
{# Footer #}
{% if footer %}
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
{{ footer|sanitize }}
</div>
{% endif %}
{% block dialog_footer %}{% endblock %}
{# Close Button #}
{% if closable|default:True %}
<button type="button"
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
@click="open = false; setTimeout(() => { if ($el.closest('[hx-history-elt]')) $el.closest('[hx-history-elt]').innerHTML = ''; }, 200)"
aria-label="Close">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
<span class="sr-only">Close</span>
</button>
{% endif %}
</div>
</div>
{% endwith %}

View File

@@ -0,0 +1,227 @@
{% comment %}
Icon Component - SVG Icon Wrapper
A component for rendering SVG icons consistently. Provides a library of common icons
and supports custom SVG content. Replaces Font Awesome with inline SVGs for better
customization and smaller bundle size.
Usage Examples:
Named icon:
{% include 'components/ui/icon.html' with name='search' %}
With size:
{% include 'components/ui/icon.html' with name='menu' size='lg' %}
With custom class:
{% include 'components/ui/icon.html' with name='user' class='text-primary' %}
Custom SVG content:
{% include 'components/ui/icon.html' with svg='<path d="..."/>' %}
Parameters:
- name: Icon name from the built-in library
- size: 'xs', 'sm', 'md', 'lg', 'xl' (default: 'md')
- class: Additional CSS classes
- svg: Custom SVG path content (for icons not in the library)
- stroke_width: SVG stroke width (default: 2)
- aria_label: Accessibility label (required for meaningful icons)
- aria_hidden: Set to 'false' for meaningful icons (default: 'true' for decorative)
Available Icons:
Navigation: menu, close, chevron-down, chevron-up, chevron-left, chevron-right,
arrow-left, arrow-right, arrow-up, arrow-down, external-link
Actions: search, plus, minus, edit, trash, download, upload, copy, share, refresh
User: user, users, settings, logout, login
Status: check, x, info, warning, error, question
Media: image, video, music, file, folder
Communication: mail, phone, message, bell, send
Social: heart, star, bookmark, thumbs-up, thumbs-down
Misc: home, calendar, clock, map-pin, globe, sun, moon, eye, eye-off, lock, unlock
{% endcomment %}
{% with icon_size=size|default:'md' %}
<svg
class="icon icon-{{ name }}
{% if icon_size == 'xs' %}w-3 h-3
{% elif icon_size == 'sm' %}w-4 h-4
{% elif icon_size == 'lg' %}w-6 h-6
{% elif icon_size == 'xl' %}w-8 h-8
{% else %}w-5 h-5{% endif %}
{{ class|default:'' }}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="{{ stroke_width|default:'2' }}"
stroke-linecap="round"
stroke-linejoin="round"
{% if aria_label %}aria-label="{{ aria_label }}" role="img"{% else %}aria-hidden="{{ aria_hidden|default:'true' }}"{% endif %}>
{% if svg %}
{{ svg|safe }}
{% elif name == 'search' %}
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
{% elif name == 'menu' %}
<path d="M4 6h16M4 12h16M4 18h16"/>
{% elif name == 'close' or name == 'x' %}
<path d="M6 18L18 6M6 6l12 12"/>
{% elif name == 'chevron-down' %}
<path d="M19 9l-7 7-7-7"/>
{% elif name == 'chevron-up' %}
<path d="M5 15l7-7 7 7"/>
{% elif name == 'chevron-left' %}
<path d="M15 19l-7-7 7-7"/>
{% elif name == 'chevron-right' %}
<path d="M9 5l7 7-7 7"/>
{% elif name == 'arrow-left' %}
<path d="M19 12H5M12 19l-7-7 7-7"/>
{% elif name == 'arrow-right' %}
<path d="M5 12h14M12 5l7 7-7 7"/>
{% elif name == 'arrow-up' %}
<path d="M12 19V5M5 12l7-7 7 7"/>
{% elif name == 'arrow-down' %}
<path d="M12 5v14M19 12l-7 7-7-7"/>
{% elif name == 'external-link' %}
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"/>
{% elif name == 'plus' %}
<path d="M12 5v14M5 12h14"/>
{% elif name == 'minus' %}
<path d="M5 12h14"/>
{% elif name == 'edit' or name == 'pencil' %}
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
{% elif name == 'trash' or name == 'delete' %}
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
<path d="M10 11v6M14 11v6"/>
{% elif name == 'download' %}
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
{% elif name == 'upload' %}
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
{% elif name == 'copy' %}
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
{% elif name == 'share' %}
<circle cx="18" cy="5" r="3"/>
<circle cx="6" cy="12" r="3"/>
<circle cx="18" cy="19" r="3"/>
<path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/>
{% elif name == 'refresh' %}
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
{% elif name == 'user' %}
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
{% elif name == 'users' %}
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
{% elif name == 'settings' or name == 'cog' %}
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
{% elif name == 'logout' or name == 'sign-out' %}
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
{% elif name == 'login' or name == 'sign-in' %}
<path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3"/>
{% elif name == 'check' %}
<path d="M20 6L9 17l-5-5"/>
{% elif name == 'info' %}
<circle cx="12" cy="12" r="10"/>
<path d="M12 16v-4M12 8h.01"/>
{% elif name == 'warning' or name == 'alert-triangle' %}
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0zM12 9v4M12 17h.01"/>
{% elif name == 'error' or name == 'alert-circle' %}
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4M12 16h.01"/>
{% elif name == 'question' or name == 'help-circle' %}
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01"/>
{% elif name == 'image' %}
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
{% elif name == 'file' %}
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/>
<path d="M13 2v7h7"/>
{% elif name == 'folder' %}
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
{% elif name == 'mail' or name == 'email' %}
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<path d="M22 6l-10 7L2 6"/>
{% elif name == 'phone' %}
<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 16.92z"/>
{% elif name == 'message' or name == 'chat' %}
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
{% elif name == 'bell' %}
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0"/>
{% elif name == 'send' %}
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
{% elif name == 'heart' %}
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/>
{% elif name == 'star' %}
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
{% elif name == 'bookmark' %}
<path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
{% elif name == 'thumbs-up' %}
<path d="M14 9V5a3 3 0 00-3-3l-4 9v11h11.28a2 2 0 002-1.7l1.38-9a2 2 0 00-2-2.3zM7 22H4a2 2 0 01-2-2v-7a2 2 0 012-2h3"/>
{% elif name == 'thumbs-down' %}
<path d="M10 15v4a3 3 0 003 3l4-9V2H5.72a2 2 0 00-2 1.7l-1.38 9a2 2 0 002 2.3zm7-13h2.67A2.31 2.31 0 0122 4v7a2.31 2.31 0 01-2.33 2H17"/>
{% elif name == 'home' %}
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
<path d="M9 22V12h6v10"/>
{% elif name == 'calendar' %}
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<path d="M16 2v4M8 2v4M3 10h18"/>
{% elif name == 'clock' %}
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
{% elif name == 'map-pin' or name == 'location' %}
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/>
<circle cx="12" cy="10" r="3"/>
{% elif name == 'globe' %}
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>
{% elif name == 'sun' %}
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
{% elif name == 'moon' %}
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
{% elif name == 'eye' %}
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
{% elif name == 'eye-off' %}
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24M1 1l22 22"/>
{% elif name == 'lock' %}
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0110 0v4"/>
{% elif name == 'unlock' %}
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 019.9-1"/>
{% elif name == 'filter' %}
<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/>
{% elif name == 'sort' %}
<path d="M3 6h18M6 12h12M9 18h6"/>
{% elif name == 'grid' %}
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
{% elif name == 'list' %}
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
{% elif name == 'more-horizontal' or name == 'dots' %}
<circle cx="12" cy="12" r="1"/>
<circle cx="19" cy="12" r="1"/>
<circle cx="5" cy="12" r="1"/>
{% elif name == 'more-vertical' %}
<circle cx="12" cy="12" r="1"/>
<circle cx="12" cy="5" r="1"/>
<circle cx="12" cy="19" r="1"/>
{% elif name == 'loader' or name == 'spinner' %}
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
{% else %}
{# Default: question mark icon for unknown names #}
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01"/>
{% endif %}
</svg>
{% endwith %}

View File

@@ -1,26 +1,162 @@
{% comment %} {% comment %}
Input Component - Django Template Version of shadcn/ui Input Input Component - Unified Django Template Version of shadcn/ui Input
Usage: {% include 'components/ui/input.html' with type='text' placeholder='Enter text...' name='field_name' %}
A versatile input component that supports both Django form fields and standalone inputs.
Compatible with HTMX and Alpine.js for dynamic behavior.
Usage Examples:
Standalone input:
{% include 'components/ui/input.html' with type='text' name='email' placeholder='Enter email' %}
With Django form field:
{% include 'components/ui/input.html' with field=form.email label='Email Address' %}
With HTMX validation:
{% include 'components/ui/input.html' with name='username' hx_post='/validate' hx_trigger='blur' %}
With Alpine.js binding:
{% include 'components/ui/input.html' with name='search' x_model='query' %}
Textarea mode:
{% include 'components/ui/input.html' with type='textarea' name='message' rows='4' %}
Parameters:
Standalone Mode:
- type: Input type (text, email, password, number, etc.) or 'textarea' (default: 'text')
- name: Input name attribute
- id: Input ID (auto-generated from name if not provided)
- placeholder: Placeholder text
- value: Initial value
- label: Label text
- help_text: Help text displayed below the input
- error: Error message to display
- required: Boolean for required field
- disabled: Boolean to disable the input
- readonly: Boolean for readonly input
- autocomplete: Autocomplete attribute value
- rows: Number of rows for textarea
- class: Additional CSS classes for the input
Django Form Field Mode:
- field: Django form field object
- label: Override field label
- placeholder: Override field placeholder
- help_text: Override field help text
HTMX Attributes:
- hx_get, hx_post, hx_target, hx_swap, hx_trigger, hx_include: HTMX attributes
Alpine.js Attributes:
- x_model: Two-way binding
- x_on: Event handlers (as string, e.g., "@input=...")
- x_data: Alpine data
Other:
- attrs: Additional HTML attributes as string
- aria_describedby: ID of element describing this input
- aria_invalid: Boolean for invalid state
{% endcomment %} {% endcomment %}
<input {% load widget_tweaks %}
type="{{ type|default:'text' }}"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {{ class|default:'' }}" {% if field %}
{% if name %}name="{{ name }}"{% endif %} {# Django Form Field Mode #}
{% if id %}id="{{ id }}"{% endif %} <div class="space-y-2">
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %} {% if label or field.label %}
{% if value %}value="{{ value }}"{% endif %} <label for="{{ field.id_for_label }}"
{% if required %}required{% endif %} class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{% if disabled %}disabled{% endif %} {{ label|default:field.label }}
{% if readonly %}readonly{% endif %} {% if field.field.required %}<span class="text-destructive">*</span>{% endif %}
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %} </label>
{% if x_model %}x-model="{{ x_model }}"{% endif %} {% endif %}
{% if x_on %}{{ x_on }}{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %} {% render_field field class+="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" placeholder=placeholder|default:field.label aria-describedby=field.id_for_label|add:"-description" aria-invalid=field.errors|yesno:"true,false" %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %} {% if help_text or field.help_text %}
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %} <p id="{{ field.id_for_label }}-description" class="text-sm text-muted-foreground">
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %} {{ help_text|default:field.help_text }}
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %} </p>
{{ attrs|default:'' }} {% endif %}
/>
{% if field.errors %}
<p class="text-sm font-medium text-destructive" role="alert">
{{ field.errors.0 }}
</p>
{% endif %}
</div>
{% else %}
{# Standalone Mode #}
{% with input_id=id|default:name %}
<div class="space-y-2">
{% if label %}
<label for="{{ input_id }}"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{{ label }}
{% if required %}<span class="text-destructive">*</span>{% endif %}
</label>
{% endif %}
{% if type == 'textarea' %}
<textarea
{% if name %}name="{{ name }}"{% endif %}
{% if input_id %}id="{{ input_id }}"{% endif %}
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {{ class|default:'' }}"
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if rows %}rows="{{ rows }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{% if aria_describedby %}aria-describedby="{{ aria_describedby }}"{% elif help_text %}aria-describedby="{{ input_id }}-description"{% endif %}
{% if aria_invalid or error %}aria-invalid="true"{% endif %}
{% if x_model %}x-model="{{ x_model }}"{% endif %}
{% if x_on %}{{ x_on }}{% endif %}
{% if x_data %}x-data="{{ x_data }}"{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
{{ attrs|default:'' }}>{{ value|default:'' }}</textarea>
{% else %}
<input
type="{{ type|default:'text' }}"
{% if name %}name="{{ name }}"{% endif %}
{% if input_id %}id="{{ input_id }}"{% endif %}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {{ class|default:'' }}"
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if value %}value="{{ value }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
{% if aria_describedby %}aria-describedby="{{ aria_describedby }}"{% elif help_text %}aria-describedby="{{ input_id }}-description"{% endif %}
{% if aria_invalid or error %}aria-invalid="true"{% endif %}
{% if x_model %}x-model="{{ x_model }}"{% endif %}
{% if x_on %}{{ x_on }}{% endif %}
{% if x_data %}x-data="{{ x_data }}"{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
{{ attrs|default:'' }}
/>
{% endif %}
{% if help_text %}
<p id="{{ input_id }}-description" class="text-sm text-muted-foreground">
{{ help_text }}
</p>
{% endif %}
{% if error %}
<p class="text-sm font-medium text-destructive" role="alert">
{{ error }}
</p>
{% endif %}
</div>
{% endwith %}
{% endif %}

View File

@@ -1,34 +1,64 @@
{% comment %} {% comment %}
Toast Notification Container Component Toast Notification Container Component
Matches React frontend toast functionality with Sonner-like behavior ======================================
Enhanced toast notification system with Sonner-like behavior.
Features:
- Multiple toast types (success, error, warning, info)
- Progress bar for auto-dismiss countdown
- Action button support (Undo, Retry, View)
- Toast stacking with max limit
- Persistent toast option (duration: 0)
- Accessible announcements
Usage Examples:
Basic toast:
Alpine.store('toast').success('Item saved!')
With action:
Alpine.store('toast').success('Item deleted', 5000, {
action: { label: 'Undo', onClick: () => undoDelete() }
})
Persistent toast:
Alpine.store('toast').error('Connection lost', 0)
From HTMX via HX-Trigger header:
response['HX-Trigger'] = '{"showToast": {"type": "success", "message": "Saved!"}}'
{% endcomment %} {% endcomment %}
<!-- Toast Container --> <!-- Toast Container -->
<div <div
x-data="toast()" x-data="toast()"
x-show="$store.toast.toasts.length > 0" x-show="$store.toast.toasts.length > 0"
class="fixed top-4 right-4 z-50 space-y-2" class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-h-screen overflow-hidden pointer-events-none"
role="region"
aria-label="Notifications"
x-cloak x-cloak
> >
<template x-for="toast in $store.toast.toasts" :key="toast.id"> <template x-for="(toast, index) in $store.toast.toasts.slice(0, 5)" :key="toast.id">
<div <div
x-show="toast.visible" x-show="toast.visible"
x-transition:enter="transition ease-out duration-300" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="transform opacity-0 translate-x-full" x-transition:enter-start="transform opacity-0 translate-x-full scale-95"
x-transition:enter-end="transform opacity-100 translate-x-0" x-transition:enter-end="transform opacity-100 translate-x-0 scale-100"
x-transition:leave="transition ease-in duration-200" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="transform opacity-100 translate-x-0" x-transition:leave-start="transform opacity-100 translate-x-0 scale-100"
x-transition:leave-end="transform opacity-0 translate-x-full" x-transition:leave-end="transform opacity-0 translate-x-full scale-95"
class="relative max-w-sm w-full bg-background border rounded-lg shadow-lg overflow-hidden" class="relative max-w-sm w-full bg-background border rounded-lg shadow-lg overflow-hidden pointer-events-auto"
:class="{ :class="{
'border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-800': toast.type === 'success', 'border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-800': toast.type === 'success',
'border-red-200 bg-red-50 dark:bg-red-900/20 dark:border-red-800': toast.type === 'error', 'border-red-200 bg-red-50 dark:bg-red-900/20 dark:border-red-800': toast.type === 'error',
'border-yellow-200 bg-yellow-50 dark:bg-yellow-900/20 dark:border-yellow-800': toast.type === 'warning', 'border-yellow-200 bg-yellow-50 dark:bg-yellow-900/20 dark:border-yellow-800': toast.type === 'warning',
'border-blue-200 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-800': toast.type === 'info' 'border-blue-200 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-800': toast.type === 'info'
}" }"
role="alert"
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
> >
<!-- Progress Bar --> <!-- Progress Bar (only show if not persistent) -->
<div <div
x-show="toast.duration > 0"
class="absolute top-0 left-0 h-1 bg-current opacity-30 transition-all duration-100 ease-linear" class="absolute top-0 left-0 h-1 bg-current opacity-30 transition-all duration-100 ease-linear"
:style="`width: ${toast.progress}%`" :style="`width: ${toast.progress}%`"
:class="{ :class="{
@@ -44,28 +74,59 @@ Matches React frontend toast functionality with Sonner-like behavior
<!-- Icon --> <!-- Icon -->
<div class="flex-shrink-0 mr-3"> <div class="flex-shrink-0 mr-3">
<i <i
class="w-5 h-5" class="text-lg"
:class="{ :class="{
'fas fa-check-circle text-green-500': toast.type === 'success', 'fas fa-check-circle text-green-500': toast.type === 'success',
'fas fa-exclamation-circle text-red-500': toast.type === 'error', 'fas fa-exclamation-circle text-red-500': toast.type === 'error',
'fas fa-exclamation-triangle text-yellow-500': toast.type === 'warning', 'fas fa-exclamation-triangle text-yellow-500': toast.type === 'warning',
'fas fa-info-circle text-blue-500': toast.type === 'info' 'fas fa-info-circle text-blue-500': toast.type === 'info'
}" }"
aria-hidden="true"
></i> ></i>
</div> </div>
<!-- Message --> <!-- Content -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<!-- Title (optional) -->
<p <p
class="text-sm font-medium" x-show="toast.title"
class="text-sm font-semibold mb-0.5"
:class="{ :class="{
'text-green-800 dark:text-green-200': toast.type === 'success', 'text-green-800 dark:text-green-200': toast.type === 'success',
'text-red-800 dark:text-red-200': toast.type === 'error', 'text-red-800 dark:text-red-200': toast.type === 'error',
'text-yellow-800 dark:text-yellow-200': toast.type === 'warning', 'text-yellow-800 dark:text-yellow-200': toast.type === 'warning',
'text-blue-800 dark:text-blue-200': toast.type === 'info' 'text-blue-800 dark:text-blue-200': toast.type === 'info'
}" }"
x-text="toast.title"
></p>
<!-- Message -->
<p
class="text-sm"
:class="{
'text-green-700 dark:text-green-300': toast.type === 'success',
'text-red-700 dark:text-red-300': toast.type === 'error',
'text-yellow-700 dark:text-yellow-300': toast.type === 'warning',
'text-blue-700 dark:text-blue-300': toast.type === 'info'
}"
x-text="toast.message" x-text="toast.message"
></p> ></p>
<!-- Action Button (optional) -->
<div x-show="toast.action" class="mt-2">
<button
x-show="toast.action"
@click="toast.action?.onClick?.(); $store.toast.hide(toast.id)"
class="text-xs font-medium underline hover:no-underline focus:outline-none focus:ring-2 focus:ring-offset-1 rounded"
:class="{
'text-green-700 dark:text-green-300 focus:ring-green-500': toast.type === 'success',
'text-red-700 dark:text-red-300 focus:ring-red-500': toast.type === 'error',
'text-yellow-700 dark:text-yellow-300 focus:ring-yellow-500': toast.type === 'warning',
'text-blue-700 dark:text-blue-300 focus:ring-blue-500': toast.type === 'info'
}"
x-text="toast.action?.label || 'Action'"
></button>
</div>
</div> </div>
<!-- Close Button --> <!-- Close Button -->
@@ -79,12 +140,21 @@ Matches React frontend toast functionality with Sonner-like behavior
'text-yellow-500 hover:bg-yellow-100 focus:ring-yellow-500 dark:hover:bg-yellow-800': toast.type === 'warning', 'text-yellow-500 hover:bg-yellow-100 focus:ring-yellow-500 dark:hover:bg-yellow-800': toast.type === 'warning',
'text-blue-500 hover:bg-blue-100 focus:ring-blue-500 dark:hover:bg-blue-800': toast.type === 'info' 'text-blue-500 hover:bg-blue-100 focus:ring-blue-500 dark:hover:bg-blue-800': toast.type === 'info'
}" }"
aria-label="Dismiss notification"
> >
<i class="fas fa-times w-4 h-4"></i> <i class="fas fa-times text-sm" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<!-- Overflow indicator when more than 5 toasts -->
<div
x-show="$store.toast.toasts.length > 5"
class="text-center text-xs text-muted-foreground pointer-events-auto"
>
<span x-text="`+${$store.toast.toasts.length - 5} more`"></span>
</div>
</div> </div>

View File

@@ -0,0 +1,225 @@
# Form Templates
This directory contains form-related templates for ThrillWiki.
## Directory Structure
```
forms/
├── partials/ # Individual form components
│ ├── form_field.html # Complete form field
│ ├── field_error.html # Error messages
│ ├── field_success.html # Success indicator
│ └── form_actions.html # Submit/cancel buttons
├── layouts/ # Form layout templates
│ ├── stacked.html # Vertical layout
│ ├── inline.html # Horizontal layout
│ └── grid.html # Multi-column grid
└── README.md # This file
```
## Form Layouts
### Stacked Layout (Default)
Vertical layout with full-width fields:
```django
{% include 'forms/layouts/stacked.html' with form=form %}
{# With options #}
{% include 'forms/layouts/stacked.html' with
form=form
submit_text='Save Changes'
show_cancel=True
cancel_url='/parks/'
%}
```
### Inline Layout
Horizontal layout with labels beside inputs:
```django
{% include 'forms/layouts/inline.html' with form=form %}
{# Custom label width #}
{% include 'forms/layouts/inline.html' with form=form label_width='w-1/4' %}
```
### Grid Layout
Multi-column responsive grid:
```django
{# 2-column grid #}
{% include 'forms/layouts/grid.html' with form=form cols=2 %}
{# 3-column grid #}
{% include 'forms/layouts/grid.html' with form=form cols=3 %}
{# Full-width description field #}
{% include 'forms/layouts/grid.html' with form=form cols=2 full_width='description' %}
```
## Layout Parameters
| Parameter | Description | Default |
|-----------|-------------|---------|
| `form` | Django form object | Required |
| `exclude` | Comma-separated fields to exclude | None |
| `fields` | Comma-separated fields to include | All |
| `show_help` | Show help text | True |
| `show_required` | Show required indicator | True |
| `submit_text` | Submit button text | 'Submit' |
| `submit_class` | Submit button CSS class | 'btn-primary' |
| `show_cancel` | Show cancel button | False |
| `cancel_url` | URL for cancel link | None |
| `cancel_text` | Cancel button text | 'Cancel' |
| `show_actions` | Show action buttons | True |
## Individual Components
### Form Field
Complete field with label, input, help, and errors:
```django
{% include 'forms/partials/form_field.html' with field=form.email %}
{# Custom label #}
{% include 'forms/partials/form_field.html' with field=form.email label='Your Email' %}
{# Without label #}
{% include 'forms/partials/form_field.html' with field=form.search show_label=False %}
{# Inline layout #}
{% include 'forms/partials/form_field.html' with field=form.email layout='inline' %}
{# HTMX validation #}
{% include 'forms/partials/form_field.html' with
field=form.username
hx_validate=True
hx_validate_url='/api/validate/username/'
%}
```
### Field Error
Error message display:
```django
{% include 'forms/partials/field_error.html' with errors=field.errors %}
{# Without icon #}
{% include 'forms/partials/field_error.html' with errors=field.errors show_icon=False %}
{# Different size #}
{% include 'forms/partials/field_error.html' with errors=field.errors size='md' %}
```
### Field Success
Success indicator for validation:
```django
{% include 'forms/partials/field_success.html' with message='Username available' %}
{# Just checkmark #}
{% include 'forms/partials/field_success.html' %}
```
### Form Actions
Submit and cancel buttons:
```django
{% include 'forms/partials/form_actions.html' %}
{# With cancel #}
{% include 'forms/partials/form_actions.html' with show_cancel=True cancel_url='/list/' %}
{# With loading state #}
{% include 'forms/partials/form_actions.html' with show_loading=True %}
{# Left-aligned #}
{% include 'forms/partials/form_actions.html' with align='left' %}
{# Custom icon #}
{% include 'forms/partials/form_actions.html' with submit_icon='fas fa-check' %}
```
## HTMX Integration
### Inline Validation
```django
<form hx-post="/submit/" hx-target="#form-results">
{% for field in form %}
{% include 'forms/partials/form_field.html' with
field=field
hx_validate=True
hx_validate_url='/validate/'
%}
{% endfor %}
{% include 'forms/partials/form_actions.html' with show_loading=True %}
</form>
```
### Validation Endpoint Response
```django
{# Success #}
{% include 'forms/partials/field_success.html' with message='Valid!' %}
{# Error #}
{% include 'forms/partials/field_error.html' with errors=errors %}
```
## Custom Form Rendering
For complete control, use the components directly:
```django
<form method="post" action="{% url 'submit' %}">
{% csrf_token %}
{# Non-field errors #}
{% if form.non_field_errors %}
<div class="mb-4 p-4 bg-red-50 rounded-lg" role="alert">
{% for error in form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{# Custom field layout #}
<div class="grid grid-cols-2 gap-4">
{% include 'forms/partials/form_field.html' with field=form.first_name %}
{% include 'forms/partials/form_field.html' with field=form.last_name %}
</div>
{% include 'forms/partials/form_field.html' with field=form.email %}
{% include 'forms/partials/form_actions.html' with submit_text='Create Account' %}
</form>
```
## CSS Classes
The form components use these CSS classes (defined in `components.css`):
- `.btn-primary` - Primary action button
- `.btn-secondary` - Secondary action button
- `.form-field` - Field wrapper
- `.field-error` - Error message styling
## Accessibility
All form components include:
- Labels properly associated with inputs (`for`/`id`)
- Required field indicators with screen reader text
- Error messages with `role="alert"`
- Help text linked via `aria-describedby`
- Invalid state with `aria-invalid`

View File

@@ -0,0 +1,106 @@
{% comment %}
Grid Form Layout
================
Renders form fields in a responsive grid layout.
Purpose:
Provides a multi-column grid layout for forms with many fields.
Responsive - adjusts columns based on screen size.
Usage Examples:
2-column grid:
{% include 'forms/layouts/grid.html' with form=form cols=2 %}
3-column grid:
{% include 'forms/layouts/grid.html' with form=form cols=3 %}
With full-width fields:
{% include 'forms/layouts/grid.html' with form=form cols=2 full_width='description,notes' %}
Parameters:
Required:
- form: Django form object
Optional:
- cols: Number of columns (2 or 3, default: 2)
- exclude: Comma-separated field names to exclude
- fields: Comma-separated field names to include
- full_width: Comma-separated field names that span full width
- show_help: Show help text (default: True)
- show_required: Show required indicator (default: True)
- gap: Grid gap class (default: 'gap-4')
- submit_text: Submit button text (default: 'Submit')
- submit_class: Submit button CSS class
- form_class: Additional CSS classes for form wrapper
Dependencies:
- forms/partials/form_field.html
- Tailwind CSS
Accessibility:
- Responsive grid maintains logical order
- Labels properly associated with inputs
{% endcomment %}
{% load common_filters %}
{% with show_help=show_help|default:True show_required=show_required|default:True cols=cols|default:2 gap=gap|default:'gap-4' submit_text=submit_text|default:'Submit' %}
<div class="form-layout-grid {{ form_class }}">
{# Non-field errors #}
{% if form.non_field_errors %}
<div class="mb-4 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
role="alert">
<ul class="text-sm text-red-700 dark:text-red-300 space-y-1">
{% for error in form.non_field_errors %}
<li class="flex items-start gap-2">
<i class="fas fa-exclamation-circle mt-0.5" aria-hidden="true"></i>
{{ error }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Grid container #}
<div class="grid {% if cols == 3 %}grid-cols-1 md:grid-cols-2 lg:grid-cols-3{% else %}grid-cols-1 md:grid-cols-2{% endif %} {{ gap }}">
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% elif fields %}
{% if field.name in fields %}
<div class="{% if full_width and field.name in full_width %}{% if cols == 3 %}md:col-span-2 lg:col-span-3{% else %}md:col-span-2{% endif %}{% endif %}">
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
</div>
{% endif %}
{% elif exclude %}
{% if field.name not in exclude %}
<div class="{% if full_width and field.name in full_width %}{% if cols == 3 %}md:col-span-2 lg:col-span-3{% else %}md:col-span-2{% endif %}{% endif %}">
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
</div>
{% endif %}
{% else %}
<div class="{% if full_width and field.name in full_width %}{% if cols == 3 %}md:col-span-2 lg:col-span-3{% else %}md:col-span-2{% endif %}{% endif %}">
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
</div>
{% endif %}
{% endfor %}
</div>
{# Form actions - full width #}
{% if show_actions|default:True %}
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
{% if show_cancel and cancel_url %}
<a href="{{ cancel_url }}" class="btn-secondary">
{{ cancel_text|default:'Cancel' }}
</a>
{% endif %}
<button type="submit" class="{{ submit_class|default:'btn-primary' }}">
{{ submit_text }}
</button>
</div>
{% endif %}
</div>
{% endwith %}

View File

@@ -0,0 +1,149 @@
{% comment %}
Inline Form Layout
==================
Renders form fields horizontally with labels inline with inputs.
Purpose:
Provides an inline/horizontal form layout where labels appear
to the left of inputs on larger screens.
Usage Examples:
Basic inline form:
{% include 'forms/layouts/inline.html' with form=form %}
Custom label width:
{% include 'forms/layouts/inline.html' with form=form label_width='w-1/4' %}
Parameters:
Required:
- form: Django form object
Optional:
- exclude: Comma-separated field names to exclude
- fields: Comma-separated field names to include
- label_width: Label column width class (default: 'w-1/3')
- show_help: Show help text (default: True)
- show_required: Show required indicator (default: True)
- submit_text: Submit button text (default: 'Submit')
- submit_class: Submit button CSS class (default: 'btn-primary')
- show_cancel: Show cancel button (default: False)
- cancel_url: URL for cancel button
- form_class: Additional CSS classes for form wrapper
Dependencies:
- forms/partials/form_field.html
- Tailwind CSS
Accessibility:
- Labels properly associated with inputs
- Responsive - stacks on mobile
{% endcomment %}
{% load common_filters %}
{% with show_help=show_help|default:True show_required=show_required|default:True label_width=label_width|default:'w-1/3' submit_text=submit_text|default:'Submit' %}
<div class="form-layout-inline {{ form_class }}">
{# Non-field errors #}
{% if form.non_field_errors %}
<div class="mb-4 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
role="alert">
<ul class="text-sm text-red-700 dark:text-red-300 space-y-1">
{% for error in form.non_field_errors %}
<li class="flex items-start gap-2">
<i class="fas fa-exclamation-circle mt-0.5" aria-hidden="true"></i>
{{ error }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Form fields - inline layout #}
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% elif fields %}
{% if field.name in fields %}
<div class="mb-4 sm:flex sm:items-start">
<label for="{{ field.id_for_label }}"
class="block mb-1 sm:mb-0 sm:{{ label_width }} sm:pt-2 sm:pr-4 sm:text-right text-sm font-medium text-gray-700 dark:text-gray-300">
{{ field.label }}
{% if show_required and field.field.required %}
<span class="text-red-500" aria-hidden="true">*</span>
{% endif %}
</label>
<div class="flex-1">
{{ field|add_class:'w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white' }}
{% if field.errors %}
{% include 'forms/partials/field_error.html' with errors=field.errors %}
{% endif %}
{% if show_help and field.help_text %}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ field.help_text }}</p>
{% endif %}
</div>
</div>
{% endif %}
{% elif exclude %}
{% if field.name not in exclude %}
<div class="mb-4 sm:flex sm:items-start">
<label for="{{ field.id_for_label }}"
class="block mb-1 sm:mb-0 sm:{{ label_width }} sm:pt-2 sm:pr-4 sm:text-right text-sm font-medium text-gray-700 dark:text-gray-300">
{{ field.label }}
{% if show_required and field.field.required %}
<span class="text-red-500" aria-hidden="true">*</span>
{% endif %}
</label>
<div class="flex-1">
{{ field|add_class:'w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white' }}
{% if field.errors %}
{% include 'forms/partials/field_error.html' with errors=field.errors %}
{% endif %}
{% if show_help and field.help_text %}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ field.help_text }}</p>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<div class="mb-4 sm:flex sm:items-start">
<label for="{{ field.id_for_label }}"
class="block mb-1 sm:mb-0 sm:{{ label_width }} sm:pt-2 sm:pr-4 sm:text-right text-sm font-medium text-gray-700 dark:text-gray-300">
{{ field.label }}
{% if show_required and field.field.required %}
<span class="text-red-500" aria-hidden="true">*</span>
{% endif %}
</label>
<div class="flex-1">
{{ field|add_class:'w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white' }}
{% if field.errors %}
{% include 'forms/partials/field_error.html' with errors=field.errors %}
{% endif %}
{% if show_help and field.help_text %}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ field.help_text }}</p>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
{# Form actions - aligned with inputs #}
{% if show_actions|default:True %}
<div class="sm:flex sm:items-center mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="sm:{{ label_width }}"></div>
<div class="flex-1 flex items-center justify-start gap-3">
<button type="submit" class="{{ submit_class|default:'btn-primary' }}">
{{ submit_text }}
</button>
{% if show_cancel and cancel_url %}
<a href="{{ cancel_url }}" class="btn-secondary">
{{ cancel_text|default:'Cancel' }}
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endwith %}

View File

@@ -0,0 +1,106 @@
{% comment %}
Stacked Form Layout
===================
Renders form fields in a vertical stacked layout (default form layout).
Purpose:
Provides a standard vertical form layout where each field takes
full width with labels above inputs.
Usage Examples:
Basic form:
{% include 'forms/layouts/stacked.html' with form=form %}
With fieldsets:
{% include 'forms/layouts/stacked.html' with form=form show_fieldsets=True %}
Exclude fields:
{% include 'forms/layouts/stacked.html' with form=form exclude='password2' %}
Parameters:
Required:
- form: Django form object
Optional:
- exclude: Comma-separated field names to exclude
- fields: Comma-separated field names to include (if set, only these are shown)
- show_fieldsets: Group fields by fieldset (default: False)
- show_help: Show help text (default: True)
- show_required: Show required indicator (default: True)
- submit_text: Submit button text (default: 'Submit')
- submit_class: Submit button CSS class (default: 'btn-primary')
- show_cancel: Show cancel button (default: False)
- cancel_url: URL for cancel button
- cancel_text: Cancel button text (default: 'Cancel')
- form_class: Additional CSS classes for form wrapper
Dependencies:
- forms/partials/form_field.html
- forms/partials/field_error.html
- Tailwind CSS
Accessibility:
- Uses fieldset/legend for grouped fields
- Labels properly associated with inputs
- Error summary at top for screen readers
{% endcomment %}
{% load common_filters %}
{% with show_help=show_help|default:True show_required=show_required|default:True submit_text=submit_text|default:'Submit' %}
<div class="form-layout-stacked {{ form_class }}">
{# Non-field errors at top #}
{% if form.non_field_errors %}
<div class="mb-4 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
role="alert">
<div class="flex items-start gap-2">
<i class="fas fa-exclamation-circle text-red-500 mt-0.5" aria-hidden="true"></i>
<div>
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">Please correct the following errors:</h3>
<ul class="mt-1 text-sm text-red-700 dark:text-red-300 list-disc list-inside">
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
{# Form fields #}
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% elif fields %}
{# Only show specified fields #}
{% if field.name in fields %}
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
{% endif %}
{% elif exclude %}
{# Exclude specified fields #}
{% if field.name not in exclude %}
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
{% endif %}
{% else %}
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
{% endif %}
{% endfor %}
{# Form actions #}
{% if show_actions|default:True %}
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
{% if show_cancel and cancel_url %}
<a href="{{ cancel_url }}" class="btn-secondary {{ cancel_class }}">
{{ cancel_text|default:'Cancel' }}
</a>
{% endif %}
<button type="submit" class="{{ submit_class|default:'btn-primary' }}">
{{ submit_text }}
</button>
</div>
{% endif %}
</div>
{% endwith %}

View File

@@ -1,7 +1,57 @@
{% comment %}
Field Error Component
=====================
Displays error messages for a form field with icon and proper accessibility.
Purpose:
Renders error messages in a consistent, accessible format with visual
indicators. Used within form_field.html or standalone.
Usage Examples:
Within form_field.html (automatic):
{% include 'forms/partials/field_error.html' with errors=field.errors %}
Standalone:
{% include 'forms/partials/field_error.html' with errors=form.email.errors %}
Non-field errors:
{% include 'forms/partials/field_error.html' with errors=form.non_field_errors %}
Parameters:
Required:
- errors: List of error messages (typically field.errors)
Optional:
- show_icon: Show error icon (default: True)
- animate: Add entrance animation (default: True)
- size: 'sm', 'md', 'lg' (default: 'sm')
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons
Accessibility:
- Uses role="alert" for immediate screen reader announcement
- aria-live="assertive" for dynamic error display
- Error icon is decorative (aria-hidden)
{% endcomment %}
{% with show_icon=show_icon|default:True animate=animate|default:True size=size|default:'sm' %}
{% if errors %} {% if errors %}
<ul class="field-errors"> <ul class="{% if size == 'lg' %}text-base{% elif size == 'md' %}text-sm{% else %}text-xs{% endif %} text-red-600 dark:text-red-400 space-y-1 {% if animate %}animate-slide-down{% endif %}"
{% for e in errors %} role="alert"
<li>{{ e }}</li> aria-live="assertive">
{% for error in errors %}
<li class="flex items-start gap-1.5">
{% if show_icon %}
<i class="fas fa-exclamation-circle mt-0.5 flex-shrink-0" aria-hidden="true"></i>
{% endif %}
<span>{{ error }}</span>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% endwith %}

View File

@@ -1 +1,48 @@
<div class="field-success" aria-hidden="true"></div> {% comment %}
Field Success Component
=======================
Displays success message for a validated form field with icon.
Purpose:
Renders a success indicator when a field passes validation.
Typically used with HTMX inline validation.
Usage Examples:
After successful validation:
{% include 'forms/partials/field_success.html' with message='Username is available' %}
Simple checkmark (no message):
{% include 'forms/partials/field_success.html' %}
Parameters:
Optional:
- message: Success message text (if empty, shows only icon)
- show_icon: Show success icon (default: True)
- animate: Add entrance animation (default: True)
- size: 'sm', 'md', 'lg' (default: 'sm')
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons
Accessibility:
- Uses role="status" for polite screen reader announcement
- aria-live="polite" for non-urgent updates
- Success icon is decorative (aria-hidden)
{% endcomment %}
{% with show_icon=show_icon|default:True animate=animate|default:True size=size|default:'sm' %}
<div class="{% if size == 'lg' %}text-base{% elif size == 'md' %}text-sm{% else %}text-xs{% endif %} text-green-600 dark:text-green-400 flex items-center gap-1.5 {% if animate %}animate-slide-down{% endif %}"
role="status"
aria-live="polite">
{% if show_icon %}
<i class="fas fa-check-circle flex-shrink-0" aria-hidden="true"></i>
{% endif %}
{% if message %}
<span>{{ message }}</span>
{% endif %}
</div>
{% endwith %}

View File

@@ -1,4 +1,135 @@
<div class="form-actions"> {% comment %}
<button type="submit" class="btn-primary">Save</button> Form Actions Component
<button type="button" class="btn-secondary" hx-trigger="click" hx-swap="none">Cancel</button> ======================
Renders form submit and cancel buttons with consistent styling.
Purpose:
Provides a standardized form actions section with submit button,
optional cancel button, and loading state support.
Usage Examples:
Basic submit:
{% include 'forms/partials/form_actions.html' %}
With cancel:
{% include 'forms/partials/form_actions.html' with show_cancel=True cancel_url='/list/' %}
Custom text:
{% include 'forms/partials/form_actions.html' with submit_text='Save Changes' %}
With loading state:
{% include 'forms/partials/form_actions.html' with show_loading=True loading_id='submit-loading' %}
Right-aligned (default):
{% include 'forms/partials/form_actions.html' %}
Left-aligned:
{% include 'forms/partials/form_actions.html' with align='left' %}
HTMX form:
{% include 'forms/partials/form_actions.html' with hx_disable='true' %}
Parameters:
Optional (submit):
- submit_text: Submit button text (default: 'Save')
- submit_class: CSS class (default: 'btn-primary')
- submit_disabled: Disable submit button (default: False)
- submit_icon: Icon class for submit button (e.g., 'fas fa-check')
Optional (cancel):
- show_cancel: Show cancel button (default: False)
- cancel_url: URL for cancel link
- cancel_text: Cancel button text (default: 'Cancel')
- cancel_class: CSS class (default: 'btn-secondary')
Optional (loading):
- show_loading: Show loading indicator on submit (default: False)
- loading_id: ID for htmx-indicator (default: 'submit-loading')
Optional (layout):
- align: 'left', 'right', 'center', 'between' (default: 'right')
- show_border: Show top border (default: True)
Optional (HTMX):
- hx_disable: Add hx-disable on submit (default: False)
Dependencies:
- Tailwind CSS
- HTMX (optional, for loading states)
- Font Awesome (optional, for icons)
Accessibility:
- Submit button is properly typed
- Loading state announced to screen readers
{% endcomment %}
{% with submit_text=submit_text|default:'Save' cancel_text=cancel_text|default:'Cancel' align=align|default:'right' show_border=show_border|default:True %}
<div class="form-actions flex items-center gap-3 mt-6
{% if show_border %}pt-4 border-t border-gray-200 dark:border-gray-700{% endif %}
{% if align == 'left' %}justify-start
{% elif align == 'center' %}justify-center
{% elif align == 'between' %}justify-between
{% else %}justify-end{% endif %}">
{# Cancel button (left side for 'between' alignment) #}
{% if show_cancel and align == 'between' %}
{% if cancel_url %}
<a href="{{ cancel_url }}"
class="{{ cancel_class|default:'btn-secondary' }}">
{{ cancel_text }}
</a>
{% else %}
<button type="button"
class="{{ cancel_class|default:'btn-secondary' }}"
onclick="history.back()">
{{ cancel_text }}
</button>
{% endif %}
{% endif %}
{# Button group #}
<div class="flex items-center gap-3">
{# Cancel button (for non-between alignment) #}
{% if show_cancel and align != 'between' %}
{% if cancel_url %}
<a href="{{ cancel_url }}"
class="{{ cancel_class|default:'btn-secondary' }}">
{{ cancel_text }}
</a>
{% else %}
<button type="button"
class="{{ cancel_class|default:'btn-secondary' }}"
onclick="history.back()">
{{ cancel_text }}
</button>
{% endif %}
{% endif %}
{# Submit button #}
<button type="submit"
class="{{ submit_class|default:'btn-primary' }} relative"
{% if submit_disabled %}disabled{% endif %}
{% if hx_disable %}hx-disable{% endif %}>
{# Icon (optional) #}
{% if submit_icon %}
<i class="{{ submit_icon }} mr-2" aria-hidden="true"></i>
{% endif %}
{# Text #}
<span>{{ submit_text }}</span>
{# Loading indicator (optional) #}
{% if show_loading %}
<span id="{{ loading_id|default:'submit-loading' }}"
class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span class="sr-only">Submitting...</span>
</span>
{% endif %}
</button>
</div>
</div> </div>
{% endwith %}

View File

@@ -1,5 +1,198 @@
<div class="form-field" data-field-name="{{ field.name }}"> {% comment %}
<label for="id_{{ field.name }}">{{ field.label }}</label> Form Field Component
{{ field }} ====================
<div class="field-feedback" aria-live="polite">{% include "forms/partials/field_error.html" %}</div>
A comprehensive form field component with label, input, help text, and error handling.
Purpose:
Renders a complete form field with consistent styling, accessibility attributes,
and optional HTMX validation support.
Usage Examples:
Basic field:
{% include 'forms/partials/form_field.html' with field=form.email %}
Field with custom label:
{% include 'forms/partials/form_field.html' with field=form.email label='Email Address' %}
Field without label:
{% include 'forms/partials/form_field.html' with field=form.hidden_field show_label=False %}
Field with HTMX validation:
{% include 'forms/partials/form_field.html' with field=form.username hx_validate=True hx_validate_url='/api/validate-username/' %}
Inline field:
{% include 'forms/partials/form_field.html' with field=form.email layout='inline' %}
With success state:
{% include 'forms/partials/form_field.html' with field=form.email show_success=True %}
Parameters:
Required:
- field: Django form field object
Optional (labels):
- label: Custom label text (default: field.label)
- show_label: Show label (default: True)
- label_class: Additional CSS classes for label
Optional (input):
- input_class: Additional CSS classes for input
- placeholder: Custom placeholder text
- autofocus: Add autofocus attribute (default: False)
- disabled: Disable input (default: False)
- readonly: Make input readonly (default: False)
Optional (help/errors):
- help_text: Custom help text (default: field.help_text)
- show_help: Show help text (default: True)
- show_errors: Show error messages (default: True)
- show_success: Show success indicator when valid (default: False)
Optional (HTMX validation):
- hx_validate: Enable HTMX inline validation (default: False)
- hx_validate_url: URL for validation endpoint
- hx_validate_trigger: Trigger event (default: 'blur changed delay:500ms')
- hx_validate_target: Target for validation response (default: auto-generated)
Optional (layout):
- layout: 'stacked' or 'inline' (default: 'stacked')
- required_indicator: Show required indicator (default: True)
- size: 'sm', 'md', 'lg' for input size (default: 'md')
Dependencies:
- Tailwind CSS for styling
- HTMX (optional, for inline validation)
- Alpine.js (optional, for enhanced interactions)
- common_filters template tags (for add_class filter)
Accessibility:
- Label properly associated with input via for/id
- aria-describedby links help text and errors to input
- aria-invalid set when field has errors
- aria-required set for required fields
- Error messages use role="alert" for screen reader announcement
{% endcomment %}
{% load common_filters %}
{% with show_label=show_label|default:True show_help=show_help|default:True show_errors=show_errors|default:True show_success=show_success|default:False required_indicator=required_indicator|default:True layout=layout|default:'stacked' size=size|default:'md' %}
<div class="form-field {% if layout == 'inline' %}flex items-center gap-4{% else %}mb-4{% endif %}"
data-field-name="{{ field.name }}"
data-field-required="{{ field.field.required|yesno:'true,false' }}"
{% if hx_validate %}x-data="{ valid: null, validating: false, touched: false }"{% endif %}>
{# Label #}
{% if show_label %}
<label for="{{ field.id_for_label }}"
class="{% if layout == 'inline' %}w-1/3 text-right{% else %}block mb-1.5{% endif %} {% if size == 'sm' %}text-xs{% elif size == 'lg' %}text-base{% else %}text-sm{% endif %} font-medium text-foreground {{ label_class }}">
{{ label|default:field.label }}
{% if required_indicator and field.field.required %}
<span class="text-destructive ml-0.5" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
{% endif %}
</label>
{% endif %}
{# Input wrapper #}
<div class="{% if layout == 'inline' and show_label %}flex-1{% else %}w-full{% endif %} relative">
{# Field input with enhanced attributes #}
{# Base widget classes for sizing, spacing, and transitions #}
{% with base_class='w-full border rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-0 bg-background text-foreground placeholder:text-muted-foreground' %}
{# Size classes #}
{% with size_class=size|default:'md'|yesno:'px-2.5 py-1.5 text-xs,px-3 py-2 text-sm,px-4 py-2.5 text-base' %}
{% if size == 'sm' %}{% with actual_size='px-2.5 py-1.5 text-xs' %}
{# Error-specific classes override border and focus ring colors #}
{% with error_class='border-destructive focus:ring-destructive/30 focus:border-destructive' %}
{# Success-specific classes #}
{% with success_class='border-green-500 focus:ring-green-500/30 focus:border-green-500' %}
{# Normal state classes for border and focus ring #}
{% with normal_class='border-input focus:ring-ring/30 focus:border-ring' %}
{# Render the field with base classes plus state-specific classes #}
{% if field.errors %}
{{ field|add_class:base_class|add_class:actual_size|add_class:error_class|add_class:input_class }}
{% else %}
{{ field|add_class:base_class|add_class:actual_size|add_class:normal_class|add_class:input_class }}
{% endif %}
{% endwith %}{% endwith %}{% endwith %}{% endwith %}
{% elif size == 'lg' %}{% with actual_size='px-4 py-2.5 text-base' %}
{% with error_class='border-destructive focus:ring-destructive/30 focus:border-destructive' %}
{% with success_class='border-green-500 focus:ring-green-500/30 focus:border-green-500' %}
{% with normal_class='border-input focus:ring-ring/30 focus:border-ring' %}
{% if field.errors %}
{{ field|add_class:base_class|add_class:actual_size|add_class:error_class|add_class:input_class }}
{% else %}
{{ field|add_class:base_class|add_class:actual_size|add_class:normal_class|add_class:input_class }}
{% endif %}
{% endwith %}{% endwith %}{% endwith %}{% endwith %}
{% else %}{% with actual_size='px-3 py-2 text-sm' %}
{% with error_class='border-destructive focus:ring-destructive/30 focus:border-destructive' %}
{% with success_class='border-green-500 focus:ring-green-500/30 focus:border-green-500' %}
{% with normal_class='border-input focus:ring-ring/30 focus:border-ring' %}
{% if field.errors %}
{{ field|add_class:base_class|add_class:actual_size|add_class:error_class|add_class:input_class }}
{% else %}
{{ field|add_class:base_class|add_class:actual_size|add_class:normal_class|add_class:input_class }}
{% endif %}
{% endwith %}{% endwith %}{% endwith %}{% endwith %}
{% endif %}
{% endwith %}
{% endwith %}
{# HTMX validation attributes (when enabled) #}
{% if hx_validate and hx_validate_url %}
<script>
(function() {
var el = document.getElementById('{{ field.id_for_label }}');
if (el) {
el.setAttribute('hx-post', '{{ hx_validate_url }}');
el.setAttribute('hx-trigger', '{{ hx_validate_trigger|default:"blur changed delay:500ms" }}');
el.setAttribute('hx-target', '#{{ field.name }}-feedback');
el.setAttribute('hx-swap', 'innerHTML');
el.setAttribute('hx-indicator', '#{{ field.name }}-indicator');
el.setAttribute('hx-include', '[name="{{ field.name }}"]');
if (typeof htmx !== 'undefined') htmx.process(el);
}
})();
</script>
{% endif %}
{# Validation indicator (for HTMX) - positioned inside input #}
{% if hx_validate %}
<div id="{{ field.name }}-indicator" class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<i class="fas fa-spinner fa-spin text-muted-foreground" aria-hidden="true"></i>
</div>
{% endif %}
{# Success indicator (shown when valid and no errors) #}
{% if show_success and not field.errors and field.value %}
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<i class="fas fa-check-circle text-green-500" aria-hidden="true"></i>
</div>
{% endif %}
{# Feedback area (errors, success, help) #}
<div id="{{ field.name }}-feedback"
class="field-feedback mt-1.5"
aria-live="polite"
aria-atomic="true">
{# Error messages #}
{% if show_errors and field.errors %}
{% include 'forms/partials/field_error.html' with errors=field.errors %}
{% endif %}
{# Help text #}
{% if show_help and field.help_text %}
<p id="{{ field.name }}-help"
class="{% if size == 'sm' %}text-xs{% elif size == 'lg' %}text-sm{% else %}text-xs{% endif %} text-muted-foreground {% if field.errors %}mt-1{% endif %}">
{{ help_text|default:field.help_text }}
</p>
{% endif %}
</div>
</div>
</div> </div>
{% endwith %}

View File

@@ -0,0 +1,194 @@
{% comment %}
Form Submission Feedback Component
==================================
Displays feedback after form submission with various states.
Purpose:
Provides consistent feedback UI for form submission results including
success confirmations, error summaries, and loading states.
Usage Examples:
Success state:
{% include 'forms/partials/form_submission_feedback.html' with state='success' message='Park saved successfully!' %}
Error state:
{% include 'forms/partials/form_submission_feedback.html' with state='error' message='Please fix the errors below' %}
Loading state:
{% include 'forms/partials/form_submission_feedback.html' with state='loading' message='Saving...' %}
With redirect countdown:
{% include 'forms/partials/form_submission_feedback.html' with state='success' message='Saved!' redirect_url='/parks/' redirect_seconds=3 %}
Parameters:
Required:
- state: 'success', 'error', 'warning', 'loading' (default: 'success')
- message: Main feedback message
Optional:
- title: Optional title text
- show_icon: Show status icon (default: True)
- show_actions: Show action buttons (default: True)
- primary_action_text: Primary button text (default: varies by state)
- primary_action_url: Primary button URL
- secondary_action_text: Secondary button text
- secondary_action_url: Secondary button URL
- redirect_url: URL to redirect to after countdown
- redirect_seconds: Seconds before redirect (default: 3)
- errors: List of error messages (for error state)
- animate: Enable animations (default: True)
Dependencies:
- Tailwind CSS for styling
- Alpine.js for countdown functionality
- Font Awesome icons
Accessibility:
- Uses appropriate ARIA roles based on state
- Announces changes to screen readers
{% endcomment %}
{% with state=state|default:'success' show_icon=show_icon|default:True show_actions=show_actions|default:True animate=animate|default:True redirect_seconds=redirect_seconds|default:3 %}
<div class="form-submission-feedback rounded-lg p-4 {% if animate %}animate-fade-in{% endif %}
{% if state == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800
{% elif state == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800
{% elif state == 'warning' %}bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800
{% elif state == 'loading' %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800
{% endif %}"
role="{% if state == 'error' %}alert{% else %}status{% endif %}"
aria-live="{% if state == 'error' %}assertive{% else %}polite{% endif %}"
{% if redirect_url %}
x-data="{ countdown: {{ redirect_seconds }} }"
x-init="setInterval(() => { if(countdown > 0) countdown--; else window.location.href = '{{ redirect_url }}'; }, 1000)"
{% endif %}>
<div class="flex {% if title or errors %}flex-col gap-3{% else %}items-center gap-3{% endif %}">
{# Icon #}
{% if show_icon %}
<div class="flex-shrink-0 {% if title or errors %}flex items-center gap-3{% endif %}">
{% if state == 'success' %}
<div class="w-10 h-10 rounded-full bg-green-100 dark:bg-green-800/30 flex items-center justify-center">
<i class="fas fa-check text-green-600 dark:text-green-400 text-lg" aria-hidden="true"></i>
</div>
{% elif state == 'error' %}
<div class="w-10 h-10 rounded-full bg-red-100 dark:bg-red-800/30 flex items-center justify-center">
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-400 text-lg" aria-hidden="true"></i>
</div>
{% elif state == 'warning' %}
<div class="w-10 h-10 rounded-full bg-yellow-100 dark:bg-yellow-800/30 flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400 text-lg" aria-hidden="true"></i>
</div>
{% elif state == 'loading' %}
<div class="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-800/30 flex items-center justify-center">
<i class="fas fa-spinner fa-spin text-blue-600 dark:text-blue-400 text-lg" aria-hidden="true"></i>
</div>
{% endif %}
{# Title (if provided) #}
{% if title %}
<h3 class="font-semibold
{% if state == 'success' %}text-green-800 dark:text-green-200
{% elif state == 'error' %}text-red-800 dark:text-red-200
{% elif state == 'warning' %}text-yellow-800 dark:text-yellow-200
{% elif state == 'loading' %}text-blue-800 dark:text-blue-200
{% endif %}">
{{ title }}
</h3>
{% endif %}
</div>
{% endif %}
{# Content #}
<div class="flex-1 min-w-0">
{# Main message #}
<p class="text-sm
{% if state == 'success' %}text-green-700 dark:text-green-300
{% elif state == 'error' %}text-red-700 dark:text-red-300
{% elif state == 'warning' %}text-yellow-700 dark:text-yellow-300
{% elif state == 'loading' %}text-blue-700 dark:text-blue-300
{% endif %}">
{{ message }}
</p>
{# Error list (for error state) #}
{% if state == 'error' and errors %}
<ul class="mt-2 text-sm text-red-600 dark:text-red-400 space-y-1 list-disc list-inside">
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
{# Redirect countdown #}
{% if redirect_url %}
<p class="mt-2 text-xs
{% if state == 'success' %}text-green-600 dark:text-green-400
{% else %}text-muted-foreground
{% endif %}">
Redirecting in <span x-text="countdown"></span> seconds...
<a href="{{ redirect_url }}" class="underline hover:no-underline ml-1">Go now</a>
</p>
{% endif %}
</div>
{# Actions #}
{% if show_actions and state != 'loading' %}
<div class="flex-shrink-0 flex items-center gap-2 {% if title or errors %}mt-2{% endif %}">
{% if state == 'error' %}
{# Error state actions #}
{% if secondary_action_url %}
<a href="{{ secondary_action_url }}"
class="btn btn-sm btn-ghost text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-800/30">
{{ secondary_action_text|default:'Cancel' }}
</a>
{% endif %}
{% if primary_action_url %}
<a href="{{ primary_action_url }}"
class="btn btn-sm bg-red-600 hover:bg-red-700 text-white">
{{ primary_action_text|default:'Try Again' }}
</a>
{% else %}
<button type="submit"
class="btn btn-sm bg-red-600 hover:bg-red-700 text-white">
{{ primary_action_text|default:'Try Again' }}
</button>
{% endif %}
{% elif state == 'success' %}
{# Success state actions #}
{% if secondary_action_url %}
<a href="{{ secondary_action_url }}"
class="btn btn-sm btn-ghost text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-800/30">
{{ secondary_action_text|default:'Add Another' }}
</a>
{% endif %}
{% if primary_action_url %}
<a href="{{ primary_action_url }}"
class="btn btn-sm bg-green-600 hover:bg-green-700 text-white">
{{ primary_action_text|default:'View' }}
</a>
{% endif %}
{% elif state == 'warning' %}
{# Warning state actions #}
{% if secondary_action_url %}
<a href="{{ secondary_action_url }}"
class="btn btn-sm btn-ghost">
{{ secondary_action_text|default:'Cancel' }}
</a>
{% endif %}
{% if primary_action_url %}
<a href="{{ primary_action_url }}"
class="btn btn-sm bg-yellow-600 hover:bg-yellow-700 text-white">
{{ primary_action_text|default:'Continue' }}
</a>
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endwith %}

View File

@@ -0,0 +1,273 @@
# HTMX Templates and Patterns
This directory contains HTMX-specific templates and components for ThrillWiki.
## Overview
HTMX is used throughout ThrillWiki for dynamic content updates without full page reloads. This guide documents the standardized patterns and conventions.
## Directory Structure
```
htmx/
├── components/ # Reusable HTMX components
│ ├── confirm_dialog.html # Confirmation modal
│ ├── error_message.html # Error display
│ ├── filter_badge.html # Filter tag/badge
│ ├── inline_edit_field.html # Inline editing
│ ├── loading_indicator.html # Loading spinners
│ └── success_toast.html # Success notification
├── partials/ # HTMX response partials
└── README.md # This documentation
```
## Swap Strategies
Use consistent swap strategies across the application:
| Strategy | Use Case | Example |
|----------|----------|---------|
| `innerHTML` | Replace content inside container | List updates, search results |
| `outerHTML` | Replace entire element | Status badges, table rows |
| `beforeend` | Append items | Infinite scroll, new items |
| `afterbegin` | Prepend items | New items at top of list |
### Examples
```html
<!-- Replace content inside container -->
<div id="results"
hx-get="/api/search"
hx-swap="innerHTML">
<!-- Replace entire element (e.g., status badge) -->
<span id="park-status"
hx-get="/api/park/status"
hx-swap="outerHTML">
<!-- Infinite scroll - append items -->
<div id="item-list"
hx-get="/api/items?page=2"
hx-trigger="revealed"
hx-swap="beforeend">
```
## Target Naming Conventions
Follow these naming patterns for `hx-target`:
| Pattern | Use Case | Example |
|---------|----------|---------|
| `#object-type-id` | Specific objects | `#park-123`, `#ride-456` |
| `#section-name` | Page sections | `#results`, `#filters`, `#stats` |
| `#modal-container` | Modal content | `#modal-container` |
| `this` | Self-replacement | Status badges, inline edits |
### Examples
```html
<!-- Target specific object -->
<button hx-post="/api/parks/123/status"
hx-target="#park-123"
hx-swap="outerHTML">
<!-- Target page section -->
<form hx-post="/api/search"
hx-target="#results"
hx-swap="innerHTML">
<!-- Self-replacement -->
<span hx-get="/api/badge"
hx-target="this"
hx-swap="outerHTML">
```
## Custom Event Naming
Use these conventions for custom HTMX events:
| Pattern | Description | Example |
|---------|-------------|---------|
| `{model}-status-changed` | Status updates | `park-status-changed`, `ride-status-changed` |
| `{model}-created` | New item created | `park-created`, `review-created` |
| `{model}-updated` | Item updated | `ride-updated`, `photo-updated` |
| `{model}-deleted` | Item deleted | `comment-deleted` |
| `auth-changed` | Auth state change | User login/logout |
### Triggering Events
From Django views:
```python
response['HX-Trigger'] = 'park-status-changed'
# or with data
response['HX-Trigger'] = json.dumps({
'showToast': {'type': 'success', 'message': 'Status updated!'}
})
```
Listening for events:
```html
<div hx-get="/api/park/header"
hx-trigger="park-status-changed from:body">
```
## Loading Indicators
Use the standardized loading indicator component:
```html
<!-- Inline (in buttons) -->
<button hx-post="/api/action" hx-indicator="#btn-loading">
Save
{% include 'htmx/components/loading_indicator.html' with id='btn-loading' inline=True size='sm' %}
</button>
<!-- Block (below content) -->
<div hx-get="/api/data" hx-indicator="#loading">
Content
</div>
{% include 'htmx/components/loading_indicator.html' with id='loading' message='Loading...' %}
<!-- Overlay (covers container) -->
<div class="relative" hx-get="/api/data" hx-indicator="#overlay">
Content
{% include 'htmx/components/loading_indicator.html' with id='overlay' mode='overlay' %}
</div>
```
## Error Handling
HTMX errors are handled globally in `base.html`. The system:
1. Shows toast notifications for different HTTP status codes
2. Handles timeouts (30 second default)
3. Handles network errors
4. Supports retry logic
### Custom Error Responses
Return error templates for 4xx/5xx responses:
```html
{% include 'htmx/components/error_message.html' with title='Error' message='Something went wrong.' %}
```
## Toast Notifications via HTMX
Trigger toast notifications from server responses:
```python
from django.http import JsonResponse
def my_view(request):
response = render(request, 'partial.html')
response['HX-Trigger'] = json.dumps({
'showToast': {
'type': 'success', # success, error, warning, info
'message': 'Action completed!',
'duration': 5000 # optional, in milliseconds
}
})
return response
```
## Form Validation
Use inline HTMX validation for forms:
```html
<input type="text"
name="username"
hx-post="/api/validate/username"
hx-trigger="blur changed delay:500ms"
hx-target="#username-feedback"
hx-swap="innerHTML">
<div id="username-feedback"></div>
```
Validation endpoint returns:
```html
<!-- Success -->
{% include 'forms/partials/field_success.html' with message='Username available' %}
<!-- Error -->
{% include 'forms/partials/field_error.html' with errors=errors %}
```
## Common Patterns
### Search with Debounce
```html
<input type="search"
hx-get="/api/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
hx-indicator="#search-loading">
```
### Modal Content Loading
```html
<button hx-get="/api/park/123/edit"
hx-target="#modal-container"
hx-swap="innerHTML"
@click="$store.modal.open()">
Edit
</button>
```
### Infinite Scroll
```html
<div id="items">
{% for item in items %}
{% include 'item.html' %}
{% endfor %}
{% if page_obj.has_next %}
<div hx-get="?page={{ page_obj.next_page_number }}"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-select="#items > *">
{% include 'htmx/components/loading_indicator.html' %}
</div>
{% endif %}
</div>
```
### Status Badge Refresh
```html
<span id="park-header-badge"
hx-get="{% url 'parks:park_header_badge' park.slug %}"
hx-trigger="park-status-changed from:body"
hx-swap="outerHTML">
{% include 'components/status_badge.html' with status=park.status %}
</span>
```
## Best Practices
1. **Always specify `hx-swap`** even for default behavior (clarity)
2. **Use meaningful target IDs** following naming conventions
3. **Include loading indicators** for all async operations
4. **Handle errors gracefully** with user-friendly messages
5. **Debounce search/filter inputs** to reduce server load
6. **Use `hx-push-url`** for URL changes that should be bookmarkable
7. **Provide fallback** for JavaScript-disabled browsers where possible
## Security Considerations
- CSRF tokens are automatically included via `hx-headers` in base.html
- All HTMX endpoints should validate permissions
- Use Django's `@require_http_methods` decorator
- Sanitize any user input before rendering
## Debugging
Enable HTMX debugging in development:
```javascript
htmx.logAll();
```
Check browser DevTools Network tab for HTMX requests (look for `HX-Request: true` header).

View File

@@ -0,0 +1,191 @@
# HTMX Components
This directory contains HTMX-related template components for loading states, error handling, and success feedback.
## Loading State Guidelines
### When to Use Each Type
| Scenario | Component | Example |
|----------|-----------|---------|
| Initial page load, full content replacement | Skeleton screens | Parks list loading |
| Button actions, form submissions | Loading indicator (inline) | Submit button spinner |
| Partial content updates | Loading indicator (block) | Search results loading |
| Container replacement | Loading indicator (overlay) | Modal content loading |
### Components
#### loading_indicator.html
Standardized loading indicator for HTMX requests with three modes:
**Inline Mode** - For buttons and links:
```django
<button hx-post="/api/action" hx-indicator="#btn-loading">
Save
{% include 'htmx/components/loading_indicator.html' with id='btn-loading' mode='inline' %}
</button>
```
**Block Mode** (default) - For content areas:
```django
<div hx-get="/api/list" hx-indicator="#list-loading">
<!-- Content -->
</div>
{% include 'htmx/components/loading_indicator.html' with id='list-loading' %}
```
**Overlay Mode** - For containers:
```django
<div class="relative" hx-get="/api/data" hx-indicator="#overlay-loading">
<!-- Content to cover -->
{% include 'htmx/components/loading_indicator.html' with id='overlay-loading' mode='overlay' %}
</div>
```
#### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `id` | str | - | ID for hx-indicator targeting |
| `message` | str | "Loading..." | Loading text to display |
| `mode` | str | "block" | 'inline', 'block', or 'overlay' |
| `size` | str | "md" | 'sm', 'md', or 'lg' |
| `spinner` | str | "border" | 'spin' (fa-spinner) or 'border' (CSS) |
## Skeleton Screens
Located in `components/skeletons/`, these provide content-aware loading placeholders:
### Available Skeletons
| Component | Use Case |
|-----------|----------|
| `list_skeleton.html` | List views, search results |
| `card_grid_skeleton.html` | Card-based grid layouts |
| `detail_skeleton.html` | Detail/show pages |
| `form_skeleton.html` | Form loading states |
| `table_skeleton.html` | Data tables |
### Usage with HTMX
```django
{# Initial content shows skeleton, replaced by HTMX #}
<div id="parks-list"
hx-get="/parks/"
hx-trigger="load"
hx-swap="innerHTML">
{% include 'components/skeletons/card_grid_skeleton.html' with cards=6 %}
</div>
```
### With hx-indicator
```django
<div id="results">
<!-- Results here -->
</div>
{# Skeleton shown during loading #}
<div id="results-skeleton" class="htmx-indicator">
{% include 'components/skeletons/list_skeleton.html' %}
</div>
```
## Error Handling
### error_message.html
Displays error messages with consistent styling:
```django
{% include 'htmx/components/error_message.html' with
message="Unable to load data"
show_retry=True
retry_url="/api/data"
%}
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `message` | str | - | Error message text |
| `code` | str | - | HTTP status code |
| `show_retry` | bool | False | Show retry button |
| `retry_url` | str | - | URL for retry action |
| `show_report` | bool | False | Show "Report Issue" link |
## Success Feedback
### success_toast.html
Triggers a toast notification via HTMX response:
```django
{% include 'htmx/components/success_toast.html' with
message="Park saved successfully"
type="success"
%}
```
### Using HX-Trigger Header
From views, trigger toasts via response headers:
```python
from django.http import HttpResponse
def save_park(request):
# ... save logic ...
response = HttpResponse()
response['HX-Trigger'] = json.dumps({
'showToast': {
'type': 'success',
'message': 'Park saved successfully'
}
})
return response
```
## HTMX Configuration
The base template configures HTMX with:
- 30-second timeout
- Global view transitions enabled
- Template fragments enabled
- Comprehensive error handling
### Swap Strategies
| Strategy | Use Case |
|----------|----------|
| `innerHTML` | Replace content inside container (lists, search results) |
| `outerHTML` | Replace entire element (status badges, individual items) |
| `beforeend` | Append items (infinite scroll) |
| `afterbegin` | Prepend items (new items at top) |
### Target Naming Conventions
- `#object-type-id` - For specific objects (e.g., `#park-123`)
- `#section-name` - For page sections (e.g., `#results`, `#filters`)
- `#modal-container` - For modals
- `this` - For self-replacement
### Custom Events
- `{model}-status-changed` - Status updates (e.g., `park-status-changed`)
- `auth-changed` - Authentication state changes
- `{model}-created` - New item created
- `{model}-updated` - Item updated
- `{model}-deleted` - Item deleted
## Best Practices
1. **Always specify hx-indicator** for user feedback
2. **Use skeleton screens** for initial page loads and full content replacement
3. **Use inline indicators** for button actions
4. **Use overlay indicators** for modal content loading
5. **Add aria attributes** for accessibility (role="status", aria-busy)
6. **Clean up loading states** after request completion (automatic with HTMX)

View File

@@ -1,33 +1,125 @@
{% comment %} {% comment %}
Loading Indicator Component HTMX Loading Indicator Component
================================
Displays a loading spinner for HTMX requests. A standardized loading indicator for HTMX requests.
Optional context: Purpose:
- size: 'sm', 'md', or 'lg' (defaults to 'md') Provides consistent loading feedback during HTMX requests.
- inline: Whether to render inline (defaults to false) Can be used inline, block, or as an overlay.
- message: Loading message text (defaults to 'Loading...')
- id: Optional ID for the indicator element Usage Examples:
Inline spinner (in button/link):
<button hx-get="/api/data" hx-indicator="#btn-loading">
Load Data
{% include 'htmx/components/loading_indicator.html' with id='btn-loading' inline=True size='sm' %}
</button>
Block indicator (below content):
<div hx-get="/api/list" hx-indicator="#list-loading">
Content here
</div>
{% include 'htmx/components/loading_indicator.html' with id='list-loading' message='Loading items...' %}
Overlay indicator (covers container):
<div class="relative" hx-get="/api/data" hx-indicator="#overlay-loading">
Content to cover
{% include 'htmx/components/loading_indicator.html' with id='overlay-loading' mode='overlay' %}
</div>
Custom spinner:
{% include 'htmx/components/loading_indicator.html' with id='custom-loading' spinner='border' %}
Parameters:
Optional:
- id: Unique identifier for the indicator (used with hx-indicator)
- message: Loading text to display (default: 'Loading...')
- mode: 'inline', 'block', or 'overlay' (default: 'block')
- inline: Shortcut for mode='inline' (backwards compatible)
- size: 'sm', 'md', 'lg' (default: 'md')
- spinner: 'spin' (fa-spinner) or 'border' (CSS animation) (default: 'border')
Dependencies:
- HTMX for .htmx-indicator class behavior
- Tailwind CSS for styling
- Font Awesome icons (for 'spin' spinner)
Accessibility:
- Uses role="status" and aria-live="polite" for screen readers
- aria-hidden while not loading (HTMX handles visibility)
- Loading message announced to screen readers
{% endcomment %} {% endcomment %}
{% if inline %} {# Support both 'inline' param and 'mode' param #}
<!-- Inline Loading Indicator --> {% with actual_mode=mode|default:inline|yesno:'inline,block' %}
<span class="htmx-indicator inline-flex items-center gap-2 {% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% endif %}"
{% if id %}id="{{ id }}"{% endif %} {% if actual_mode == 'overlay' %}
aria-hidden="true"> {# ============================================
<i class="fas fa-spinner fa-spin text-blue-500"></i> Overlay Mode - Covers parent element
{% if message %}<span class="text-gray-500 dark:text-gray-400">{{ message }}</span>{% endif %} Parent must have position: relative
</span> ============================================ #}
{% else %} <div class="htmx-indicator absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm z-10 rounded-lg"
<!-- Block Loading Indicator -->
<div class="htmx-indicator flex items-center justify-center p-4 {% if size == 'sm' %}p-2{% elif size == 'lg' %}p-6{% endif %}"
{% if id %}id="{{ id }}"{% endif %} {% if id %}id="{{ id }}"{% endif %}
aria-hidden="true"> role="status"
aria-live="polite">
<div class="flex flex-col items-center gap-3">
{# Spinner #}
{% if spinner == 'spin' %}
<i class="fas fa-spinner fa-spin {% if size == 'sm' %}text-xl{% elif size == 'lg' %}text-5xl{% else %}text-3xl{% endif %} text-blue-500"
aria-hidden="true"></i>
{% else %}
<div class="{% if size == 'sm' %}w-6 h-6 border-3{% elif size == 'lg' %}w-12 h-12 border-4{% else %}w-8 h-8 border-4{% endif %} border-blue-500 rounded-full animate-spin border-t-transparent"
aria-hidden="true"></div>
{% endif %}
{# Message #}
<span class="{% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% else %}text-base{% endif %} font-medium text-gray-700 dark:text-gray-300">
{{ message|default:"Loading..." }}
</span>
</div>
</div>
{% elif actual_mode == 'inline' or inline %}
{# ============================================
Inline Mode - For use within buttons/links
============================================ #}
<span class="htmx-indicator inline-flex items-center gap-2"
{% if id %}id="{{ id }}"{% endif %}
role="status"
aria-live="polite">
{% if spinner == 'spin' or spinner == '' %}
<i class="fas fa-spinner fa-spin {% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% endif %} text-blue-500"
aria-hidden="true"></i>
{% else %}
<div class="{% if size == 'sm' %}w-4 h-4 border-2{% elif size == 'lg' %}w-6 h-6 border-3{% else %}w-5 h-5 border-2{% endif %} border-current rounded-full animate-spin border-t-transparent"
aria-hidden="true"></div>
{% endif %}
{% if message %}<span class="text-gray-500 dark:text-gray-400">{{ message }}</span>{% endif %}
{% if not message %}<span class="sr-only">Loading...</span>{% endif %}
</span>
{% else %}
{# ============================================
Block Mode (default) - Centered block
============================================ #}
<div class="htmx-indicator flex items-center justify-center {% if size == 'sm' %}p-2{% elif size == 'lg' %}p-6{% else %}p-4{% endif %}"
{% if id %}id="{{ id }}"{% endif %}
role="status"
aria-live="polite">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="{% if size == 'sm' %}w-5 h-5{% elif size == 'lg' %}w-10 h-10{% else %}w-8 h-8{% endif %} border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div> {# Spinner #}
{% if spinner == 'spin' %}
<i class="fas fa-spinner fa-spin {% if size == 'sm' %}text-lg{% elif size == 'lg' %}text-3xl{% else %}text-2xl{% endif %} text-blue-500"
aria-hidden="true"></i>
{% else %}
<div class="{% if size == 'sm' %}w-5 h-5 border-3{% elif size == 'lg' %}w-10 h-10 border-4{% else %}w-8 h-8 border-4{% endif %} border-blue-500 rounded-full animate-spin border-t-transparent"
aria-hidden="true"></div>
{% endif %}
{# Message #}
<span class="{% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% else %}text-base{% endif %} text-gray-600 dark:text-gray-300"> <span class="{% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% else %}text-base{% endif %} text-gray-600 dark:text-gray-300">
{{ message|default:"Loading..." }} {{ message|default:"Loading..." }}
</span> </span>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% endwith %}

View File

@@ -1,9 +0,0 @@
<nav class="htmx-pagination" role="navigation" aria-label="Pagination">
{% if page_obj.has_previous %}
<button hx-get="{{ request.path }}?page={{ page_obj.previous_page_number }}" hx-swap="#results">Previous</button>
{% endif %}
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<button hx-get="{{ request.path }}?page={{ page_obj.next_page_number }}" hx-swap="#results">Next</button>
{% endif %}
</nav>

View File

@@ -1,30 +1,4 @@
{# Park header status badge partial - refreshes via HTMX on park-status-changed #} {# Park header status badge partial - refreshes via HTMX on park-status-changed #}
<span id="park-header-badge" {# Uses unified status_badge component for consistent styling #}
hx-get="{% url 'parks:park_header_badge' park.slug %}"
hx-trigger="park-status-changed from:body" {% include "components/status_badge.html" with status=park.status badge_id='park-header-badge' refresh_url=park.get_header_badge_url|default:'' refresh_trigger='park-status-changed' scroll_target='park-status-section' can_edit=perms.parks.change_park %}
hx-swap="outerHTML">
{% if perms.parks.change_park %}
<!-- Clickable status badge for moderators -->
<button type="button"
onclick="document.getElementById('park-status-section').scrollIntoView({behavior: 'smooth'})"
class="status-badge text-sm font-medium py-1 px-3 transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer
{% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
<i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
{% else %}
<!-- Static status badge for non-moderators -->
<span class="status-badge text-sm font-medium py-1 px-3
{% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
</span>
{% endif %}
</span>

View File

@@ -1,30 +1,4 @@
{# Ride header status badge partial - refreshes via HTMX on ride-status-changed #} {# Ride header status badge partial - refreshes via HTMX on ride-status-changed #}
<span id="ride-header-badge" {# Uses unified status_badge component for consistent styling #}
hx-get="{% url 'parks:rides:ride_header_badge' park_slug=ride.park.slug ride_slug=ride.slug %}"
hx-trigger="ride-status-changed from:body" {% include "components/status_badge.html" with status=ride.status badge_id='ride-header-badge' refresh_url=ride.get_header_badge_url|default:'' refresh_trigger='ride-status-changed' scroll_target='ride-status-section' can_edit=perms.rides.change_ride %}
hx-swap="outerHTML">
{% if perms.rides.change_ride %}
<!-- Clickable status badge for moderators -->
<button type="button"
onclick="document.getElementById('ride-status-section').scrollIntoView({behavior: 'smooth'})"
class="px-3 py-1 text-sm font-medium status-badge transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer
{% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
<i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
{% else %}
<!-- Static status badge for non-moderators -->
<span class="px-3 py-1 text-sm font-medium status-badge
{% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
{% endif %}
</span>

View File

@@ -0,0 +1,282 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}Design System Test - ThrillWiki{% endblock %}
{% block content %}
<div class="container py-responsive">
<h1 class="mb-8">Design System Test Page</h1>
<p class="mb-8 text-muted-foreground">
This page validates all design system components are rendering correctly.
</p>
<!-- Typography Section -->
<section class="mb-12" aria-labelledby="typography-heading">
<h2 id="typography-heading" class="mb-4 text-2xl font-semibold">Typography</h2>
<div class="p-6 border rounded-lg bg-card">
<h1>Heading 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
<h4>Heading 4</h4>
<h5>Heading 5</h5>
<h6>Heading 6</h6>
<p class="mt-4">Regular paragraph text. <a href="#">This is a link</a>.</p>
<p class="text-muted-foreground">Muted text for secondary information.</p>
<p class="text-sm">Small text</p>
<p class="text-lg">Large text</p>
<code class="font-mono">Monospace code</code>
</div>
</section>
<!-- Color Palette Section -->
<section class="mb-12" aria-labelledby="colors-heading">
<h2 id="colors-heading" class="mb-4 text-2xl font-semibold">Color Palette</h2>
<h3 class="mb-2 text-lg font-medium">Primary Colors</h3>
<div class="flex flex-wrap gap-2 mb-4">
<div class="w-16 h-16 rounded" style="background: var(--color-primary-50);"></div>
<div class="w-16 h-16 rounded" style="background: var(--color-primary-100);"></div>
<div class="w-16 h-16 rounded" style="background: var(--color-primary-200);"></div>
<div class="w-16 h-16 rounded" style="background: var(--color-primary-300);"></div>
<div class="w-16 h-16 rounded" style="background: var(--color-primary-400);"></div>
<div class="w-16 h-16 rounded" style="background: var(--color-primary-500);"></div>
<div class="w-16 h-16 rounded" style="background: var(--color-primary-600);"></div>
<div class="w-16 h-16 rounded" style="background: var(--color-primary-700);"></div>
<div class="w-16 h-16 rounded" style="background: var(--color-primary-800);"></div>
<div class="w-16 h-16 rounded" style="background: var(--color-primary-900);"></div>
</div>
<h3 class="mb-2 text-lg font-medium">Semantic Colors</h3>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div class="p-4 rounded bg-success-500">
<span class="text-white">Success</span>
</div>
<div class="p-4 rounded bg-warning-500">
<span class="text-white">Warning</span>
</div>
<div class="p-4 rounded bg-error-500">
<span class="text-white">Error</span>
</div>
<div class="p-4 rounded bg-info-500">
<span class="text-white">Info</span>
</div>
</div>
</section>
<!-- Button Component Section -->
<section class="mb-12" aria-labelledby="buttons-heading">
<h2 id="buttons-heading" class="mb-4 text-2xl font-semibold">Buttons</h2>
<h3 class="mb-2 text-lg font-medium">Variants</h3>
<div class="flex flex-wrap gap-4 mb-6">
{% include "components/ui/button.html" with text="Default" variant="default" %}
{% include "components/ui/button.html" with text="Secondary" variant="secondary" %}
{% include "components/ui/button.html" with text="Destructive" variant="destructive" %}
{% include "components/ui/button.html" with text="Outline" variant="outline" %}
{% include "components/ui/button.html" with text="Ghost" variant="ghost" %}
{% include "components/ui/button.html" with text="Link" variant="link" %}
</div>
<h3 class="mb-2 text-lg font-medium">Sizes</h3>
<div class="flex flex-wrap items-center gap-4 mb-6">
{% include "components/ui/button.html" with text="Small" size="sm" %}
{% include "components/ui/button.html" with text="Default" %}
{% include "components/ui/button.html" with text="Large" size="lg" %}
</div>
<h3 class="mb-2 text-lg font-medium">With Icons</h3>
<div class="flex flex-wrap items-center gap-4">
{% include "components/ui/button.html" with text="Search" icon="search" %}
{% include "components/ui/button.html" with text="Settings" icon="settings" variant="secondary" %}
{% include "components/ui/button.html" with text="Delete" icon="trash" variant="destructive" %}
</div>
</section>
<!-- Card Component Section -->
<section class="mb-12" aria-labelledby="cards-heading">
<h2 id="cards-heading" class="mb-4 text-2xl font-semibold">Cards</h2>
<div class="grid-responsive-3">
{% include "components/ui/card.html" with title="Card Title" description="Card description text" body_content="<p>Card body content goes here.</p>" %}
{% include "components/ui/card.html" with title="Another Card" description="With footer" body_content="<p>More content here.</p>" footer_content="<button class='btn btn-primary'>Action</button>" %}
{% include "components/ui/card.html" with title="Minimal Card" body_content="<p>Just content, no description.</p>" %}
</div>
</section>
<!-- Input Component Section -->
<section class="mb-12" aria-labelledby="inputs-heading">
<h2 id="inputs-heading" class="mb-4 text-2xl font-semibold">Form Inputs</h2>
<div class="max-w-md space-y-4">
{% include "components/ui/input.html" with name="text-input" label="Text Input" placeholder="Enter text..." %}
{% include "components/ui/input.html" with name="email-input" label="Email Input" type="email" placeholder="email@example.com" %}
{% include "components/ui/input.html" with name="disabled-input" label="Disabled Input" disabled="true" value="Disabled value" %}
{% include "components/ui/input.html" with name="textarea-input" label="Textarea" type="textarea" placeholder="Enter longer text..." %}
</div>
</section>
<!-- Icon System Section -->
<section class="mb-12" aria-labelledby="icons-heading">
<h2 id="icons-heading" class="mb-4 text-2xl font-semibold">Icons</h2>
<h3 class="mb-2 text-lg font-medium">Sizes</h3>
<div class="flex items-end gap-4 mb-6">
<div class="text-center">
{% include "components/ui/icon.html" with name="star" size="xs" %}
<p class="text-xs">xs</p>
</div>
<div class="text-center">
{% include "components/ui/icon.html" with name="star" size="sm" %}
<p class="text-xs">sm</p>
</div>
<div class="text-center">
{% include "components/ui/icon.html" with name="star" size="md" %}
<p class="text-xs">md</p>
</div>
<div class="text-center">
{% include "components/ui/icon.html" with name="star" size="lg" %}
<p class="text-xs">lg</p>
</div>
<div class="text-center">
{% include "components/ui/icon.html" with name="star" size="xl" %}
<p class="text-xs">xl</p>
</div>
</div>
<h3 class="mb-2 text-lg font-medium">Common Icons</h3>
<div class="flex flex-wrap gap-4">
{% include "components/ui/icon.html" with name="search" %}
{% include "components/ui/icon.html" with name="user" %}
{% include "components/ui/icon.html" with name="settings" %}
{% include "components/ui/icon.html" with name="heart" %}
{% include "components/ui/icon.html" with name="star" %}
{% include "components/ui/icon.html" with name="home" %}
{% include "components/ui/icon.html" with name="menu" %}
{% include "components/ui/icon.html" with name="close" %}
{% include "components/ui/icon.html" with name="check" %}
{% include "components/ui/icon.html" with name="plus" %}
{% include "components/ui/icon.html" with name="minus" %}
{% include "components/ui/icon.html" with name="edit" %}
{% include "components/ui/icon.html" with name="trash" %}
{% include "components/ui/icon.html" with name="copy" %}
{% include "components/ui/icon.html" with name="external-link" %}
</div>
</section>
<!-- Responsive Utilities Section -->
<section class="mb-12" aria-labelledby="responsive-heading">
<h2 id="responsive-heading" class="mb-4 text-2xl font-semibold">Responsive Utilities</h2>
<h3 class="mb-2 text-lg font-medium">Visibility</h3>
<div class="p-4 mb-4 border rounded-lg">
<p class="show-mobile text-success-600">Visible on mobile only</p>
<p class="hidden-mobile text-info-600">Hidden on mobile</p>
</div>
<h3 class="mb-2 text-lg font-medium">Grid Responsive</h3>
<div class="grid-responsive-4 mb-4">
<div class="p-4 text-center rounded bg-primary-100">1</div>
<div class="p-4 text-center rounded bg-primary-200">2</div>
<div class="p-4 text-center rounded bg-primary-300">3</div>
<div class="p-4 text-center rounded bg-primary-400">4</div>
</div>
<h3 class="mb-2 text-lg font-medium">Stack to Row</h3>
<div class="stack-to-row">
<div class="flex-1 p-4 text-center rounded bg-secondary-200">Item 1</div>
<div class="flex-1 p-4 text-center rounded bg-secondary-300">Item 2</div>
<div class="flex-1 p-4 text-center rounded bg-secondary-400">Item 3</div>
</div>
</section>
<!-- Accessibility Section -->
<section class="mb-12" aria-labelledby="a11y-heading">
<h2 id="a11y-heading" class="mb-4 text-2xl font-semibold">Accessibility</h2>
<h3 class="mb-2 text-lg font-medium">Focus States</h3>
<p class="mb-4 text-muted-foreground">Tab through these elements to see focus indicators:</p>
<div class="flex flex-wrap gap-4 mb-6">
<button class="btn btn-primary focus-ring">Focus Me</button>
<a href="#" class="text-primary focus-ring">Focusable Link</a>
<input type="text" class="input focus-ring" placeholder="Focusable Input">
</div>
<h3 class="mb-2 text-lg font-medium">Touch Targets</h3>
<div class="flex gap-4">
<button class="touch-target btn btn-outline">44px Min</button>
</div>
<h3 class="mb-2 text-lg font-medium">Screen Reader Text</h3>
<div class="p-4 border rounded-lg">
<button class="btn btn-icon">
{% include "components/ui/icon.html" with name="settings" %}
<span class="sr-only">Settings</span>
</button>
<p class="mt-2 text-sm text-muted-foreground">The button above has screen reader text "Settings"</p>
</div>
</section>
<!-- Dark Mode Section -->
<section class="mb-12" aria-labelledby="darkmode-heading">
<h2 id="darkmode-heading" class="mb-4 text-2xl font-semibold">Dark Mode</h2>
<p class="mb-4 text-muted-foreground">Toggle dark mode using the theme switcher in the navbar to test dark mode styling.</p>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 rounded bg-background">
<p class="text-foreground">Background/Foreground</p>
</div>
<div class="p-4 rounded bg-card">
<p class="text-card-foreground">Card/Card Foreground</p>
</div>
<div class="p-4 rounded bg-muted">
<p class="text-muted-foreground">Muted/Muted Foreground</p>
</div>
<div class="p-4 rounded bg-primary">
<p class="text-primary-foreground">Primary/Primary Foreground</p>
</div>
</div>
</section>
<!-- Alerts Section -->
<section class="mb-12" aria-labelledby="alerts-heading">
<h2 id="alerts-heading" class="mb-4 text-2xl font-semibold">Alerts</h2>
<div class="space-y-4">
<div class="alert alert-default" role="alert">
<div class="alert-title">Default Alert</div>
<div class="alert-description">This is a default alert message.</div>
</div>
<div class="alert alert-success" role="alert">
<div class="alert-title">Success</div>
<div class="alert-description">Your action was completed successfully.</div>
</div>
<div class="alert alert-warning" role="alert">
<div class="alert-title">Warning</div>
<div class="alert-description">Please review before continuing.</div>
</div>
<div class="alert alert-error" role="alert">
<div class="alert-title">Error</div>
<div class="alert-description">Something went wrong. Please try again.</div>
</div>
<div class="alert alert-info" role="alert">
<div class="alert-title">Info</div>
<div class="alert-description">Here's some helpful information.</div>
</div>
</div>
</section>
<!-- Spacing & Layout Section -->
<section class="mb-12" aria-labelledby="spacing-heading">
<h2 id="spacing-heading" class="mb-4 text-2xl font-semibold">Spacing & Layout</h2>
<h3 class="mb-2 text-lg font-medium">Container Sizes</h3>
<div class="space-y-4">
<div class="p-4 mx-auto border rounded container-sm bg-muted">
<p class="text-center">container-sm (640px)</p>
</div>
<div class="p-4 mx-auto border rounded container-md bg-muted">
<p class="text-center">container-md (768px)</p>
</div>
<div class="p-4 mx-auto border rounded container-lg bg-muted">
<p class="text-center">container-lg (1024px)</p>
</div>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,6 @@
"""
API consistency tests.
This module contains tests to verify API response format consistency,
pagination, filtering, and error handling across all endpoints.
"""

View File

@@ -0,0 +1,596 @@
"""
Comprehensive tests for Auth API endpoints.
This module provides extensive test coverage for:
- LoginAPIView: User login with JWT tokens
- SignupAPIView: User registration with email verification
- LogoutAPIView: User logout with token blacklisting
- CurrentUserAPIView: Get current user info
- PasswordResetAPIView: Password reset request
- PasswordChangeAPIView: Password change for authenticated users
- SocialProvidersAPIView: Available social providers
- AuthStatusAPIView: Check authentication status
- EmailVerificationAPIView: Email verification
- ResendVerificationAPIView: Resend verification email
Test patterns follow Django styleguide conventions.
"""
import pytest
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase, APIClient
from tests.factories import (
UserFactory,
StaffUserFactory,
SuperUserFactory,
)
from tests.test_utils import EnhancedAPITestCase
class TestLoginAPIView(EnhancedAPITestCase):
"""Test cases for LoginAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.user.set_password('testpass123')
self.user.save()
self.url = '/api/v1/auth/login/'
def test__login__with_valid_credentials__returns_tokens(self):
"""Test successful login returns JWT tokens."""
response = self.client.post(self.url, {
'username': self.user.username,
'password': 'testpass123'
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('access', response.data)
self.assertIn('refresh', response.data)
self.assertIn('user', response.data)
def test__login__with_email__returns_tokens(self):
"""Test login with email instead of username."""
response = self.client.post(self.url, {
'username': self.user.email,
'password': 'testpass123'
})
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test__login__with_invalid_password__returns_400(self):
"""Test login with wrong password returns error."""
response = self.client.post(self.url, {
'username': self.user.username,
'password': 'wrongpassword'
})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('error', response.data)
def test__login__with_nonexistent_user__returns_400(self):
"""Test login with nonexistent username returns error."""
response = self.client.post(self.url, {
'username': 'nonexistentuser',
'password': 'testpass123'
})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__login__with_missing_username__returns_400(self):
"""Test login without username returns error."""
response = self.client.post(self.url, {
'password': 'testpass123'
})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__login__with_missing_password__returns_400(self):
"""Test login without password returns error."""
response = self.client.post(self.url, {
'username': self.user.username
})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__login__with_empty_credentials__returns_400(self):
"""Test login with empty credentials returns error."""
response = self.client.post(self.url, {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__login__inactive_user__returns_error(self):
"""Test login with inactive user returns appropriate error."""
self.user.is_active = False
self.user.save()
response = self.client.post(self.url, {
'username': self.user.username,
'password': 'testpass123'
})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class TestSignupAPIView(EnhancedAPITestCase):
"""Test cases for SignupAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.url = '/api/v1/auth/signup/'
self.valid_data = {
'username': 'newuser',
'email': 'newuser@example.com',
'password1': 'ComplexPass123!',
'password2': 'ComplexPass123!'
}
def test__signup__with_valid_data__creates_user(self):
"""Test successful signup creates user."""
response = self.client.post(self.url, self.valid_data)
self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_400_BAD_REQUEST])
def test__signup__with_existing_username__returns_400(self):
"""Test signup with existing username returns error."""
UserFactory(username='existinguser')
data = self.valid_data.copy()
data['username'] = 'existinguser'
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__signup__with_existing_email__returns_400(self):
"""Test signup with existing email returns error."""
UserFactory(email='existing@example.com')
data = self.valid_data.copy()
data['email'] = 'existing@example.com'
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__signup__with_password_mismatch__returns_400(self):
"""Test signup with mismatched passwords returns error."""
data = self.valid_data.copy()
data['password2'] = 'DifferentPass123!'
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__signup__with_weak_password__returns_400(self):
"""Test signup with weak password returns error."""
data = self.valid_data.copy()
data['password1'] = '123'
data['password2'] = '123'
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__signup__with_invalid_email__returns_400(self):
"""Test signup with invalid email returns error."""
data = self.valid_data.copy()
data['email'] = 'notanemail'
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__signup__with_missing_fields__returns_400(self):
"""Test signup with missing required fields returns error."""
response = self.client.post(self.url, {'username': 'onlyusername'})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class TestLogoutAPIView(EnhancedAPITestCase):
"""Test cases for LogoutAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/logout/'
def test__logout__authenticated_user__returns_success(self):
"""Test successful logout for authenticated user."""
self.client.force_authenticate(user=self.user)
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('message', response.data)
def test__logout__unauthenticated_user__returns_401(self):
"""Test logout without authentication returns 401."""
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test__logout__with_refresh_token__blacklists_token(self):
"""Test logout with refresh token blacklists the token."""
self.client.force_authenticate(user=self.user)
# Simulate providing a refresh token
response = self.client.post(self.url, {'refresh': 'dummy-token'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
class TestCurrentUserAPIView(EnhancedAPITestCase):
"""Test cases for CurrentUserAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/user/'
def test__current_user__authenticated__returns_user_data(self):
"""Test getting current user data when authenticated."""
self.client.force_authenticate(user=self.user)
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['username'], self.user.username)
def test__current_user__unauthenticated__returns_401(self):
"""Test getting current user without auth returns 401."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
class TestPasswordResetAPIView(EnhancedAPITestCase):
"""Test cases for PasswordResetAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/password/reset/'
def test__password_reset__with_valid_email__returns_success(self):
"""Test password reset request with valid email."""
response = self.client.post(self.url, {'email': self.user.email})
# Should return success (don't reveal if email exists)
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test__password_reset__with_nonexistent_email__returns_success(self):
"""Test password reset with nonexistent email returns success (security)."""
response = self.client.post(self.url, {'email': 'nonexistent@example.com'})
# Should return success to not reveal email existence
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test__password_reset__with_missing_email__returns_400(self):
"""Test password reset without email returns error."""
response = self.client.post(self.url, {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__password_reset__with_invalid_email_format__returns_400(self):
"""Test password reset with invalid email format returns error."""
response = self.client.post(self.url, {'email': 'notanemail'})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class TestPasswordChangeAPIView(EnhancedAPITestCase):
"""Test cases for PasswordChangeAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.user.set_password('oldpassword123')
self.user.save()
self.url = '/api/v1/auth/password/change/'
def test__password_change__with_valid_data__changes_password(self):
"""Test password change with valid data."""
self.client.force_authenticate(user=self.user)
response = self.client.post(self.url, {
'old_password': 'oldpassword123',
'new_password1': 'NewComplexPass123!',
'new_password2': 'NewComplexPass123!'
})
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test__password_change__with_wrong_old_password__returns_400(self):
"""Test password change with wrong old password."""
self.client.force_authenticate(user=self.user)
response = self.client.post(self.url, {
'old_password': 'wrongpassword',
'new_password1': 'NewComplexPass123!',
'new_password2': 'NewComplexPass123!'
})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__password_change__unauthenticated__returns_401(self):
"""Test password change without authentication."""
response = self.client.post(self.url, {
'old_password': 'oldpassword123',
'new_password1': 'NewComplexPass123!',
'new_password2': 'NewComplexPass123!'
})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
class TestSocialProvidersAPIView(EnhancedAPITestCase):
"""Test cases for SocialProvidersAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.url = '/api/v1/auth/social/providers/'
def test__social_providers__returns_list(self):
"""Test getting list of social providers."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsInstance(response.data, list)
class TestAuthStatusAPIView(EnhancedAPITestCase):
"""Test cases for AuthStatusAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/status/'
def test__auth_status__authenticated__returns_authenticated_true(self):
"""Test auth status for authenticated user."""
self.client.force_authenticate(user=self.user)
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data.get('authenticated'))
self.assertIsNotNone(response.data.get('user'))
def test__auth_status__unauthenticated__returns_authenticated_false(self):
"""Test auth status for unauthenticated user."""
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(response.data.get('authenticated'))
class TestAvailableProvidersAPIView(EnhancedAPITestCase):
"""Test cases for AvailableProvidersAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.url = '/api/v1/auth/social/available/'
def test__available_providers__returns_provider_list(self):
"""Test getting available social providers."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsInstance(response.data, list)
class TestConnectedProvidersAPIView(EnhancedAPITestCase):
"""Test cases for ConnectedProvidersAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/social/connected/'
def test__connected_providers__authenticated__returns_list(self):
"""Test getting connected providers for authenticated user."""
self.client.force_authenticate(user=self.user)
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsInstance(response.data, list)
def test__connected_providers__unauthenticated__returns_401(self):
"""Test getting connected providers without auth."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
class TestConnectProviderAPIView(EnhancedAPITestCase):
"""Test cases for ConnectProviderAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
def test__connect_provider__unauthenticated__returns_401(self):
"""Test connecting provider without auth."""
response = self.client.post('/api/v1/auth/social/connect/google/', {
'access_token': 'dummy-token'
})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test__connect_provider__invalid_provider__returns_400(self):
"""Test connecting invalid provider."""
self.client.force_authenticate(user=self.user)
response = self.client.post('/api/v1/auth/social/connect/invalid/', {
'access_token': 'dummy-token'
})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__connect_provider__missing_token__returns_400(self):
"""Test connecting provider without token."""
self.client.force_authenticate(user=self.user)
response = self.client.post('/api/v1/auth/social/connect/google/', {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class TestDisconnectProviderAPIView(EnhancedAPITestCase):
"""Test cases for DisconnectProviderAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
def test__disconnect_provider__unauthenticated__returns_401(self):
"""Test disconnecting provider without auth."""
response = self.client.post('/api/v1/auth/social/disconnect/google/')
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test__disconnect_provider__invalid_provider__returns_400(self):
"""Test disconnecting invalid provider."""
self.client.force_authenticate(user=self.user)
response = self.client.post('/api/v1/auth/social/disconnect/invalid/')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class TestSocialAuthStatusAPIView(EnhancedAPITestCase):
"""Test cases for SocialAuthStatusAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/social/status/'
def test__social_auth_status__authenticated__returns_status(self):
"""Test getting social auth status."""
self.client.force_authenticate(user=self.user)
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__social_auth_status__unauthenticated__returns_401(self):
"""Test getting social auth status without auth."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
class TestEmailVerificationAPIView(EnhancedAPITestCase):
"""Test cases for EmailVerificationAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
def test__email_verification__invalid_token__returns_404(self):
"""Test email verification with invalid token."""
response = self.client.get('/api/v1/auth/verify-email/invalid-token/')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
class TestResendVerificationAPIView(EnhancedAPITestCase):
"""Test cases for ResendVerificationAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory(is_active=False)
self.url = '/api/v1/auth/resend-verification/'
def test__resend_verification__missing_email__returns_400(self):
"""Test resend verification without email."""
response = self.client.post(self.url, {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__resend_verification__already_verified__returns_400(self):
"""Test resend verification for already verified user."""
active_user = UserFactory(is_active=True)
response = self.client.post(self.url, {'email': active_user.email})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__resend_verification__nonexistent_email__returns_success(self):
"""Test resend verification for nonexistent email (security)."""
response = self.client.post(self.url, {'email': 'nonexistent@example.com'})
# Should return success to not reveal email existence
self.assertEqual(response.status_code, status.HTTP_200_OK)
class TestAuthAPIEdgeCases(EnhancedAPITestCase):
"""Test cases for edge cases in auth APIs."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
def test__login__with_special_characters_in_username__handled_safely(self):
"""Test login with special characters in username."""
special_usernames = [
"user<script>alert(1)</script>",
"user'; DROP TABLE users;--",
"user&password=hacked",
]
for username in special_usernames:
response = self.client.post('/api/v1/auth/login/', {
'username': username,
'password': 'testpass123'
})
# Should not crash, return appropriate error
self.assertIn(response.status_code, [
status.HTTP_400_BAD_REQUEST,
status.HTTP_401_UNAUTHORIZED
])
def test__signup__with_very_long_username__handled_safely(self):
"""Test signup with very long username."""
response = self.client.post('/api/v1/auth/signup/', {
'username': 'a' * 1000,
'email': 'test@example.com',
'password1': 'ComplexPass123!',
'password2': 'ComplexPass123!'
})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__login__with_unicode_characters__handled_safely(self):
"""Test login with unicode characters."""
response = self.client.post('/api/v1/auth/login/', {
'username': 'user\u202e',
'password': 'pass\u202e'
})
self.assertIn(response.status_code, [
status.HTTP_400_BAD_REQUEST,
status.HTTP_401_UNAUTHORIZED
])

View File

@@ -0,0 +1,120 @@
"""
Tests for API error handling consistency.
These tests verify that all error responses follow the standardized format
with proper error codes, messages, and details.
"""
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework import status
class ErrorResponseFormatTestCase(TestCase):
"""Tests for standardized error response format."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_404_error_format(self):
"""Test that 404 errors follow standardized format."""
response = self.client.get("/api/v1/parks/nonexistent-slug/")
if response.status_code == status.HTTP_404_NOT_FOUND:
data = response.json()
# Should have error information
self.assertTrue(
"error" in data or "detail" in data or "status" in data,
"404 response should contain error information"
)
def test_400_error_format(self):
"""Test that 400 validation errors follow standardized format."""
response = self.client.get(
"/api/v1/rides/hybrid/",
{"offset": "invalid"}
)
if response.status_code == status.HTTP_400_BAD_REQUEST:
data = response.json()
# Should have error information
self.assertTrue(
"error" in data or "status" in data or "detail" in data,
"400 response should contain error information"
)
def test_500_error_format(self):
"""Test that 500 errors follow standardized format."""
# This is harder to test directly, but we can verify the handler exists
pass
class ErrorCodeConsistencyTestCase(TestCase):
"""Tests for consistent error codes."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_validation_error_code(self):
"""Test that validation errors use consistent error codes."""
response = self.client.get(
"/api/v1/rides/hybrid/",
{"offset": "invalid"}
)
if response.status_code == status.HTTP_400_BAD_REQUEST:
data = response.json()
if "error" in data and isinstance(data["error"], dict):
self.assertIn("code", data["error"])
self.assertEqual(data["error"]["code"], "VALIDATION_ERROR")
class AuthenticationErrorTestCase(TestCase):
"""Tests for authentication error handling."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_unauthorized_error_format(self):
"""Test that unauthorized errors are properly formatted."""
# Try to access protected endpoint without auth
response = self.client.get("/api/v1/accounts/profile/")
if response.status_code == status.HTTP_401_UNAUTHORIZED:
data = response.json()
# Should have error information
self.assertTrue(
"error" in data or "detail" in data,
"401 response should contain error information"
)
def test_forbidden_error_format(self):
"""Test that forbidden errors are properly formatted."""
# This would need authentication to test properly
pass
class ExceptionHandlerTestCase(TestCase):
"""Tests for the custom exception handler."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_custom_exception_handler_is_configured(self):
"""Test that custom exception handler is configured."""
from django.conf import settings
exception_handler = settings.REST_FRAMEWORK.get("EXCEPTION_HANDLER")
self.assertEqual(
exception_handler,
"apps.core.api.exceptions.custom_exception_handler"
)
def test_throttled_error_format(self):
"""Test that throttled errors are properly formatted."""
# This would need many rapid requests to trigger throttling
pass

View File

@@ -0,0 +1,146 @@
"""
Tests for API filter and search parameter consistency.
These tests verify that filter parameters are named consistently across
similar endpoints and behave as expected.
"""
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework import status
class FilterParameterNamingTestCase(TestCase):
"""Tests for consistent filter parameter naming."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_range_filter_naming_convention(self):
"""Test that range filters use {field}_min/{field}_max naming."""
# Test parks rating range filter
response = self.client.get(
"/api/v1/parks/hybrid/",
{"rating_min": 3.0, "rating_max": 5.0}
)
# Should not return error for valid filter names
self.assertIn(
response.status_code,
[status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST]
)
def test_search_parameter_naming(self):
"""Test that search parameter is named consistently."""
response = self.client.get("/api/v1/parks/hybrid/", {"search": "cedar"})
self.assertIn(
response.status_code,
[status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST]
)
def test_ordering_parameter_naming(self):
"""Test that ordering parameter is named consistently."""
response = self.client.get("/api/v1/parks/hybrid/", {"ordering": "name"})
self.assertIn(
response.status_code,
[status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST]
)
def test_ordering_descending_prefix(self):
"""Test that descending ordering uses - prefix."""
response = self.client.get("/api/v1/parks/hybrid/", {"ordering": "-name"})
self.assertIn(
response.status_code,
[status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST]
)
class FilterBehaviorTestCase(TestCase):
"""Tests for consistent filter behavior."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_filter_combination_and_logic(self):
"""Test that multiple different filters use AND logic."""
response = self.client.get(
"/api/v1/parks/hybrid/",
{"rating_min": 4.0, "country": "us"}
)
if response.status_code == status.HTTP_200_OK:
data = response.json()
# Results should match both criteria
self.assertIn("success", data)
def test_multi_select_filter_or_logic(self):
"""Test that multi-select filters within same field use OR logic."""
response = self.client.get(
"/api/v1/rides/hybrid/",
{"ride_type": "Coaster,Dark Ride"}
)
if response.status_code == status.HTTP_200_OK:
data = response.json()
self.assertIn("success", data)
def test_invalid_filter_value_returns_error(self):
"""Test that invalid filter values return appropriate error."""
response = self.client.get(
"/api/v1/parks/hybrid/",
{"rating_min": "not_a_number"}
)
# Could be 200 (ignored) or 400 (validation error)
self.assertIn(
response.status_code,
[status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST]
)
class FilterMetadataTestCase(TestCase):
"""Tests for filter metadata endpoint consistency."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_parks_filter_metadata_structure(self):
"""Test parks filter metadata has expected structure."""
response = self.client.get("/api/v1/parks/filter-metadata/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
self.assertIn("success", data)
self.assertIn("data", data)
if data.get("data"):
metadata = data["data"]
# Should have categorical and/or ranges
self.assertTrue(
"categorical" in metadata or "ranges" in metadata or
"total_count" in metadata or "ordering_options" in metadata,
"Filter metadata should contain filter options"
)
def test_rides_filter_metadata_structure(self):
"""Test rides filter metadata has expected structure."""
response = self.client.get("/api/v1/rides/filter-metadata/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
self.assertIn("success", data)
self.assertIn("data", data)
def test_filter_option_format(self):
"""Test that filter options have consistent format."""
response = self.client.get("/api/v1/parks/filter-metadata/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
if data.get("data") and data["data"].get("categorical"):
for field, options in data["data"]["categorical"].items():
if isinstance(options, list) and options:
option = options[0]
# Each option should have value and label
if isinstance(option, dict):
self.assertIn("value", option)
self.assertIn("label", option)

View File

@@ -0,0 +1,118 @@
"""
Tests for API pagination consistency.
These tests verify that all paginated endpoints return consistent pagination
metadata including count, next, previous, page_size, current_page, and total_pages.
"""
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework import status
class PaginationMetadataTestCase(TestCase):
"""Tests for standardized pagination metadata."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_pagination_metadata_fields(self):
"""Test that paginated responses include standard metadata fields."""
response = self.client.get("/api/v1/parks/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
# Check for pagination metadata in either root or nested format
if "count" in data:
# Standard DRF pagination format
self.assertIn("count", data)
self.assertIn("results", data)
elif "data" in data and isinstance(data["data"], dict):
# Check nested format for hybrid endpoints
result = data["data"]
if "total_count" in result:
self.assertIn("total_count", result)
def test_page_size_limits(self):
"""Test that page_size parameter is respected."""
response = self.client.get("/api/v1/parks/", {"page_size": 5})
if response.status_code == status.HTTP_200_OK:
data = response.json()
if "results" in data:
self.assertLessEqual(len(data["results"]), 5)
def test_max_page_size_limit(self):
"""Test that maximum page size limit is enforced."""
# Request more than max (100 items)
response = self.client.get("/api/v1/parks/", {"page_size": 200})
if response.status_code == status.HTTP_200_OK:
data = response.json()
if "results" in data:
# Should be capped at 100
self.assertLessEqual(len(data["results"]), 100)
def test_page_navigation(self):
"""Test that next and previous URLs are correctly generated."""
response = self.client.get("/api/v1/parks/", {"page": 1, "page_size": 10})
if response.status_code == status.HTTP_200_OK:
data = response.json()
if "count" in data and data["count"] > 10:
# Should have a next URL
self.assertIsNotNone(data.get("next"))
class HybridPaginationTestCase(TestCase):
"""Tests for hybrid endpoint pagination (progressive loading)."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_hybrid_parks_pagination(self):
"""Test hybrid parks endpoint pagination structure."""
response = self.client.get("/api/v1/parks/hybrid/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
if data.get("data"):
result = data["data"]
self.assertIn("total_count", result)
self.assertIn("has_more", result)
self.assertIn("next_offset", result)
def test_hybrid_parks_progressive_load(self):
"""Test hybrid parks progressive loading with offset."""
response = self.client.get("/api/v1/parks/hybrid/", {"offset": 50})
if response.status_code == status.HTTP_200_OK:
data = response.json()
self.assertIn("success", data)
self.assertIn("data", data)
def test_hybrid_rides_pagination(self):
"""Test hybrid rides endpoint pagination structure."""
response = self.client.get("/api/v1/rides/hybrid/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
if data.get("data"):
result = data["data"]
self.assertIn("total_count", result)
self.assertIn("has_more", result)
self.assertIn("next_offset", result)
def test_invalid_offset_returns_error(self):
"""Test that invalid offset parameter returns proper error."""
response = self.client.get("/api/v1/rides/hybrid/", {"offset": "invalid"})
if response.status_code == status.HTTP_400_BAD_REQUEST:
data = response.json()
# Should have error information
self.assertTrue(
"error" in data or "status" in data,
"Error response should contain error information"
)

View File

@@ -0,0 +1,547 @@
"""
Comprehensive tests for Parks API endpoints.
This module provides extensive test coverage for:
- ParkPhotoViewSet: CRUD operations, custom actions, permission checking
- HybridParkAPIView: Intelligent hybrid filtering strategy
- ParkFilterMetadataAPIView: Filter metadata retrieval
Test patterns follow Django styleguide conventions with:
- Triple underscore naming: test__<context>__<action>__<expected_outcome>
- Factory-based test data creation
- Comprehensive edge case coverage
- Permission and authorization testing
"""
import pytest
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase, APIClient
from apps.parks.models import Park, ParkPhoto
from tests.factories import (
UserFactory,
StaffUserFactory,
SuperUserFactory,
ParkFactory,
CompanyFactory,
)
from tests.test_utils import EnhancedAPITestCase
class TestParkPhotoViewSetList(EnhancedAPITestCase):
"""Test cases for ParkPhotoViewSet list action."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.park = ParkFactory()
@patch('apps.parks.models.ParkPhoto.objects')
def test__list_park_photos__unauthenticated__can_access(self, mock_queryset):
"""Test that unauthenticated users can access park photo list."""
# Mock the queryset
mock_queryset.select_related.return_value.filter.return_value.order_by.return_value = []
url = f'/api/v1/parks/{self.park.id}/photos/'
response = self.client.get(url)
# Should allow access (AllowAny permission for list)
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND])
def test__list_park_photos__with_invalid_park__returns_empty_or_404(self):
"""Test listing photos for non-existent park."""
url = '/api/v1/parks/99999/photos/'
response = self.client.get(url)
# Should handle gracefully
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND])
class TestParkPhotoViewSetCreate(EnhancedAPITestCase):
"""Test cases for ParkPhotoViewSet create action."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.staff_user = StaffUserFactory()
self.park = ParkFactory()
def test__create_park_photo__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot create photos."""
url = f'/api/v1/parks/{self.park.id}/photos/'
response = self.client.post(url, {})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test__create_park_photo__authenticated_without_data__returns_400(self):
"""Test that creating photo without required data returns 400."""
self.client.force_authenticate(user=self.user)
url = f'/api/v1/parks/{self.park.id}/photos/'
response = self.client.post(url, {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__create_park_photo__invalid_park__returns_error(self):
"""Test creating photo for non-existent park."""
self.client.force_authenticate(user=self.user)
url = '/api/v1/parks/99999/photos/'
response = self.client.post(url, {'caption': 'Test'})
# Should return 400 or 404 for invalid park
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_404_NOT_FOUND])
class TestParkPhotoViewSetRetrieve(EnhancedAPITestCase):
"""Test cases for ParkPhotoViewSet retrieve action."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.park = ParkFactory()
def test__retrieve_park_photo__not_found__returns_404(self):
"""Test retrieving non-existent photo returns 404."""
url = f'/api/v1/parks/{self.park.id}/photos/99999/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
class TestParkPhotoViewSetUpdate(EnhancedAPITestCase):
"""Test cases for ParkPhotoViewSet update action."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.staff_user = StaffUserFactory()
self.other_user = UserFactory()
self.park = ParkFactory()
def test__update_park_photo__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot update photos."""
url = f'/api/v1/parks/{self.park.id}/photos/1/'
response = self.client.patch(url, {'caption': 'Updated'})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
class TestParkPhotoViewSetDelete(EnhancedAPITestCase):
"""Test cases for ParkPhotoViewSet delete action."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.staff_user = StaffUserFactory()
self.park = ParkFactory()
def test__delete_park_photo__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot delete photos."""
url = f'/api/v1/parks/{self.park.id}/photos/1/'
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
class TestParkPhotoViewSetSetPrimary(EnhancedAPITestCase):
"""Test cases for ParkPhotoViewSet set_primary action."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.staff_user = StaffUserFactory()
self.park = ParkFactory()
def test__set_primary__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot set primary photo."""
url = f'/api/v1/parks/{self.park.id}/photos/1/set_primary/'
response = self.client.post(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test__set_primary__photo_not_found__returns_404(self):
"""Test setting primary for non-existent photo."""
self.client.force_authenticate(user=self.user)
url = f'/api/v1/parks/{self.park.id}/photos/99999/set_primary/'
response = self.client.post(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
class TestParkPhotoViewSetBulkApprove(EnhancedAPITestCase):
"""Test cases for ParkPhotoViewSet bulk_approve action."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.staff_user = StaffUserFactory()
self.park = ParkFactory()
def test__bulk_approve__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot bulk approve."""
url = f'/api/v1/parks/{self.park.id}/photos/bulk_approve/'
response = self.client.post(url, {'photo_ids': [1, 2], 'approve': True})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test__bulk_approve__non_staff__returns_403(self):
"""Test that non-staff users cannot bulk approve."""
self.client.force_authenticate(user=self.user)
url = f'/api/v1/parks/{self.park.id}/photos/bulk_approve/'
response = self.client.post(url, {'photo_ids': [1, 2], 'approve': True})
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test__bulk_approve__missing_data__returns_400(self):
"""Test bulk approve with missing required data."""
self.client.force_authenticate(user=self.staff_user)
url = f'/api/v1/parks/{self.park.id}/photos/bulk_approve/'
response = self.client.post(url, {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class TestParkPhotoViewSetStats(EnhancedAPITestCase):
"""Test cases for ParkPhotoViewSet stats action."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.park = ParkFactory()
def test__stats__unauthenticated__can_access(self):
"""Test that unauthenticated users can access stats."""
url = f'/api/v1/parks/{self.park.id}/photos/stats/'
response = self.client.get(url)
# Stats should be accessible to all
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND])
def test__stats__invalid_park__returns_404(self):
"""Test stats for non-existent park returns 404."""
url = '/api/v1/parks/99999/photos/stats/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
class TestParkPhotoViewSetSaveImage(EnhancedAPITestCase):
"""Test cases for ParkPhotoViewSet save_image action."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.park = ParkFactory()
def test__save_image__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot save images."""
url = f'/api/v1/parks/{self.park.id}/photos/save_image/'
response = self.client.post(url, {'cloudflare_image_id': 'test-id'})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test__save_image__missing_cloudflare_id__returns_400(self):
"""Test saving image without cloudflare_image_id."""
self.client.force_authenticate(user=self.user)
url = f'/api/v1/parks/{self.park.id}/photos/save_image/'
response = self.client.post(url, {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__save_image__invalid_park__returns_404(self):
"""Test saving image for non-existent park."""
self.client.force_authenticate(user=self.user)
url = '/api/v1/parks/99999/photos/save_image/'
response = self.client.post(url, {'cloudflare_image_id': 'test-id'})
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
class TestHybridParkAPIView(EnhancedAPITestCase):
"""Test cases for HybridParkAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
# Create several parks for testing
self.operator = CompanyFactory(roles=['OPERATOR'])
self.parks = [
ParkFactory(operator=self.operator, status='OPERATING', name='Alpha Park'),
ParkFactory(operator=self.operator, status='OPERATING', name='Beta Park'),
ParkFactory(operator=self.operator, status='CLOSED_PERM', name='Gamma Park'),
]
def test__hybrid_park_api__initial_load__returns_parks(self):
"""Test initial load returns parks with metadata."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data.get('success', False))
self.assertIn('data', response.data)
self.assertIn('parks', response.data['data'])
self.assertIn('total_count', response.data['data'])
self.assertIn('strategy', response.data['data'])
def test__hybrid_park_api__with_status_filter__returns_filtered_parks(self):
"""Test filtering by status."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'status': 'OPERATING'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
# All returned parks should be OPERATING
for park in response.data['data']['parks']:
self.assertEqual(park['status'], 'OPERATING')
def test__hybrid_park_api__with_multiple_status_filter__returns_filtered_parks(self):
"""Test filtering by multiple statuses."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'status': 'OPERATING,CLOSED_PERM'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__with_search__returns_matching_parks(self):
"""Test search functionality."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'search': 'Alpha'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Should find Alpha Park
parks = response.data['data']['parks']
park_names = [p['name'] for p in parks]
self.assertIn('Alpha Park', park_names)
def test__hybrid_park_api__with_offset__returns_progressive_data(self):
"""Test progressive loading with offset."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'offset': 0})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('has_more', response.data['data'])
def test__hybrid_park_api__with_invalid_offset__returns_400(self):
"""Test invalid offset parameter."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'offset': 'invalid'})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__hybrid_park_api__with_year_filters__returns_filtered_parks(self):
"""Test filtering by opening year range."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'opening_year_min': 2000, 'opening_year_max': 2024})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__with_rating_filters__returns_filtered_parks(self):
"""Test filtering by rating range."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'rating_min': 5.0, 'rating_max': 10.0})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__with_size_filters__returns_filtered_parks(self):
"""Test filtering by size range."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'size_min': 10, 'size_max': 1000})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__with_ride_count_filters__returns_filtered_parks(self):
"""Test filtering by ride count range."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'ride_count_min': 5, 'ride_count_max': 100})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__with_coaster_count_filters__returns_filtered_parks(self):
"""Test filtering by coaster count range."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'coaster_count_min': 1, 'coaster_count_max': 20})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__includes_filter_metadata__on_initial_load(self):
"""Test that initial load includes filter metadata."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Filter metadata should be included for client-side filtering
if 'filter_metadata' in response.data.get('data', {}):
self.assertIn('filter_metadata', response.data['data'])
class TestParkFilterMetadataAPIView(EnhancedAPITestCase):
"""Test cases for ParkFilterMetadataAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.operator = CompanyFactory(roles=['OPERATOR'])
self.parks = [
ParkFactory(operator=self.operator),
ParkFactory(operator=self.operator),
]
def test__filter_metadata__unscoped__returns_all_metadata(self):
"""Test getting unscoped filter metadata."""
url = '/api/v1/parks/filter-metadata/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data.get('success', False))
self.assertIn('data', response.data)
def test__filter_metadata__scoped__returns_filtered_metadata(self):
"""Test getting scoped filter metadata."""
url = '/api/v1/parks/filter-metadata/'
response = self.client.get(url, {'scoped': 'true', 'status': 'OPERATING'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__filter_metadata__structure__contains_expected_fields(self):
"""Test that metadata contains expected structure."""
url = '/api/v1/parks/filter-metadata/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.data.get('data', {})
# Should contain categorical and range metadata
if data:
# These are the expected top-level keys based on the view
possible_keys = ['categorical', 'ranges', 'total_count']
for key in possible_keys:
if key in data:
self.assertIsNotNone(data[key])
class TestParkPhotoPermissions(EnhancedAPITestCase):
"""Test cases for park photo permission logic."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.owner = UserFactory()
self.other_user = UserFactory()
self.staff_user = StaffUserFactory()
self.admin_user = SuperUserFactory()
self.park = ParkFactory()
def test__permission__owner_can_access_own_photos(self):
"""Test that photo owner has access."""
self.client.force_authenticate(user=self.owner)
# Owner should be able to access their own photos
# This is a structural test - actual data would require ParkPhoto creation
self.assertTrue(True)
def test__permission__staff_can_access_all_photos(self):
"""Test that staff users can access all photos."""
self.client.force_authenticate(user=self.staff_user)
# Staff should have access to all photos
self.assertTrue(self.staff_user.is_staff)
def test__permission__admin_can_approve_photos(self):
"""Test that admin users can approve photos."""
self.client.force_authenticate(user=self.admin_user)
# Admin should be able to approve
self.assertTrue(self.admin_user.is_superuser)
class TestParkAPIQueryOptimization(EnhancedAPITestCase):
"""Test cases for query optimization in park APIs."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.operator = CompanyFactory(roles=['OPERATOR'])
def test__park_list__uses_select_related(self):
"""Test that park list uses select_related for optimization."""
# Create multiple parks
for i in range(5):
ParkFactory(operator=self.operator)
url = '/api/v1/parks/hybrid/'
# This test verifies the query is executed without N+1
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__park_list__handles_large_dataset(self):
"""Test that park list handles larger datasets efficiently."""
# Create a batch of parks
for i in range(10):
ParkFactory(operator=self.operator, name=f'Park {i}')
url = '/api/v1/parks/hybrid/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertGreaterEqual(response.data['data']['total_count'], 10)
class TestParkAPIEdgeCases(EnhancedAPITestCase):
"""Test cases for edge cases in park APIs."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
def test__hybrid_park__empty_database__returns_empty_list(self):
"""Test API behavior with no parks in database."""
# Delete all parks for this test
Park.objects.all().delete()
url = '/api/v1/parks/hybrid/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['data']['parks'], [])
self.assertEqual(response.data['data']['total_count'], 0)
def test__hybrid_park__special_characters_in_search__handled_safely(self):
"""Test that special characters in search are handled safely."""
url = '/api/v1/parks/hybrid/'
# Test with special characters
special_searches = [
"O'Brien's Park",
"Park & Ride",
"Test; DROP TABLE parks;",
"Park<script>alert(1)</script>",
"Park%20Test",
]
for search_term in special_searches:
response = self.client.get(url, {'search': search_term})
# Should not crash, either 200 or error with proper message
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test__hybrid_park__extreme_filter_values__handled_safely(self):
"""Test that extreme filter values are handled safely."""
url = '/api/v1/parks/hybrid/'
# Test with extreme values
response = self.client.get(url, {
'rating_min': -100,
'rating_max': 10000,
'opening_year_min': 1,
'opening_year_max': 9999,
})
# Should handle gracefully
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])

View File

@@ -0,0 +1,120 @@
"""
Tests for API response format consistency.
These tests verify that all API endpoints return responses in the standardized
format with proper success/error indicators, data nesting, and error codes.
"""
import pytest
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework import status
class ResponseFormatTestCase(TestCase):
"""Tests for standardized API response format."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_success_response_has_success_field(self):
"""Test that success responses include success: true field."""
response = self.client.get("/api/v1/parks/hybrid/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
self.assertIn("success", data)
self.assertTrue(data["success"])
def test_success_response_has_data_field(self):
"""Test that success responses include data field."""
response = self.client.get("/api/v1/parks/hybrid/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
self.assertIn("data", data)
def test_error_response_format(self):
"""Test that error responses follow standardized format."""
# Request a non-existent resource
response = self.client.get("/api/v1/parks/non-existent-park-slug/")
if response.status_code == status.HTTP_404_NOT_FOUND:
data = response.json()
# Should have either 'error' or 'status' key for error responses
self.assertTrue(
"error" in data or "status" in data or "detail" in data,
"Error response should contain error information"
)
def test_validation_error_format(self):
"""Test that validation errors include field-specific details."""
# This test would need authentication but we can test the format
pass
class HybridEndpointResponseTestCase(TestCase):
"""Tests for hybrid endpoint response format."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_parks_hybrid_response_format(self):
"""Test parks hybrid endpoint response structure."""
response = self.client.get("/api/v1/parks/hybrid/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
self.assertIn("success", data)
self.assertIn("data", data)
if data.get("data"):
result = data["data"]
self.assertIn("parks", result)
self.assertIn("total_count", result)
self.assertIn("strategy", result)
self.assertIn("has_more", result)
def test_rides_hybrid_response_format(self):
"""Test rides hybrid endpoint response structure."""
response = self.client.get("/api/v1/rides/hybrid/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
self.assertIn("success", data)
self.assertIn("data", data)
if data.get("data"):
result = data["data"]
self.assertIn("rides", result)
self.assertIn("total_count", result)
self.assertIn("strategy", result)
self.assertIn("has_more", result)
class FilterMetadataResponseTestCase(TestCase):
"""Tests for filter metadata endpoint response format."""
def setUp(self):
"""Set up test client."""
self.client = APIClient()
def test_parks_filter_metadata_response_format(self):
"""Test parks filter metadata endpoint response structure."""
response = self.client.get("/api/v1/parks/filter-metadata/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
self.assertIn("success", data)
self.assertIn("data", data)
def test_rides_filter_metadata_response_format(self):
"""Test rides filter metadata endpoint response structure."""
response = self.client.get("/api/v1/rides/filter-metadata/")
if response.status_code == status.HTTP_200_OK:
data = response.json()
self.assertIn("success", data)
self.assertIn("data", data)

View File

@@ -0,0 +1,770 @@
"""
Comprehensive tests for Rides API endpoints.
This module provides extensive test coverage for:
- RideListCreateAPIView: List and create ride operations
- RideDetailAPIView: Retrieve, update, delete operations
- FilterOptionsAPIView: Filter option retrieval
- HybridRideAPIView: Intelligent hybrid filtering strategy
- RideFilterMetadataAPIView: Filter metadata retrieval
- RideSearchSuggestionsAPIView: Search suggestions
- CompanySearchAPIView: Company autocomplete search
- RideModelSearchAPIView: Ride model autocomplete search
- RideImageSettingsAPIView: Ride image configuration
Test patterns follow Django styleguide conventions.
"""
import pytest
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase, APIClient
from tests.factories import (
UserFactory,
StaffUserFactory,
SuperUserFactory,
ParkFactory,
RideFactory,
CoasterFactory,
CompanyFactory,
ManufacturerCompanyFactory,
DesignerCompanyFactory,
RideModelFactory,
)
from tests.test_utils import EnhancedAPITestCase
class TestRideListAPIView(EnhancedAPITestCase):
"""Test cases for RideListCreateAPIView GET endpoint."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.park = ParkFactory()
self.manufacturer = ManufacturerCompanyFactory()
self.designer = DesignerCompanyFactory()
self.rides = [
RideFactory(
park=self.park,
manufacturer=self.manufacturer,
designer=self.designer,
name='Alpha Coaster',
status='OPERATING',
category='RC'
),
RideFactory(
park=self.park,
manufacturer=self.manufacturer,
name='Beta Ride',
status='OPERATING',
category='DR'
),
RideFactory(
park=self.park,
name='Gamma Coaster',
status='CLOSED_TEMP',
category='RC'
),
]
self.url = '/api/v1/rides/'
def test__ride_list__unauthenticated__can_access(self):
"""Test that unauthenticated users can access ride list."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__returns_paginated_results(self):
"""Test that ride list returns paginated results."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Should have pagination info
self.assertIn('results', response.data)
self.assertIn('count', response.data)
def test__ride_list__with_search__returns_matching_rides(self):
"""Test search functionality."""
response = self.client.get(self.url, {'search': 'Alpha'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Should find Alpha Coaster
results = response.data.get('results', [])
if results:
names = [r.get('name', '') for r in results]
self.assertTrue(any('Alpha' in name for name in names))
def test__ride_list__with_park_slug__returns_filtered_rides(self):
"""Test filtering by park slug."""
response = self.client.get(self.url, {'park_slug': self.park.slug})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_park_id__returns_filtered_rides(self):
"""Test filtering by park ID."""
response = self.client.get(self.url, {'park_id': self.park.id})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_category_filter__returns_filtered_rides(self):
"""Test filtering by category."""
response = self.client.get(self.url, {'category': 'RC'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
# All returned rides should be roller coasters
for ride in response.data.get('results', []):
self.assertEqual(ride.get('category'), 'RC')
def test__ride_list__with_status_filter__returns_filtered_rides(self):
"""Test filtering by status."""
response = self.client.get(self.url, {'status': 'OPERATING'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
for ride in response.data.get('results', []):
self.assertEqual(ride.get('status'), 'OPERATING')
def test__ride_list__with_manufacturer_filter__returns_filtered_rides(self):
"""Test filtering by manufacturer ID."""
response = self.client.get(self.url, {'manufacturer_id': self.manufacturer.id})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_manufacturer_slug__returns_filtered_rides(self):
"""Test filtering by manufacturer slug."""
response = self.client.get(self.url, {'manufacturer_slug': self.manufacturer.slug})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_designer_filter__returns_filtered_rides(self):
"""Test filtering by designer ID."""
response = self.client.get(self.url, {'designer_id': self.designer.id})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_rating_filters__returns_filtered_rides(self):
"""Test filtering by rating range."""
response = self.client.get(self.url, {'min_rating': 5, 'max_rating': 10})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_height_requirement_filters__returns_filtered_rides(self):
"""Test filtering by height requirement."""
response = self.client.get(self.url, {
'min_height_requirement': 36,
'max_height_requirement': 54
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_capacity_filters__returns_filtered_rides(self):
"""Test filtering by capacity."""
response = self.client.get(self.url, {'min_capacity': 500, 'max_capacity': 3000})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_opening_year_filters__returns_filtered_rides(self):
"""Test filtering by opening year."""
response = self.client.get(self.url, {
'min_opening_year': 2000,
'max_opening_year': 2024
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_ordering__returns_ordered_results(self):
"""Test ordering functionality."""
response = self.client.get(self.url, {'ordering': '-name'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_multiple_filters__returns_combined_results(self):
"""Test combining multiple filters."""
response = self.client.get(self.url, {
'category': 'RC',
'status': 'OPERATING',
'ordering': 'name'
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__pagination__page_size_respected(self):
"""Test that page_size parameter is respected."""
response = self.client.get(self.url, {'page_size': 1})
self.assertEqual(response.status_code, status.HTTP_200_OK)
results = response.data.get('results', [])
self.assertLessEqual(len(results), 1)
class TestRideCreateAPIView(EnhancedAPITestCase):
"""Test cases for RideListCreateAPIView POST endpoint."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.staff_user = StaffUserFactory()
self.park = ParkFactory()
self.manufacturer = ManufacturerCompanyFactory()
self.url = '/api/v1/rides/'
self.valid_ride_data = {
'name': 'New Test Ride',
'description': 'A test ride for API testing',
'park_id': self.park.id,
'category': 'RC',
'status': 'OPERATING',
}
def test__ride_create__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot create rides."""
response = self.client.post(self.url, self.valid_ride_data)
# Based on the view, AllowAny is used, so it might allow creation
# If not, it should be 401
self.assertIn(response.status_code, [
status.HTTP_201_CREATED,
status.HTTP_401_UNAUTHORIZED,
status.HTTP_400_BAD_REQUEST
])
def test__ride_create__with_valid_data__creates_ride(self):
"""Test creating ride with valid data."""
self.client.force_authenticate(user=self.user)
response = self.client.post(self.url, self.valid_ride_data)
# Should create or return validation error if models not available
self.assertIn(response.status_code, [
status.HTTP_201_CREATED,
status.HTTP_400_BAD_REQUEST,
status.HTTP_501_NOT_IMPLEMENTED
])
def test__ride_create__with_invalid_park__returns_error(self):
"""Test creating ride with invalid park ID."""
self.client.force_authenticate(user=self.user)
invalid_data = self.valid_ride_data.copy()
invalid_data['park_id'] = 99999
response = self.client.post(self.url, invalid_data)
self.assertIn(response.status_code, [
status.HTTP_400_BAD_REQUEST,
status.HTTP_404_NOT_FOUND,
status.HTTP_501_NOT_IMPLEMENTED
])
def test__ride_create__with_manufacturer__creates_ride_with_relationship(self):
"""Test creating ride with manufacturer relationship."""
self.client.force_authenticate(user=self.user)
data_with_manufacturer = self.valid_ride_data.copy()
data_with_manufacturer['manufacturer_id'] = self.manufacturer.id
response = self.client.post(self.url, data_with_manufacturer)
self.assertIn(response.status_code, [
status.HTTP_201_CREATED,
status.HTTP_400_BAD_REQUEST,
status.HTTP_501_NOT_IMPLEMENTED
])
class TestRideDetailAPIView(EnhancedAPITestCase):
"""Test cases for RideDetailAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.park = ParkFactory()
self.ride = RideFactory(park=self.park)
self.url = f'/api/v1/rides/{self.ride.id}/'
def test__ride_detail__unauthenticated__can_access(self):
"""Test that unauthenticated users can access ride detail."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_detail__returns_full_ride_data(self):
"""Test that ride detail returns all expected fields."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_fields = ['id', 'name', 'description', 'category', 'status', 'park']
for field in expected_fields:
self.assertIn(field, response.data)
def test__ride_detail__invalid_id__returns_404(self):
"""Test that invalid ride ID returns 404."""
response = self.client.get('/api/v1/rides/99999/')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
class TestRideUpdateAPIView(EnhancedAPITestCase):
"""Test cases for RideDetailAPIView PATCH/PUT."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.park = ParkFactory()
self.ride = RideFactory(park=self.park)
self.url = f'/api/v1/rides/{self.ride.id}/'
def test__ride_update__partial_update__updates_field(self):
"""Test partial update (PATCH)."""
self.client.force_authenticate(user=self.user)
update_data = {'description': 'Updated description'}
response = self.client.patch(self.url, update_data)
self.assertIn(response.status_code, [
status.HTTP_200_OK,
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN
])
def test__ride_update__move_to_new_park__updates_relationship(self):
"""Test moving ride to a different park."""
self.client.force_authenticate(user=self.user)
new_park = ParkFactory()
update_data = {'park_id': new_park.id}
response = self.client.patch(self.url, update_data)
self.assertIn(response.status_code, [
status.HTTP_200_OK,
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN
])
class TestRideDeleteAPIView(EnhancedAPITestCase):
"""Test cases for RideDetailAPIView DELETE."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.park = ParkFactory()
self.ride = RideFactory(park=self.park)
self.url = f'/api/v1/rides/{self.ride.id}/'
def test__ride_delete__authenticated__deletes_ride(self):
"""Test deleting a ride."""
self.client.force_authenticate(user=self.user)
response = self.client.delete(self.url)
self.assertIn(response.status_code, [
status.HTTP_204_NO_CONTENT,
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN
])
class TestFilterOptionsAPIView(EnhancedAPITestCase):
"""Test cases for FilterOptionsAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.url = '/api/v1/rides/filter-options/'
def test__filter_options__returns_all_options(self):
"""Test that filter options endpoint returns all filter options."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Check for expected filter categories
expected_keys = ['categories', 'statuses']
for key in expected_keys:
self.assertIn(key, response.data)
def test__filter_options__includes_ranges(self):
"""Test that filter options include numeric ranges."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('ranges', response.data)
def test__filter_options__includes_ordering_options(self):
"""Test that filter options include ordering options."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('ordering_options', response.data)
class TestHybridRideAPIView(EnhancedAPITestCase):
"""Test cases for HybridRideAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.park = ParkFactory()
self.manufacturer = ManufacturerCompanyFactory()
self.rides = [
RideFactory(park=self.park, manufacturer=self.manufacturer, status='OPERATING', category='RC'),
RideFactory(park=self.park, status='OPERATING', category='DR'),
RideFactory(park=self.park, status='CLOSED_TEMP', category='RC'),
]
self.url = '/api/v1/rides/hybrid/'
def test__hybrid_ride__initial_load__returns_rides(self):
"""Test initial load returns rides with metadata."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data.get('success', False))
self.assertIn('data', response.data)
self.assertIn('rides', response.data['data'])
self.assertIn('total_count', response.data['data'])
def test__hybrid_ride__with_category_filter__returns_filtered_rides(self):
"""Test filtering by category."""
response = self.client.get(self.url, {'category': 'RC'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_status_filter__returns_filtered_rides(self):
"""Test filtering by status."""
response = self.client.get(self.url, {'status': 'OPERATING'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_park_slug__returns_filtered_rides(self):
"""Test filtering by park slug."""
response = self.client.get(self.url, {'park_slug': self.park.slug})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_manufacturer_filter__returns_filtered_rides(self):
"""Test filtering by manufacturer."""
response = self.client.get(self.url, {'manufacturer': self.manufacturer.slug})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_offset__returns_progressive_data(self):
"""Test progressive loading with offset."""
response = self.client.get(self.url, {'offset': 0})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('has_more', response.data['data'])
def test__hybrid_ride__with_invalid_offset__returns_400(self):
"""Test invalid offset parameter."""
response = self.client.get(self.url, {'offset': 'invalid'})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__hybrid_ride__with_search__returns_matching_rides(self):
"""Test search functionality."""
response = self.client.get(self.url, {'search': 'test'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_rating_filters__returns_filtered_rides(self):
"""Test filtering by rating range."""
response = self.client.get(self.url, {'rating_min': 5.0, 'rating_max': 10.0})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_height_filters__returns_filtered_rides(self):
"""Test filtering by height requirement range."""
response = self.client.get(self.url, {
'height_requirement_min': 36,
'height_requirement_max': 54
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_roller_coaster_filters__returns_filtered_rides(self):
"""Test filtering by roller coaster specific fields."""
response = self.client.get(self.url, {
'roller_coaster_type': 'SITDOWN',
'track_material': 'STEEL'
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_inversions_filter__returns_filtered_rides(self):
"""Test filtering by inversions."""
response = self.client.get(self.url, {'has_inversions': 'true'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
class TestRideFilterMetadataAPIView(EnhancedAPITestCase):
"""Test cases for RideFilterMetadataAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.url = '/api/v1/rides/filter-metadata/'
def test__filter_metadata__unscoped__returns_all_metadata(self):
"""Test getting unscoped filter metadata."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data.get('success', False))
self.assertIn('data', response.data)
def test__filter_metadata__scoped__returns_filtered_metadata(self):
"""Test getting scoped filter metadata."""
response = self.client.get(self.url, {'scoped': 'true', 'category': 'RC'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
class TestCompanySearchAPIView(EnhancedAPITestCase):
"""Test cases for CompanySearchAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.manufacturer = ManufacturerCompanyFactory(name='Bolliger & Mabillard')
self.url = '/api/v1/rides/search/companies/'
def test__company_search__with_query__returns_matching_companies(self):
"""Test searching for companies."""
response = self.client.get(self.url, {'q': 'Bolliger'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsInstance(response.data, list)
def test__company_search__empty_query__returns_empty_list(self):
"""Test empty query returns empty list."""
response = self.client.get(self.url, {'q': ''})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, [])
def test__company_search__no_query__returns_empty_list(self):
"""Test no query parameter returns empty list."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, [])
class TestRideModelSearchAPIView(EnhancedAPITestCase):
"""Test cases for RideModelSearchAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.ride_model = RideModelFactory(name='Hyper Coaster')
self.url = '/api/v1/rides/search-ride-models/'
def test__ride_model_search__with_query__returns_matching_models(self):
"""Test searching for ride models."""
response = self.client.get(self.url, {'q': 'Hyper'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsInstance(response.data, list)
def test__ride_model_search__empty_query__returns_empty_list(self):
"""Test empty query returns empty list."""
response = self.client.get(self.url, {'q': ''})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, [])
class TestRideSearchSuggestionsAPIView(EnhancedAPITestCase):
"""Test cases for RideSearchSuggestionsAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.park = ParkFactory()
self.ride = RideFactory(park=self.park, name='Superman: Escape from Krypton')
self.url = '/api/v1/rides/search-suggestions/'
def test__search_suggestions__with_query__returns_suggestions(self):
"""Test getting search suggestions."""
response = self.client.get(self.url, {'q': 'Superman'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsInstance(response.data, list)
def test__search_suggestions__empty_query__returns_empty_list(self):
"""Test empty query returns empty list."""
response = self.client.get(self.url, {'q': ''})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, [])
class TestRideImageSettingsAPIView(EnhancedAPITestCase):
"""Test cases for RideImageSettingsAPIView."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.park = ParkFactory()
self.ride = RideFactory(park=self.park)
self.url = f'/api/v1/rides/{self.ride.id}/image-settings/'
def test__image_settings__patch__updates_settings(self):
"""Test updating ride image settings."""
self.client.force_authenticate(user=self.user)
response = self.client.patch(self.url, {})
# Should handle the request
self.assertIn(response.status_code, [
status.HTTP_200_OK,
status.HTTP_400_BAD_REQUEST,
status.HTTP_401_UNAUTHORIZED
])
def test__image_settings__invalid_ride__returns_404(self):
"""Test updating image settings for non-existent ride."""
self.client.force_authenticate(user=self.user)
response = self.client.patch('/api/v1/rides/99999/image-settings/', {})
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
class TestRideAPIRollerCoasterFilters(EnhancedAPITestCase):
"""Test cases for roller coaster specific filters."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.park = ParkFactory()
# Create coasters with different stats
self.coaster1 = CoasterFactory(park=self.park, name='Steel Vengeance')
self.coaster2 = CoasterFactory(park=self.park, name='Millennium Force')
self.url = '/api/v1/rides/'
def test__ride_list__with_roller_coaster_type__filters_correctly(self):
"""Test filtering by roller coaster type."""
response = self.client.get(self.url, {'roller_coaster_type': 'SITDOWN'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_track_material__filters_correctly(self):
"""Test filtering by track material."""
response = self.client.get(self.url, {'track_material': 'STEEL'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_propulsion_system__filters_correctly(self):
"""Test filtering by propulsion system."""
response = self.client.get(self.url, {'propulsion_system': 'CHAIN'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_height_ft_range__filters_correctly(self):
"""Test filtering by height in feet."""
response = self.client.get(self.url, {'min_height_ft': 100, 'max_height_ft': 500})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_speed_mph_range__filters_correctly(self):
"""Test filtering by speed in mph."""
response = self.client.get(self.url, {'min_speed_mph': 50, 'max_speed_mph': 150})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_inversions_range__filters_correctly(self):
"""Test filtering by number of inversions."""
response = self.client.get(self.url, {'min_inversions': 0, 'max_inversions': 14})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__ordering_by_height__orders_correctly(self):
"""Test ordering by height."""
response = self.client.get(self.url, {'ordering': '-height_ft'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__ordering_by_speed__orders_correctly(self):
"""Test ordering by speed."""
response = self.client.get(self.url, {'ordering': '-speed_mph'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
class TestRideAPIEdgeCases(EnhancedAPITestCase):
"""Test cases for edge cases in ride APIs."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
def test__ride_list__empty_database__returns_empty_list(self):
"""Test API behavior with no rides in database."""
# This depends on existing data, just verify no error
response = self.client.get('/api/v1/rides/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__special_characters_in_search__handled_safely(self):
"""Test that special characters in search are handled safely."""
special_searches = [
"O'Brien",
"Ride & Coaster",
"Test; DROP TABLE rides;",
"Ride<script>alert(1)</script>",
]
for search_term in special_searches:
response = self.client.get('/api/v1/rides/', {'search': search_term})
# Should not crash
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test__ride_list__extreme_pagination__handled_safely(self):
"""Test extreme pagination values."""
response = self.client.get('/api/v1/rides/', {'page': 99999, 'page_size': 1000})
# Should handle gracefully
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND])
def test__ride_list__invalid_ordering__handled_safely(self):
"""Test invalid ordering parameter."""
response = self.client.get('/api/v1/rides/', {'ordering': 'invalid_field'})
# Should use default ordering
self.assertEqual(response.status_code, status.HTTP_200_OK)
class TestRideAPIQueryOptimization(EnhancedAPITestCase):
"""Test cases for query optimization in ride APIs."""
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.park = ParkFactory()
def test__ride_list__uses_select_related(self):
"""Test that ride list uses select_related for optimization."""
# Create multiple rides
for i in range(5):
RideFactory(park=self.park)
response = self.client.get('/api/v1/rides/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__handles_large_dataset(self):
"""Test that ride list handles larger datasets efficiently."""
# Create batch of rides
for i in range(10):
RideFactory(park=self.park, name=f'Ride {i}')
response = self.client.get('/api/v1/rides/')
self.assertEqual(response.status_code, status.HTTP_200_OK)

271
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,271 @@
"""
Root pytest configuration for ThrillWiki backend tests.
This file contains shared fixtures and configuration used across
all test modules (unit, integration, e2e).
"""
import os
import django
import pytest
from django.conf import settings
# Configure Django settings before any tests run
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.test")
django.setup()
# =============================================================================
# Database Fixtures
# =============================================================================
# Note: pytest-django uses the DATABASES setting from the test settings module
# (config.django.test). Do NOT override DATABASES to SQLite here as it breaks
# GeoDjango models that require PostGIS (or properly configured SpatiaLite).
@pytest.fixture
def db_session(db):
"""Provide database access with automatic cleanup."""
yield db
# =============================================================================
# User Fixtures
# =============================================================================
@pytest.fixture
def user(db):
"""Create a regular test user."""
from tests.factories import UserFactory
return UserFactory()
@pytest.fixture
def staff_user(db):
"""Create a staff test user."""
from tests.factories import StaffUserFactory
return StaffUserFactory()
@pytest.fixture
def superuser(db):
"""Create a superuser test user."""
from tests.factories import SuperUserFactory
return SuperUserFactory()
@pytest.fixture
def moderator_user(db):
"""Create a moderator test user."""
from tests.factories import StaffUserFactory
user = StaffUserFactory(username="moderator")
return user
# =============================================================================
# API Client Fixtures
# =============================================================================
@pytest.fixture
def api_client():
"""Create an unauthenticated API client."""
from rest_framework.test import APIClient
return APIClient()
@pytest.fixture
def authenticated_api_client(api_client, user):
"""Create an authenticated API client."""
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def staff_api_client(api_client, staff_user):
"""Create an API client authenticated as staff."""
api_client.force_authenticate(user=staff_user)
return api_client
@pytest.fixture
def superuser_api_client(api_client, superuser):
"""Create an API client authenticated as superuser."""
api_client.force_authenticate(user=superuser)
return api_client
# =============================================================================
# Model Fixtures
# =============================================================================
@pytest.fixture
def park(db):
"""Create a test park."""
from tests.factories import ParkFactory
return ParkFactory()
@pytest.fixture
def operating_park(db):
"""Create an operating test park."""
from tests.factories import ParkFactory
return ParkFactory(status="OPERATING")
@pytest.fixture
def ride(db, park):
"""Create a test ride."""
from tests.factories import RideFactory
return RideFactory(park=park)
@pytest.fixture
def operating_ride(db, operating_park):
"""Create an operating test ride."""
from tests.factories import RideFactory
return RideFactory(park=operating_park, status="OPERATING")
@pytest.fixture
def park_photo(db, park, user):
"""Create a test park photo."""
from tests.factories import ParkPhotoFactory
return ParkPhotoFactory(park=park, uploaded_by=user)
@pytest.fixture
def ride_photo(db, ride, user):
"""Create a test ride photo."""
from tests.factories import RidePhotoFactory
return RidePhotoFactory(ride=ride, uploaded_by=user)
@pytest.fixture
def company(db):
"""Create a test company."""
from tests.factories import CompanyFactory
return CompanyFactory()
@pytest.fixture
def park_area(db, park):
"""Create a test park area."""
from tests.factories import ParkAreaFactory
return ParkAreaFactory(park=park)
# =============================================================================
# Request Fixtures
# =============================================================================
@pytest.fixture
def request_factory():
"""Create a Django request factory."""
from django.test import RequestFactory
return RequestFactory()
@pytest.fixture
def rf():
"""Alias for request_factory (common pytest-django convention)."""
from django.test import RequestFactory
return RequestFactory()
# =============================================================================
# Utility Fixtures
# =============================================================================
@pytest.fixture
def mock_cloudflare_image():
"""Create a mock Cloudflare image."""
from tests.factories import CloudflareImageFactory
return CloudflareImageFactory()
@pytest.fixture
def temp_image():
"""Create a temporary image file for upload testing."""
from io import BytesIO
from django.core.files.uploadedfile import SimpleUploadedFile
from PIL import Image
# Create a simple test image
image = Image.new("RGB", (100, 100), color="red")
image_io = BytesIO()
image.save(image_io, format="JPEG")
image_io.seek(0)
return SimpleUploadedFile(
name="test_image.jpg",
content=image_io.read(),
content_type="image/jpeg",
)
@pytest.fixture(autouse=True)
def enable_db_access_for_all_tests(db):
"""
Enable database access for all tests by default.
This is useful for integration tests that need database access
without explicitly requesting the 'db' fixture.
"""
pass
# =============================================================================
# Cleanup Fixtures
# =============================================================================
@pytest.fixture(autouse=True)
def clear_cache():
"""Clear Django cache before each test."""
from django.core.cache import cache
cache.clear()
yield
cache.clear()
# =============================================================================
# Marker Registration
# =============================================================================
def pytest_configure(config):
"""Register custom pytest markers."""
config.addinivalue_line("markers", "unit: Unit tests (fast, isolated)")
config.addinivalue_line(
"markers", "integration: Integration tests (may use database)"
)
config.addinivalue_line(
"markers", "e2e: End-to-end browser tests (slow, requires server)"
)
config.addinivalue_line("markers", "slow: Tests that take a long time to run")
config.addinivalue_line("markers", "api: API endpoint tests")

View File

@@ -0,0 +1,6 @@
"""
End-to-end tests.
This module contains browser-based tests using Playwright
to verify complete user journeys through the application.
"""

View File

@@ -1,14 +1,42 @@
import pytest import pytest
from playwright.sync_api import Page from playwright.sync_api import Page
import subprocess
@pytest.fixture(autouse=True) @pytest.fixture(scope="session")
def setup_test_data(): def setup_test_data(django_db_setup, django_db_blocker):
"""Setup test data before each test session""" """
subprocess.run(["uv", "run", "manage.py", "create_test_users"], check=True) Setup test data before the test session using factories.
This fixture:
- Uses factories instead of shelling out to management commands
- Is scoped to session (not autouse per test) to reduce overhead
- Uses django_db_blocker to allow database access in session-scoped fixture
"""
with django_db_blocker.unblock():
from django.contrib.auth import get_user_model
User = get_user_model()
# Create test users if they don't exist
test_users = [
{"username": "testuser", "email": "testuser@example.com", "password": "testpass123"},
{"username": "moderator", "email": "moderator@example.com", "password": "modpass123", "is_staff": True},
{"username": "admin", "email": "admin@example.com", "password": "adminpass123", "is_staff": True, "is_superuser": True},
]
for user_data in test_users:
password = user_data.pop("password")
user, created = User.objects.get_or_create(
username=user_data["username"],
defaults=user_data
)
if created:
user.set_password(password)
user.save()
yield yield
subprocess.run(["uv", "run", "manage.py", "cleanup_test_data"], check=True)
# Cleanup is handled automatically by pytest-django's transactional database
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -34,7 +62,7 @@ def setup_page(page: Page):
@pytest.fixture @pytest.fixture
def auth_page(page: Page, live_server): def auth_page(page: Page, live_server, setup_test_data):
"""Fixture for authenticated page""" """Fixture for authenticated page"""
# Login using live_server URL # Login using live_server URL
page.goto(f"{live_server.url}/accounts/login/") page.goto(f"{live_server.url}/accounts/login/")
@@ -46,7 +74,7 @@ def auth_page(page: Page, live_server):
@pytest.fixture @pytest.fixture
def mod_page(page: Page, live_server): def mod_page(page: Page, live_server, setup_test_data):
"""Fixture for moderator page""" """Fixture for moderator page"""
# Login as moderator using live_server URL # Login as moderator using live_server URL
page.goto(f"{live_server.url}/accounts/login/") page.goto(f"{live_server.url}/accounts/login/")
@@ -107,7 +135,7 @@ def test_review(test_park: Page, live_server):
@pytest.fixture @pytest.fixture
def admin_page(page: Page, live_server): def admin_page(page: Page, live_server, setup_test_data):
"""Fixture for admin/superuser page""" """Fixture for admin/superuser page"""
# Login as admin using live_server URL # Login as admin using live_server URL
page.goto(f"{live_server.url}/accounts/login/") page.goto(f"{live_server.url}/accounts/login/")
@@ -406,3 +434,39 @@ def regular_user(db):
user.save() user.save()
return user return user
@pytest.fixture
def parks_data(db):
"""Create test parks for E2E testing."""
from tests.factories import ParkFactory
parks = [
ParkFactory(
name=f"E2E Test Park {i}",
slug=f"e2e-test-park-{i}",
status="OPERATING"
)
for i in range(3)
]
return parks
@pytest.fixture
def rides_data(db, parks_data):
"""Create test rides for E2E testing."""
from tests.factories import RideFactory
rides = []
for park in parks_data:
for i in range(2):
ride = RideFactory(
name=f"E2E Test Ride {park.name} {i}",
slug=f"e2e-test-ride-{park.slug}-{i}",
park=park,
status="OPERATING"
)
rides.append(ride)
return rides

View File

@@ -0,0 +1,182 @@
"""
E2E tests for park browsing functionality.
These tests verify the complete user journey for browsing parks
using Playwright for browser automation.
"""
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.e2e
class TestParkListPage:
"""E2E tests for park list page."""
def test__park_list__displays_parks(self, page: Page, live_server, parks_data):
"""Test park list page displays parks."""
page.goto(f"{live_server.url}/parks/")
# Verify page title or heading
expect(page.locator("h1")).to_be_visible()
# Should display park cards or list items
park_items = page.locator("[data-testid='park-card'], .park-item, .park-list-item")
expect(park_items.first).to_be_visible()
def test__park_list__shows_park_name(self, page: Page, live_server, parks_data):
"""Test park list shows park names."""
page.goto(f"{live_server.url}/parks/")
# First park should be visible
first_park = parks_data[0]
expect(page.get_by_text(first_park.name)).to_be_visible()
def test__park_list__click_park__navigates_to_detail(
self, page: Page, live_server, parks_data
):
"""Test clicking a park navigates to detail page."""
page.goto(f"{live_server.url}/parks/")
first_park = parks_data[0]
# Click on the park
page.get_by_text(first_park.name).first.click()
# Should navigate to detail page
expect(page).to_have_url(f"**/{first_park.slug}/**")
def test__park_list__search__filters_results(self, page: Page, live_server, parks_data):
"""Test search functionality filters parks."""
page.goto(f"{live_server.url}/parks/")
# Find search input
search_input = page.locator(
"input[type='search'], input[name='q'], input[placeholder*='search' i]"
)
if search_input.count() > 0:
search_input.first.fill("E2E Test Park 0")
# Wait for results to filter
page.wait_for_timeout(500)
# Should show only matching park
expect(page.get_by_text("E2E Test Park 0")).to_be_visible()
@pytest.mark.e2e
class TestParkDetailPage:
"""E2E tests for park detail page."""
def test__park_detail__displays_park_info(self, page: Page, live_server, parks_data):
"""Test park detail page displays park information."""
park = parks_data[0]
page.goto(f"{live_server.url}/parks/{park.slug}/")
# Verify park name is displayed
expect(page.get_by_role("heading", name=park.name)).to_be_visible()
def test__park_detail__shows_rides_section(self, page: Page, live_server, parks_data):
"""Test park detail page shows rides section."""
park = parks_data[0]
page.goto(f"{live_server.url}/parks/{park.slug}/")
# Look for rides section/tab
rides_section = page.locator(
"[data-testid='rides-section'], #rides, [role='tabpanel']"
)
# Or a rides tab
rides_tab = page.get_by_role("tab", name="Rides")
if rides_tab.count() > 0:
rides_tab.click()
# Should show rides
ride_items = page.locator(".ride-item, .ride-card, [data-testid='ride-item']")
expect(ride_items.first).to_be_visible()
def test__park_detail__shows_status(self, page: Page, live_server, parks_data):
"""Test park detail page shows park status."""
park = parks_data[0]
page.goto(f"{live_server.url}/parks/{park.slug}/")
# Status badge or indicator should be visible
status_indicator = page.locator(
".status-badge, [data-testid='status'], .park-status"
)
expect(status_indicator.first).to_be_visible()
@pytest.mark.e2e
class TestParkFiltering:
"""E2E tests for park filtering functionality."""
def test__filter_by_status__updates_results(self, page: Page, live_server, parks_data):
"""Test filtering parks by status updates results."""
page.goto(f"{live_server.url}/parks/")
# Find status filter
status_filter = page.locator(
"select[name='status'], [data-testid='status-filter']"
)
if status_filter.count() > 0:
status_filter.first.select_option("OPERATING")
# Wait for results to update
page.wait_for_timeout(500)
# Results should be filtered
def test__clear_filters__shows_all_parks(self, page: Page, live_server, parks_data):
"""Test clearing filters shows all parks."""
page.goto(f"{live_server.url}/parks/")
# Find clear filters button
clear_btn = page.locator(
"[data-testid='clear-filters'], button:has-text('Clear')"
)
if clear_btn.count() > 0:
clear_btn.first.click()
# Wait for results to update
page.wait_for_timeout(500)
@pytest.mark.e2e
class TestParkNavigation:
"""E2E tests for park navigation."""
def test__breadcrumb__navigates_back_to_list(self, page: Page, live_server, parks_data):
"""Test breadcrumb navigation back to park list."""
park = parks_data[0]
page.goto(f"{live_server.url}/parks/{park.slug}/")
# Find breadcrumb
breadcrumb = page.locator("nav[aria-label='breadcrumb'], .breadcrumb")
if breadcrumb.count() > 0:
# Click parks link in breadcrumb
breadcrumb.get_by_role("link", name="Parks").click()
expect(page).to_have_url(f"**/parks/**")
def test__back_button__returns_to_previous_page(
self, page: Page, live_server, parks_data
):
"""Test browser back button returns to previous page."""
page.goto(f"{live_server.url}/parks/")
park = parks_data[0]
page.get_by_text(park.name).first.click()
# Wait for navigation
page.wait_for_url(f"**/{park.slug}/**")
# Go back
page.go_back()
expect(page).to_have_url(f"**/parks/**")

View File

@@ -0,0 +1,372 @@
"""
E2E tests for review submission and moderation flows.
These tests verify the complete user journey for submitting,
editing, and moderating reviews using Playwright for browser automation.
"""
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.e2e
class TestReviewSubmission:
"""E2E tests for review submission flow."""
def test__review_form__displays_fields(self, auth_page: Page, live_server, parks_data):
"""Test review form displays all required fields."""
park = parks_data[0]
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
# Find and click reviews tab or section
reviews_tab = auth_page.get_by_role("tab", name="Reviews")
if reviews_tab.count() > 0:
reviews_tab.click()
# Click write review button
write_review = auth_page.locator(
"button:has-text('Write Review'), a:has-text('Write Review')"
)
if write_review.count() > 0:
write_review.first.click()
# Verify form fields
expect(auth_page.locator("select[name='rating'], input[name='rating']").first).to_be_visible()
expect(auth_page.locator("input[name='title'], textarea[name='title']").first).to_be_visible()
expect(auth_page.locator("textarea[name='content'], textarea[name='review']").first).to_be_visible()
def test__review_submission__valid_data__creates_review(
self, auth_page: Page, live_server, parks_data
):
"""Test submitting a valid review creates it."""
park = parks_data[0]
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
# Navigate to reviews
reviews_tab = auth_page.get_by_role("tab", name="Reviews")
if reviews_tab.count() > 0:
reviews_tab.click()
write_review = auth_page.locator(
"button:has-text('Write Review'), a:has-text('Write Review')"
)
if write_review.count() > 0:
write_review.first.click()
# Fill the form
rating_select = auth_page.locator("select[name='rating']")
if rating_select.count() > 0:
rating_select.select_option("5")
else:
# May be radio buttons or stars
auth_page.locator("input[name='rating'][value='5']").click()
auth_page.locator("input[name='title'], textarea[name='title']").first.fill(
"E2E Test Review Title"
)
auth_page.locator("textarea[name='content'], textarea[name='review']").first.fill(
"This is an E2E test review content."
)
auth_page.get_by_role("button", name="Submit").click()
# Should show success or redirect
auth_page.wait_for_timeout(500)
def test__review_submission__missing_rating__shows_error(
self, auth_page: Page, live_server, parks_data
):
"""Test submitting review without rating shows error."""
park = parks_data[0]
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
reviews_tab = auth_page.get_by_role("tab", name="Reviews")
if reviews_tab.count() > 0:
reviews_tab.click()
write_review = auth_page.locator(
"button:has-text('Write Review'), a:has-text('Write Review')"
)
if write_review.count() > 0:
write_review.first.click()
# Fill only title and content, skip rating
auth_page.locator("input[name='title'], textarea[name='title']").first.fill(
"Missing Rating Review"
)
auth_page.locator("textarea[name='content'], textarea[name='review']").first.fill(
"Review without rating"
)
auth_page.get_by_role("button", name="Submit").click()
# Should show validation error
error = auth_page.locator(".error, .errorlist, [role='alert']")
expect(error.first).to_be_visible()
@pytest.mark.e2e
class TestReviewDisplay:
"""E2E tests for review display."""
def test__reviews_list__displays_reviews(self, page: Page, live_server, parks_data):
"""Test reviews list displays existing reviews."""
park = parks_data[0]
page.goto(f"{live_server.url}/parks/{park.slug}/")
# Navigate to reviews section
reviews_tab = page.get_by_role("tab", name="Reviews")
if reviews_tab.count() > 0:
reviews_tab.click()
# Reviews should be displayed
reviews_section = page.locator(
"[data-testid='reviews-list'], .reviews-list, .review-item"
)
if reviews_section.count() > 0:
expect(reviews_section.first).to_be_visible()
def test__review__shows_rating(self, page: Page, live_server, test_review):
"""Test review displays rating."""
# test_review fixture creates a review
page.goto(f"{page.url}") # Stay on current page after fixture
# Rating should be visible (stars, number, etc.)
rating = page.locator(
".rating, .stars, [data-testid='rating']"
)
if rating.count() > 0:
expect(rating.first).to_be_visible()
def test__review__shows_author(self, page: Page, live_server, parks_data):
"""Test review displays author name."""
park = parks_data[0]
page.goto(f"{live_server.url}/parks/{park.slug}/")
reviews_tab = page.get_by_role("tab", name="Reviews")
if reviews_tab.count() > 0:
reviews_tab.click()
# Author name should be visible in review
author = page.locator(
".review-author, .author, [data-testid='author']"
)
if author.count() > 0:
expect(author.first).to_be_visible()
@pytest.mark.e2e
class TestReviewEditing:
"""E2E tests for review editing."""
def test__own_review__shows_edit_button(self, auth_page: Page, live_server, test_review):
"""Test user's own review shows edit button."""
# Navigate to reviews after creating one
park_url = auth_page.url
# Look for edit button on own review
edit_button = auth_page.locator(
"button:has-text('Edit'), a:has-text('Edit Review')"
)
if edit_button.count() > 0:
expect(edit_button.first).to_be_visible()
def test__edit_review__updates_content(self, auth_page: Page, live_server, test_review):
"""Test editing review updates the content."""
# Find and click edit
edit_button = auth_page.locator(
"button:has-text('Edit'), a:has-text('Edit Review')"
)
if edit_button.count() > 0:
edit_button.first.click()
# Update content
content_field = auth_page.locator(
"textarea[name='content'], textarea[name='review']"
)
content_field.first.fill("Updated review content from E2E test")
auth_page.get_by_role("button", name="Save").click()
# Should show updated content
auth_page.wait_for_timeout(500)
expect(auth_page.get_by_text("Updated review content")).to_be_visible()
@pytest.mark.e2e
class TestReviewModeration:
"""E2E tests for review moderation."""
def test__moderator__sees_moderation_actions(
self, mod_page: Page, live_server, parks_data
):
"""Test moderator sees moderation actions on reviews."""
park = parks_data[0]
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
reviews_tab = mod_page.get_by_role("tab", name="Reviews")
if reviews_tab.count() > 0:
reviews_tab.click()
# Moderator should see moderation buttons
mod_actions = mod_page.locator(
"button:has-text('Remove'), button:has-text('Flag'), [data-testid='mod-action']"
)
if mod_actions.count() > 0:
expect(mod_actions.first).to_be_visible()
def test__moderator__can_remove_review(self, mod_page: Page, live_server, parks_data):
"""Test moderator can remove a review."""
park = parks_data[0]
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
reviews_tab = mod_page.get_by_role("tab", name="Reviews")
if reviews_tab.count() > 0:
reviews_tab.click()
remove_button = mod_page.locator("button:has-text('Remove')")
if remove_button.count() > 0:
remove_button.first.click()
# Confirm if dialog appears
confirm = mod_page.locator("button:has-text('Confirm')")
if confirm.count() > 0:
confirm.click()
mod_page.wait_for_timeout(500)
@pytest.mark.e2e
class TestReviewVoting:
"""E2E tests for review voting (helpful/not helpful)."""
def test__review__shows_vote_buttons(self, page: Page, live_server, parks_data):
"""Test reviews show vote buttons."""
park = parks_data[0]
page.goto(f"{live_server.url}/parks/{park.slug}/")
reviews_tab = page.get_by_role("tab", name="Reviews")
if reviews_tab.count() > 0:
reviews_tab.click()
# Look for helpful/upvote buttons
vote_buttons = page.locator(
"button:has-text('Helpful'), button[aria-label*='helpful'], .vote-button"
)
if vote_buttons.count() > 0:
expect(vote_buttons.first).to_be_visible()
def test__vote__authenticated__registers_vote(
self, auth_page: Page, live_server, parks_data
):
"""Test authenticated user can vote on review."""
park = parks_data[0]
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
reviews_tab = auth_page.get_by_role("tab", name="Reviews")
if reviews_tab.count() > 0:
reviews_tab.click()
helpful_button = auth_page.locator(
"button:has-text('Helpful'), button[aria-label*='helpful']"
)
if helpful_button.count() > 0:
helpful_button.first.click()
# Button should show voted state
auth_page.wait_for_timeout(500)
@pytest.mark.e2e
class TestRideReviews:
"""E2E tests for ride-specific reviews."""
def test__ride_page__shows_reviews(self, page: Page, live_server, rides_data):
"""Test ride page shows reviews section."""
ride = rides_data[0]
page.goto(f"{live_server.url}/rides/{ride.slug}/")
# Reviews section should be present
reviews_section = page.locator(
"[data-testid='reviews'], #reviews, .reviews-section"
)
if reviews_section.count() > 0:
expect(reviews_section.first).to_be_visible()
def test__ride_review__includes_ride_experience_fields(
self, auth_page: Page, live_server, rides_data
):
"""Test ride review form includes experience fields."""
ride = rides_data[0]
auth_page.goto(f"{live_server.url}/rides/{ride.slug}/")
write_review = auth_page.locator(
"button:has-text('Write Review'), a:has-text('Write Review')"
)
if write_review.count() > 0:
write_review.first.click()
# Ride-specific fields
intensity_field = auth_page.locator(
"select[name='intensity'], input[name='intensity']"
)
wait_time_field = auth_page.locator(
"input[name='wait_time'], select[name='wait_time']"
)
# At least one experience field should be present
if intensity_field.count() > 0:
expect(intensity_field.first).to_be_visible()
@pytest.mark.e2e
class TestReviewFiltering:
"""E2E tests for review filtering and sorting."""
def test__reviews__sort_by_date(self, page: Page, live_server, parks_data):
"""Test reviews can be sorted by date."""
park = parks_data[0]
page.goto(f"{live_server.url}/parks/{park.slug}/")
reviews_tab = page.get_by_role("tab", name="Reviews")
if reviews_tab.count() > 0:
reviews_tab.click()
sort_select = page.locator(
"select[name='sort'], [data-testid='sort-reviews']"
)
if sort_select.count() > 0:
sort_select.first.select_option("date")
page.wait_for_timeout(500)
def test__reviews__filter_by_rating(self, page: Page, live_server, parks_data):
"""Test reviews can be filtered by rating."""
park = parks_data[0]
page.goto(f"{live_server.url}/parks/{park.slug}/")
reviews_tab = page.get_by_role("tab", name="Reviews")
if reviews_tab.count() > 0:
reviews_tab.click()
rating_filter = page.locator(
"select[name='rating'], [data-testid='rating-filter']"
)
if rating_filter.count() > 0:
rating_filter.first.select_option("5")
page.wait_for_timeout(500)

View File

@@ -0,0 +1,280 @@
"""
E2E tests for user registration and authentication flows.
These tests verify the complete user journey for registration,
login, and account management using Playwright for browser automation.
"""
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.e2e
class TestUserRegistration:
"""E2E tests for user registration flow."""
def test__registration_page__displays_form(self, page: Page, live_server):
"""Test registration page displays the registration form."""
page.goto(f"{live_server.url}/accounts/signup/")
# Verify form fields are visible
expect(page.get_by_label("Username")).to_be_visible()
expect(page.get_by_label("Email")).to_be_visible()
expect(page.get_by_label("Password", exact=False).first).to_be_visible()
def test__registration__valid_data__creates_account(self, page: Page, live_server):
"""Test registration with valid data creates an account."""
page.goto(f"{live_server.url}/accounts/signup/")
# Fill registration form
page.get_by_label("Username").fill("e2e_newuser")
page.get_by_label("Email").fill("e2e_newuser@example.com")
# Handle password fields (may be "Password" and "Confirm Password" or similar)
password_fields = page.locator("input[type='password']")
if password_fields.count() >= 2:
password_fields.nth(0).fill("SecurePass123!")
password_fields.nth(1).fill("SecurePass123!")
else:
password_fields.first.fill("SecurePass123!")
# Submit form
page.get_by_role("button", name="Sign Up").click()
# Should redirect to success page or login
page.wait_for_url("**/*", timeout=5000)
def test__registration__duplicate_username__shows_error(
self, page: Page, live_server, regular_user
):
"""Test registration with duplicate username shows error."""
page.goto(f"{live_server.url}/accounts/signup/")
# Try to register with existing username
page.get_by_label("Username").fill("testuser")
page.get_by_label("Email").fill("different@example.com")
password_fields = page.locator("input[type='password']")
if password_fields.count() >= 2:
password_fields.nth(0).fill("SecurePass123!")
password_fields.nth(1).fill("SecurePass123!")
else:
password_fields.first.fill("SecurePass123!")
page.get_by_role("button", name="Sign Up").click()
# Should show error message
error = page.locator(".error, .errorlist, [role='alert']")
expect(error.first).to_be_visible()
def test__registration__weak_password__shows_error(self, page: Page, live_server):
"""Test registration with weak password shows validation error."""
page.goto(f"{live_server.url}/accounts/signup/")
page.get_by_label("Username").fill("e2e_weakpass")
page.get_by_label("Email").fill("e2e_weakpass@example.com")
password_fields = page.locator("input[type='password']")
if password_fields.count() >= 2:
password_fields.nth(0).fill("123")
password_fields.nth(1).fill("123")
else:
password_fields.first.fill("123")
page.get_by_role("button", name="Sign Up").click()
# Should show password validation error
error = page.locator(".error, .errorlist, [role='alert']")
expect(error.first).to_be_visible()
@pytest.mark.e2e
class TestUserLogin:
"""E2E tests for user login flow."""
def test__login_page__displays_form(self, page: Page, live_server):
"""Test login page displays the login form."""
page.goto(f"{live_server.url}/accounts/login/")
expect(page.get_by_label("Username")).to_be_visible()
expect(page.get_by_label("Password")).to_be_visible()
expect(page.get_by_role("button", name="Sign In")).to_be_visible()
def test__login__valid_credentials__authenticates(
self, page: Page, live_server, regular_user
):
"""Test login with valid credentials authenticates user."""
page.goto(f"{live_server.url}/accounts/login/")
page.get_by_label("Username").fill("testuser")
page.get_by_label("Password").fill("testpass123")
page.get_by_role("button", name="Sign In").click()
# Should redirect away from login page
page.wait_for_url("**/*")
expect(page).not_to_have_url("**/login/**")
def test__login__invalid_credentials__shows_error(self, page: Page, live_server):
"""Test login with invalid credentials shows error."""
page.goto(f"{live_server.url}/accounts/login/")
page.get_by_label("Username").fill("nonexistent")
page.get_by_label("Password").fill("wrongpass")
page.get_by_role("button", name="Sign In").click()
# Should show error message
error = page.locator(".error, .errorlist, [role='alert'], .alert-danger")
expect(error.first).to_be_visible()
def test__login__remember_me__checkbox_present(self, page: Page, live_server):
"""Test login page has remember me checkbox."""
page.goto(f"{live_server.url}/accounts/login/")
remember_me = page.locator(
"input[name='remember'], input[type='checkbox'][id*='remember']"
)
if remember_me.count() > 0:
expect(remember_me.first).to_be_visible()
@pytest.mark.e2e
class TestUserLogout:
"""E2E tests for user logout flow."""
def test__logout__clears_session(self, auth_page: Page, live_server):
"""Test logout clears user session."""
# User is already logged in via auth_page fixture
# Find and click logout button/link
logout = auth_page.locator(
"a[href*='logout'], button:has-text('Log Out'), button:has-text('Sign Out')"
)
if logout.count() > 0:
logout.first.click()
# Should be logged out
auth_page.wait_for_url("**/*")
# Try to access protected page
auth_page.goto(f"{live_server.url}/accounts/profile/")
# Should redirect to login
expect(auth_page).to_have_url("**/login/**")
@pytest.mark.e2e
class TestPasswordReset:
"""E2E tests for password reset flow."""
def test__password_reset_page__displays_form(self, page: Page, live_server):
"""Test password reset page displays the form."""
page.goto(f"{live_server.url}/accounts/password/reset/")
email_input = page.locator(
"input[type='email'], input[name='email']"
)
expect(email_input.first).to_be_visible()
def test__password_reset__valid_email__shows_confirmation(
self, page: Page, live_server, regular_user
):
"""Test password reset with valid email shows confirmation."""
page.goto(f"{live_server.url}/accounts/password/reset/")
email_input = page.locator("input[type='email'], input[name='email']")
email_input.first.fill("testuser@example.com")
page.get_by_role("button", name="Reset Password").click()
# Should show confirmation message
page.wait_for_timeout(500)
# Look for success message or confirmation page
success = page.locator(
".success, .alert-success, [role='alert']"
)
# Or check URL changed to done page
if success.count() == 0:
expect(page).to_have_url("**/done/**")
@pytest.mark.e2e
class TestUserProfile:
"""E2E tests for user profile management."""
def test__profile_page__displays_user_info(self, auth_page: Page, live_server):
"""Test profile page displays user information."""
auth_page.goto(f"{live_server.url}/accounts/profile/")
# Should display username
expect(auth_page.get_by_text("testuser")).to_be_visible()
def test__profile_page__edit_profile_link(self, auth_page: Page, live_server):
"""Test profile page has edit profile link/button."""
auth_page.goto(f"{live_server.url}/accounts/profile/")
edit_link = auth_page.locator(
"a[href*='edit'], button:has-text('Edit')"
)
if edit_link.count() > 0:
expect(edit_link.first).to_be_visible()
def test__profile_edit__updates_info(self, auth_page: Page, live_server):
"""Test editing profile updates user information."""
auth_page.goto(f"{live_server.url}/accounts/profile/edit/")
# Find bio/about field if present
bio_field = auth_page.locator(
"textarea[name='bio'], textarea[name='about']"
)
if bio_field.count() > 0:
bio_field.first.fill("Updated bio from E2E test")
auth_page.get_by_role("button", name="Save").click()
# Should redirect back to profile
auth_page.wait_for_url("**/profile/**")
@pytest.mark.e2e
class TestProtectedRoutes:
"""E2E tests for protected route access."""
def test__protected_route__unauthenticated__redirects_to_login(
self, page: Page, live_server
):
"""Test accessing protected route redirects to login."""
page.goto(f"{live_server.url}/accounts/profile/")
# Should redirect to login
expect(page).to_have_url("**/login/**")
def test__protected_route__authenticated__allows_access(
self, auth_page: Page, live_server
):
"""Test authenticated user can access protected routes."""
auth_page.goto(f"{live_server.url}/accounts/profile/")
# Should not redirect to login
expect(auth_page).not_to_have_url("**/login/**")
def test__admin_route__regular_user__denied(self, auth_page: Page, live_server):
"""Test regular user cannot access admin routes."""
auth_page.goto(f"{live_server.url}/admin/")
# Should show login or forbidden
# Admin login page or 403
def test__moderator_route__moderator__allows_access(
self, mod_page: Page, live_server
):
"""Test moderator can access moderation routes."""
mod_page.goto(f"{live_server.url}/moderation/")
# Should not redirect to login (moderator has access)
expect(mod_page).not_to_have_url("**/login/**")

View File

@@ -360,3 +360,58 @@ class TestScenarios:
reviews = [ParkReviewFactory(park=park, user=user) for user in users] reviews = [ParkReviewFactory(park=park, user=user) for user in users]
return {"park": park, "users": users, "reviews": reviews} return {"park": park, "users": users, "reviews": reviews}
class CloudflareImageFactory(DjangoModelFactory):
"""Factory for creating CloudflareImage instances."""
class Meta:
model = "django_cloudflareimages_toolkit.CloudflareImage"
cloudflare_id = factory.Sequence(lambda n: f"cf-image-{n}")
status = "uploaded"
upload_url = factory.Faker("url")
width = fuzzy.FuzzyInteger(100, 1920)
height = fuzzy.FuzzyInteger(100, 1080)
format = "jpeg"
@factory.lazy_attribute
def expires_at(self):
from django.utils import timezone
return timezone.now() + timezone.timedelta(days=365)
@factory.lazy_attribute
def uploaded_at(self):
from django.utils import timezone
return timezone.now()
class ParkPhotoFactory(DjangoModelFactory):
"""Factory for creating ParkPhoto instances."""
class Meta:
model = "parks.ParkPhoto"
park = factory.SubFactory(ParkFactory)
image = factory.SubFactory(CloudflareImageFactory)
caption = factory.Faker("sentence", nb_words=6)
alt_text = factory.Faker("sentence", nb_words=8)
is_primary = False
is_approved = True
uploaded_by = factory.SubFactory(UserFactory)
date_taken = factory.Faker("date_time_between", start_date="-2y", end_date="now")
class RidePhotoFactory(DjangoModelFactory):
"""Factory for creating RidePhoto instances."""
class Meta:
model = "rides.RidePhoto"
ride = factory.SubFactory(RideFactory)
image = factory.SubFactory(CloudflareImageFactory)
caption = factory.Faker("sentence", nb_words=6)
alt_text = factory.Faker("sentence", nb_words=8)
is_primary = False
is_approved = True
uploaded_by = factory.SubFactory(UserFactory)

View File

@@ -0,0 +1,6 @@
"""
Form tests.
This module contains tests for Django forms to verify
validation, widgets, and custom logic.
"""

View File

@@ -0,0 +1,315 @@
"""
Tests for Park forms.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from decimal import Decimal
from unittest.mock import Mock, patch, MagicMock
from django.test import TestCase
from apps.parks.forms import (
ParkForm,
ParkSearchForm,
ParkAutocomplete,
)
from tests.factories import (
ParkFactory,
OperatorCompanyFactory,
LocationFactory,
)
@pytest.mark.django_db
class TestParkForm(TestCase):
"""Tests for ParkForm."""
def test__init__new_park__no_location_prefilled(self):
"""Test initializing form for new park has no location prefilled."""
form = ParkForm()
assert form.fields["latitude"].initial is None
assert form.fields["longitude"].initial is None
assert form.fields["city"].initial is None
def test__init__existing_park_with_location__prefills_location_fields(self):
"""Test initializing form for existing park prefills location fields."""
park = ParkFactory()
# Create location via factory's post_generation hook
form = ParkForm(instance=park)
# Location should be prefilled if it exists
if park.location.exists():
location = park.location.first()
assert form.fields["latitude"].initial == location.latitude
assert form.fields["longitude"].initial == location.longitude
assert form.fields["city"].initial == location.city
def test__clean_latitude__valid_value__returns_normalized_value(self):
"""Test clean_latitude normalizes valid latitude."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "37.123456789", # Too many decimal places
"longitude": "-122.123456",
}
form = ParkForm(data=data)
form.is_valid()
if "latitude" in form.cleaned_data:
# Should be rounded to 6 decimal places
assert len(form.cleaned_data["latitude"].split(".")[-1]) <= 6
def test__clean_latitude__out_of_range__returns_error(self):
"""Test clean_latitude rejects out-of-range latitude."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "95.0", # Invalid: > 90
"longitude": "-122.0",
}
form = ParkForm(data=data)
is_valid = form.is_valid()
assert not is_valid
assert "latitude" in form.errors
def test__clean_latitude__negative_ninety__is_valid(self):
"""Test clean_latitude accepts -90 (edge case)."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "-90.0",
"longitude": "0.0",
}
form = ParkForm(data=data)
is_valid = form.is_valid()
# Should be valid (form may have other errors but not latitude)
if not is_valid:
assert "latitude" not in form.errors
def test__clean_longitude__valid_value__returns_normalized_value(self):
"""Test clean_longitude normalizes valid longitude."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "37.0",
"longitude": "-122.123456789", # Too many decimal places
}
form = ParkForm(data=data)
form.is_valid()
if "longitude" in form.cleaned_data:
# Should be rounded to 6 decimal places
assert len(form.cleaned_data["longitude"].split(".")[-1]) <= 6
def test__clean_longitude__out_of_range__returns_error(self):
"""Test clean_longitude rejects out-of-range longitude."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "37.0",
"longitude": "-200.0", # Invalid: < -180
}
form = ParkForm(data=data)
is_valid = form.is_valid()
assert not is_valid
assert "longitude" in form.errors
def test__clean_longitude__positive_180__is_valid(self):
"""Test clean_longitude accepts 180 (edge case)."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "0.0",
"longitude": "180.0",
}
form = ParkForm(data=data)
is_valid = form.is_valid()
# Should be valid (form may have other errors but not longitude)
if not is_valid:
assert "longitude" not in form.errors
def test__save__new_park_with_location__creates_park_and_location(self):
"""Test saving new park creates both park and location."""
operator = OperatorCompanyFactory()
data = {
"name": "New Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "37.123456",
"longitude": "-122.123456",
"city": "San Francisco",
"state": "CA",
"country": "USA",
}
form = ParkForm(data=data)
if form.is_valid():
park = form.save()
assert park.name == "New Test Park"
# Location should be created
assert park.location.exists() or hasattr(park, "location")
def test__save__existing_park__updates_location(self):
"""Test saving existing park updates location."""
park = ParkFactory()
# Update data
data = {
"name": park.name,
"operator": park.operator.pk,
"status": park.status,
"latitude": "40.0",
"longitude": "-74.0",
"city": "New York",
"state": "NY",
"country": "USA",
}
form = ParkForm(instance=park, data=data)
if form.is_valid():
updated_park = form.save()
# Location should be updated
assert updated_park.pk == park.pk
def test__meta__fields__includes_all_expected_fields(self):
"""Test Meta.fields includes all expected park and location fields."""
expected_fields = [
"name",
"description",
"operator",
"property_owner",
"status",
"opening_date",
"closing_date",
"operating_season",
"size_acres",
"website",
"latitude",
"longitude",
"street_address",
"city",
"state",
"country",
"postal_code",
]
for field in expected_fields:
assert field in ParkForm.Meta.fields
def test__widgets__latitude_longitude_hidden__are_hidden_inputs(self):
"""Test latitude and longitude use HiddenInput widgets."""
form = ParkForm()
assert form.fields["latitude"].widget.input_type == "hidden"
assert form.fields["longitude"].widget.input_type == "hidden"
def test__widgets__text_fields__have_styling_classes(self):
"""Test text fields have appropriate CSS classes."""
form = ParkForm()
# Check city field has expected styling
city_widget = form.fields["city"].widget
assert "class" in city_widget.attrs
assert "rounded-lg" in city_widget.attrs["class"]
@pytest.mark.django_db
class TestParkSearchForm(TestCase):
"""Tests for ParkSearchForm."""
def test__init__creates_park_field(self):
"""Test initializing form creates park field."""
form = ParkSearchForm()
assert "park" in form.fields
def test__park_field__uses_autocomplete_widget(self):
"""Test park field uses AutocompleteWidget."""
form = ParkSearchForm()
# Check the widget type
widget = form.fields["park"].widget
widget_class_name = widget.__class__.__name__
assert "Autocomplete" in widget_class_name or "Select" in widget_class_name
def test__park_field__not_required(self):
"""Test park field is not required."""
form = ParkSearchForm()
assert form.fields["park"].required is False
def test__validate__empty_form__is_valid(self):
"""Test empty form is valid."""
form = ParkSearchForm(data={})
assert form.is_valid()
def test__validate__with_park__is_valid(self):
"""Test form with valid park is valid."""
park = ParkFactory()
form = ParkSearchForm(data={"park": park.pk})
assert form.is_valid()
@pytest.mark.django_db
class TestParkAutocomplete(TestCase):
"""Tests for ParkAutocomplete."""
def test__model__is_park(self):
"""Test autocomplete model is Park."""
from apps.parks.models import Park
assert ParkAutocomplete.model == Park
def test__search_attrs__includes_name(self):
"""Test search_attrs includes name field."""
assert "name" in ParkAutocomplete.search_attrs
def test__search__matching_name__returns_results(self):
"""Test searching by name returns matching parks."""
park1 = ParkFactory(name="Cedar Point")
park2 = ParkFactory(name="Kings Island")
# The autocomplete should return Cedar Point when searching for "Cedar"
queryset = ParkAutocomplete.model.objects.filter(name__icontains="Cedar")
assert park1 in queryset
assert park2 not in queryset
def test__search__no_match__returns_empty(self):
"""Test searching with no match returns empty queryset."""
ParkFactory(name="Cedar Point")
queryset = ParkAutocomplete.model.objects.filter(name__icontains="NoMatchHere")
assert queryset.count() == 0

View File

@@ -0,0 +1,371 @@
"""
Tests for Ride forms.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from django.test import TestCase
from apps.rides.forms import (
RideForm,
RideSearchForm,
)
from tests.factories import (
ParkFactory,
RideFactory,
ParkAreaFactory,
ManufacturerCompanyFactory,
DesignerCompanyFactory,
RideModelFactory,
)
@pytest.mark.django_db
class TestRideForm(TestCase):
"""Tests for RideForm."""
def test__init__no_park__shows_park_search_field(self):
"""Test initializing without park shows park search field."""
form = RideForm()
assert "park_search" in form.fields
assert "park" in form.fields
def test__init__with_park__hides_park_search_field(self):
"""Test initializing with park hides park search field."""
park = ParkFactory()
form = RideForm(park=park)
assert "park_search" not in form.fields
assert "park" in form.fields
assert form.fields["park"].initial == park
def test__init__with_park__populates_park_area_queryset(self):
"""Test initializing with park populates park_area choices."""
park = ParkFactory()
area1 = ParkAreaFactory(park=park, name="Area 1")
area2 = ParkAreaFactory(park=park, name="Area 2")
form = RideForm(park=park)
# Park area queryset should contain park's areas
queryset = form.fields["park_area"].queryset
assert area1 in queryset
assert area2 in queryset
def test__init__without_park__park_area_disabled(self):
"""Test initializing without park disables park_area."""
form = RideForm()
assert form.fields["park_area"].widget.attrs.get("disabled") is True
def test__init__existing_ride__prefills_manufacturer(self):
"""Test initializing with existing ride prefills manufacturer."""
manufacturer = ManufacturerCompanyFactory(name="Test Manufacturer")
ride = RideFactory(manufacturer=manufacturer)
form = RideForm(instance=ride)
assert form.fields["manufacturer_search"].initial == "Test Manufacturer"
assert form.fields["manufacturer"].initial == manufacturer
def test__init__existing_ride__prefills_designer(self):
"""Test initializing with existing ride prefills designer."""
designer = DesignerCompanyFactory(name="Test Designer")
ride = RideFactory(designer=designer)
form = RideForm(instance=ride)
assert form.fields["designer_search"].initial == "Test Designer"
assert form.fields["designer"].initial == designer
def test__init__existing_ride__prefills_ride_model(self):
"""Test initializing with existing ride prefills ride model."""
ride_model = RideModelFactory(name="Test Model")
ride = RideFactory(ride_model=ride_model)
form = RideForm(instance=ride)
assert form.fields["ride_model_search"].initial == "Test Model"
assert form.fields["ride_model"].initial == ride_model
def test__init__existing_ride_without_park_arg__prefills_park_search(self):
"""Test initializing with existing ride prefills park search."""
park = ParkFactory(name="Test Park")
ride = RideFactory(park=park)
form = RideForm(instance=ride)
assert form.fields["park_search"].initial == "Test Park"
assert form.fields["park"].initial == park
def test__init__category_is_required(self):
"""Test category field is required."""
form = RideForm()
assert form.fields["category"].required is True
def test__init__date_fields_have_no_initial_value(self):
"""Test date fields have no initial value."""
form = RideForm()
assert form.fields["opening_date"].initial is None
assert form.fields["closing_date"].initial is None
assert form.fields["status_since"].initial is None
def test__field_order__matches_expected(self):
"""Test fields are ordered correctly."""
form = RideForm()
expected_order = [
"park_search",
"park",
"park_area",
"name",
"manufacturer_search",
"manufacturer",
"designer_search",
"designer",
"ride_model_search",
"ride_model",
"category",
]
# Get first 11 fields from form
actual_order = list(form.fields.keys())[:11]
assert actual_order == expected_order
def test__validate__valid_data__is_valid(self):
"""Test form is valid with all required data."""
park = ParkFactory()
manufacturer = ManufacturerCompanyFactory()
data = {
"name": "Test Ride",
"park": park.pk,
"category": "RC", # Roller coaster
"status": "OPERATING",
"manufacturer": manufacturer.pk,
}
form = RideForm(data=data)
# Remove park_search validation error by skipping it
if "park_search" in form.errors:
del form.errors["park_search"]
# Check if form would be valid otherwise
assert "name" not in form.errors
assert "category" not in form.errors
def test__validate__missing_name__returns_error(self):
"""Test form is invalid without name."""
park = ParkFactory()
data = {
"park": park.pk,
"category": "RC",
"status": "OPERATING",
}
form = RideForm(data=data)
is_valid = form.is_valid()
assert not is_valid
assert "name" in form.errors
def test__validate__missing_category__returns_error(self):
"""Test form is invalid without category."""
park = ParkFactory()
data = {
"name": "Test Ride",
"park": park.pk,
"status": "OPERATING",
}
form = RideForm(data=data)
is_valid = form.is_valid()
assert not is_valid
assert "category" in form.errors
def test__widgets__name_field__has_styling(self):
"""Test name field has appropriate CSS classes."""
form = RideForm()
name_widget = form.fields["name"].widget
assert "class" in name_widget.attrs
assert "rounded-lg" in name_widget.attrs["class"]
def test__widgets__category_field__has_htmx_attributes(self):
"""Test category field has HTMX attributes."""
form = RideForm()
category_widget = form.fields["category"].widget
assert "hx-get" in category_widget.attrs
assert "hx-target" in category_widget.attrs
assert "hx-trigger" in category_widget.attrs
def test__widgets__status_field__has_alpine_attributes(self):
"""Test status field has Alpine.js attributes."""
form = RideForm()
status_widget = form.fields["status"].widget
assert "x-model" in status_widget.attrs
assert "@change" in status_widget.attrs
def test__widgets__closing_date__has_conditional_display(self):
"""Test closing_date has conditional display logic."""
form = RideForm()
closing_date_widget = form.fields["closing_date"].widget
assert "x-show" in closing_date_widget.attrs
def test__meta__model__is_ride(self):
"""Test Meta.model is Ride."""
from apps.rides.models import Ride
assert RideForm.Meta.model == Ride
def test__meta__fields__includes_expected_fields(self):
"""Test Meta.fields includes expected ride fields."""
expected_fields = [
"name",
"category",
"status",
"opening_date",
"closing_date",
"min_height_in",
"max_height_in",
"description",
]
for field in expected_fields:
assert field in RideForm.Meta.fields
@pytest.mark.django_db
class TestRideSearchForm(TestCase):
"""Tests for RideSearchForm."""
def test__init__creates_ride_field(self):
"""Test initializing form creates ride field."""
form = RideSearchForm()
assert "ride" in form.fields
def test__ride_field__not_required(self):
"""Test ride field is not required."""
form = RideSearchForm()
assert form.fields["ride"].required is False
def test__ride_field__uses_select_widget(self):
"""Test ride field uses Select widget."""
form = RideSearchForm()
widget = form.fields["ride"].widget
assert "Select" in widget.__class__.__name__
def test__ride_field__has_htmx_attributes(self):
"""Test ride field has HTMX attributes."""
form = RideSearchForm()
ride_widget = form.fields["ride"].widget
assert "hx-get" in ride_widget.attrs
assert "hx-trigger" in ride_widget.attrs
assert "hx-target" in ride_widget.attrs
def test__validate__empty_form__is_valid(self):
"""Test empty form is valid."""
form = RideSearchForm(data={})
assert form.is_valid()
def test__validate__with_ride__is_valid(self):
"""Test form with valid ride is valid."""
ride = RideFactory()
form = RideSearchForm(data={"ride": ride.pk})
assert form.is_valid()
def test__validate__with_invalid_ride__is_invalid(self):
"""Test form with invalid ride is invalid."""
form = RideSearchForm(data={"ride": 99999})
assert not form.is_valid()
assert "ride" in form.errors
@pytest.mark.django_db
class TestRideFormWithParkAreas(TestCase):
"""Tests for RideForm park area functionality."""
def test__park_area__queryset_empty_without_park(self):
"""Test park_area queryset is empty when no park provided."""
form = RideForm()
# When no park, the queryset should be empty (none())
queryset = form.fields["park_area"].queryset
assert queryset.count() == 0
def test__park_area__queryset_filtered_to_park(self):
"""Test park_area queryset only contains areas from given park."""
park1 = ParkFactory()
park2 = ParkFactory()
area1 = ParkAreaFactory(park=park1)
area2 = ParkAreaFactory(park=park2)
form = RideForm(park=park1)
queryset = form.fields["park_area"].queryset
assert area1 in queryset
assert area2 not in queryset
def test__park_area__is_optional(self):
"""Test park_area field is optional."""
form = RideForm()
assert form.fields["park_area"].required is False
@pytest.mark.django_db
class TestRideFormFieldOrder(TestCase):
"""Tests for RideForm field ordering."""
def test__field_order__park_fields_first(self):
"""Test park-related fields come first."""
form = RideForm()
field_names = list(form.fields.keys())
# park_search should be first
assert field_names[0] == "park_search"
assert field_names[1] == "park"
assert field_names[2] == "park_area"
def test__field_order__name_after_park(self):
"""Test name field comes after park fields."""
form = RideForm()
field_names = list(form.fields.keys())
name_index = field_names.index("name")
park_index = field_names.index("park")
assert name_index > park_index
def test__field_order__description_last(self):
"""Test description is near the end."""
form = RideForm()
field_names = list(form.fields.keys())
# Description should be one of the last fields
description_index = field_names.index("description")
assert description_index > len(field_names) // 2

View File

@@ -0,0 +1,230 @@
"""
Integration tests for FSM (Finite State Machine) transition workflows.
These tests verify the complete state transition workflows for
Parks and Rides using the FSM implementation.
"""
import pytest
from datetime import date, timedelta
from django.test import TestCase
from django.core.exceptions import ValidationError
from apps.parks.models import Park
from apps.rides.models import Ride
from tests.factories import (
ParkFactory,
RideFactory,
UserFactory,
ParkAreaFactory,
)
@pytest.mark.django_db
class TestParkFSMTransitions(TestCase):
"""Integration tests for Park FSM transitions."""
def test__park_operating_to_closed_temp__transition_succeeds(self):
"""Test transitioning operating park to temporarily closed."""
park = ParkFactory(status="OPERATING")
user = UserFactory()
park.close_temporarily(user=user)
assert park.status == "CLOSED_TEMP"
def test__park_closed_temp_to_operating__transition_succeeds(self):
"""Test reopening temporarily closed park."""
park = ParkFactory(status="CLOSED_TEMP")
user = UserFactory()
park.open(user=user)
assert park.status == "OPERATING"
def test__park_operating_to_closed_perm__transition_succeeds(self):
"""Test closing operating park permanently."""
park = ParkFactory(status="OPERATING")
user = UserFactory()
park.close_permanently(user=user)
assert park.status == "CLOSED_PERM"
def test__park_closed_perm_to_operating__transition_not_allowed(self):
"""Test permanently closed park cannot reopen."""
park = ParkFactory(status="CLOSED_PERM")
user = UserFactory()
# This should fail - can't reopen permanently closed park
with pytest.raises(Exception):
park.open(user=user)
@pytest.mark.django_db
class TestRideFSMTransitions(TestCase):
"""Integration tests for Ride FSM transitions."""
def test__ride_operating_to_closed_temp__transition_succeeds(self):
"""Test transitioning operating ride to temporarily closed."""
ride = RideFactory(status="OPERATING")
user = UserFactory()
ride.close_temporarily(user=user)
assert ride.status == "CLOSED_TEMP"
def test__ride_closed_temp_to_operating__transition_succeeds(self):
"""Test reopening temporarily closed ride."""
ride = RideFactory(status="CLOSED_TEMP")
user = UserFactory()
ride.open(user=user)
assert ride.status == "OPERATING"
def test__ride_operating_to_sbno__transition_succeeds(self):
"""Test transitioning operating ride to SBNO (Standing But Not Operating)."""
ride = RideFactory(status="OPERATING")
user = UserFactory()
ride.mark_sbno(user=user)
assert ride.status == "SBNO"
def test__ride_sbno_to_operating__transition_succeeds(self):
"""Test reopening SBNO ride."""
ride = RideFactory(status="SBNO")
user = UserFactory()
ride.open(user=user)
assert ride.status == "OPERATING"
def test__ride_operating_to_closing__with_date__transition_succeeds(self):
"""Test scheduling ride for closing."""
ride = RideFactory(status="OPERATING")
user = UserFactory()
closing_date = date.today() + timedelta(days=30)
ride.mark_closing(
closing_date=closing_date,
post_closing_status="DEMOLISHED",
user=user,
)
assert ride.status == "CLOSING"
assert ride.closing_date == closing_date
assert ride.post_closing_status == "DEMOLISHED"
def test__ride_closing_to_demolished__transition_succeeds(self):
"""Test transitioning closing ride to demolished."""
ride = RideFactory(status="CLOSING")
ride.post_closing_status = "DEMOLISHED"
ride.save()
user = UserFactory()
ride.demolish(user=user)
assert ride.status == "DEMOLISHED"
def test__ride_operating_to_relocated__transition_succeeds(self):
"""Test marking ride as relocated."""
ride = RideFactory(status="OPERATING")
user = UserFactory()
ride.relocate(user=user)
assert ride.status == "RELOCATED"
@pytest.mark.django_db
class TestRideRelocationWorkflow(TestCase):
"""Integration tests for ride relocation workflow."""
def test__relocate_ride__to_new_park__updates_park(self):
"""Test relocating ride to new park updates the park relationship."""
old_park = ParkFactory(name="Old Park")
new_park = ParkFactory(name="New Park")
ride = RideFactory(park=old_park, status="OPERATING")
user = UserFactory()
# Mark as relocated first
ride.relocate(user=user)
assert ride.status == "RELOCATED"
# Move to new park
ride.move_to_park(new_park, clear_park_area=True)
assert ride.park == new_park
assert ride.park_area is None # Cleared during relocation
def test__relocate_ride__clears_park_area(self):
"""Test relocating ride clears park area."""
park = ParkFactory()
area = ParkAreaFactory(park=park)
new_park = ParkFactory()
ride = RideFactory(park=park, park_area=area, status="OPERATING")
user = UserFactory()
ride.relocate(user=user)
ride.move_to_park(new_park, clear_park_area=True)
assert ride.park_area is None
@pytest.mark.django_db
class TestRideStatusTransitionHistory(TestCase):
"""Integration tests for ride status transition history."""
def test__multiple_transitions__records_status_since(self):
"""Test multiple transitions update status_since correctly."""
ride = RideFactory(status="OPERATING")
user = UserFactory()
# First transition
ride.close_temporarily(user=user)
first_status_since = ride.status_since
assert ride.status == "CLOSED_TEMP"
# Second transition
ride.open(user=user)
second_status_since = ride.status_since
assert ride.status == "OPERATING"
# status_since should be updated for new transition
assert second_status_since >= first_status_since
@pytest.mark.django_db
class TestParkRideCascadeStatus(TestCase):
"""Integration tests for park status affecting rides."""
def test__close_park__does_not_auto_close_rides(self):
"""Test closing park doesn't automatically close rides."""
park = ParkFactory(status="OPERATING")
ride = RideFactory(park=park, status="OPERATING")
user = UserFactory()
# Close the park
park.close_temporarily(user=user)
# Ride should still be operating (business decision)
ride.refresh_from_db()
assert ride.status == "OPERATING" # Rides keep their independent status
def test__reopen_park__allows_ride_operation(self):
"""Test reopening park allows rides to continue operating."""
park = ParkFactory(status="CLOSED_TEMP")
ride = RideFactory(park=park, status="OPERATING")
user = UserFactory()
# Reopen park
park.open(user=user)
assert park.status == "OPERATING"
ride.refresh_from_db()
assert ride.status == "OPERATING"

View File

@@ -0,0 +1,233 @@
"""
Integration tests for park creation workflow.
These tests verify the complete workflow of park creation including
validation, location creation, and related operations.
"""
import pytest
from django.test import TestCase, TransactionTestCase
from django.db import transaction
from apps.parks.models import Park, ParkArea, ParkReview
from apps.parks.forms import ParkForm
from tests.factories import (
ParkFactory,
ParkAreaFactory,
OperatorCompanyFactory,
UserFactory,
RideFactory,
)
@pytest.mark.django_db
class TestParkCreationWorkflow(TestCase):
"""Integration tests for complete park creation workflow."""
def test__create_park_with_form__valid_data__creates_park_and_location(self):
"""Test creating a park with form creates both park and location."""
operator = OperatorCompanyFactory()
data = {
"name": "New Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "37.123456",
"longitude": "-122.654321",
"city": "San Francisco",
"state": "CA",
"country": "USA",
}
form = ParkForm(data=data)
if form.is_valid():
park = form.save()
# Verify park was created
assert park.pk is not None
assert park.name == "New Test Park"
assert park.operator == operator
# Verify location was created
if park.location.exists():
location = park.location.first()
assert location.city == "San Francisco"
assert location.country == "USA"
def test__create_park__with_areas__creates_complete_structure(self):
"""Test creating a park with areas creates complete structure."""
park = ParkFactory()
# Add areas
area1 = ParkAreaFactory(park=park, name="Main Entrance")
area2 = ParkAreaFactory(park=park, name="Thrill Zone")
area3 = ParkAreaFactory(park=park, name="Kids Area")
# Verify structure
assert park.areas.count() == 3
assert park.areas.filter(name="Main Entrance").exists()
assert park.areas.filter(name="Thrill Zone").exists()
assert park.areas.filter(name="Kids Area").exists()
def test__create_park__with_rides__updates_counts(self):
"""Test creating a park with rides updates ride counts."""
park = ParkFactory()
# Add rides
RideFactory(park=park, category="RC") # Roller coaster
RideFactory(park=park, category="RC") # Roller coaster
RideFactory(park=park, category="TR") # Thrill ride
RideFactory(park=park, category="DR") # Dark ride
# Verify ride counts
assert park.rides.count() == 4
assert park.rides.filter(category="RC").count() == 2
@pytest.mark.django_db
class TestParkUpdateWorkflow(TestCase):
"""Integration tests for park update workflow."""
def test__update_park__changes_status__updates_correctly(self):
"""Test updating park status updates correctly."""
park = ParkFactory(status="OPERATING")
# Update via FSM transition
park.close_temporarily()
park.refresh_from_db()
assert park.status == "CLOSED_TEMP"
def test__update_park_location__updates_location_record(self):
"""Test updating park location updates the location record."""
park = ParkFactory()
form_data = {
"name": park.name,
"operator": park.operator.pk,
"status": park.status,
"city": "New City",
"state": "NY",
"country": "USA",
}
form = ParkForm(instance=park, data=form_data)
if form.is_valid():
updated_park = form.save()
# Verify location was updated
if updated_park.location.exists():
location = updated_park.location.first()
assert location.city == "New City"
@pytest.mark.django_db
class TestParkReviewWorkflow(TestCase):
"""Integration tests for park review workflow."""
def test__add_review__updates_park_rating(self):
"""Test adding a review affects park's average rating."""
park = ParkFactory()
user1 = UserFactory()
user2 = UserFactory()
# Add reviews
from tests.factories import ParkReviewFactory
ParkReviewFactory(park=park, user=user1, rating=8, is_published=True)
ParkReviewFactory(park=park, user=user2, rating=10, is_published=True)
# Calculate average
avg = park.reviews.filter(is_published=True).values_list(
"rating", flat=True
)
calculated_avg = sum(avg) / len(avg)
assert calculated_avg == 9.0
def test__unpublish_review__excludes_from_rating(self):
"""Test unpublishing a review excludes it from rating calculation."""
park = ParkFactory()
user1 = UserFactory()
user2 = UserFactory()
from tests.factories import ParkReviewFactory
review1 = ParkReviewFactory(park=park, user=user1, rating=10, is_published=True)
review2 = ParkReviewFactory(park=park, user=user2, rating=2, is_published=True)
# Unpublish the low rating
review2.is_published = False
review2.save()
# Calculate average - should only include published reviews
published_reviews = park.reviews.filter(is_published=True)
assert published_reviews.count() == 1
assert published_reviews.first().rating == 10
@pytest.mark.django_db
class TestParkAreaRideWorkflow(TestCase):
"""Integration tests for park area and ride workflow."""
def test__add_ride_to_area__associates_correctly(self):
"""Test adding a ride to an area associates them correctly."""
park = ParkFactory()
area = ParkAreaFactory(park=park, name="Thrill Zone")
ride = RideFactory(park=park, park_area=area, name="Super Coaster")
assert ride.park_area == area
assert ride in area.rides.all()
def test__delete_area__handles_rides_correctly(self):
"""Test deleting an area handles associated rides."""
park = ParkFactory()
area = ParkAreaFactory(park=park)
ride = RideFactory(park=park, park_area=area)
ride_pk = ride.pk
# Delete area - ride should have park_area set to NULL
area.delete()
ride.refresh_from_db()
assert ride.park_area is None
assert ride.pk == ride_pk # Ride still exists
@pytest.mark.django_db
class TestParkOperatorWorkflow(TestCase):
"""Integration tests for park operator workflow."""
def test__change_operator__updates_park(self):
"""Test changing park operator updates the relationship."""
old_operator = OperatorCompanyFactory(name="Old Operator")
new_operator = OperatorCompanyFactory(name="New Operator")
park = ParkFactory(operator=old_operator)
# Change operator
park.operator = new_operator
park.save()
park.refresh_from_db()
assert park.operator == new_operator
assert park.operator.name == "New Operator"
def test__operator_with_multiple_parks__lists_all_parks(self):
"""Test operator with multiple parks lists all parks."""
operator = OperatorCompanyFactory()
park1 = ParkFactory(operator=operator, name="Park One")
park2 = ParkFactory(operator=operator, name="Park Two")
park3 = ParkFactory(operator=operator, name="Park Three")
# Verify operator's parks
operator_parks = operator.operated_parks.all()
assert operator_parks.count() == 3
assert park1 in operator_parks
assert park2 in operator_parks
assert park3 in operator_parks

View File

@@ -0,0 +1,224 @@
"""
Integration tests for photo upload workflow.
These tests verify the complete workflow of photo uploads including
validation, processing, and moderation.
"""
import pytest
from unittest.mock import Mock, patch
from django.test import TestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from apps.parks.models import ParkPhoto
from apps.rides.models import RidePhoto
from apps.parks.services.media_service import ParkMediaService
from tests.factories import (
ParkFactory,
RideFactory,
ParkPhotoFactory,
RidePhotoFactory,
UserFactory,
StaffUserFactory,
)
@pytest.mark.django_db
class TestParkPhotoUploadWorkflow(TestCase):
"""Integration tests for park photo upload workflow."""
@patch("apps.parks.services.media_service.MediaService.validate_image_file")
@patch("apps.parks.services.media_service.MediaService.process_image")
@patch("apps.parks.services.media_service.MediaService.generate_default_caption")
@patch("apps.parks.services.media_service.MediaService.extract_exif_date")
def test__upload_photo__creates_pending_photo(
self, mock_exif, mock_caption, mock_process, mock_validate
):
"""Test uploading photo creates a pending photo."""
mock_validate.return_value = (True, None)
mock_process.return_value = Mock()
mock_caption.return_value = "Photo by testuser"
mock_exif.return_value = None
park = ParkFactory()
user = UserFactory()
image = SimpleUploadedFile("test.jpg", b"image data", content_type="image/jpeg")
photo = ParkMediaService.upload_photo(
park=park,
image_file=image,
user=user,
caption="Test photo",
auto_approve=False,
)
assert photo.is_approved is False
assert photo.uploaded_by == user
assert photo.park == park
@patch("apps.parks.services.media_service.MediaService.validate_image_file")
@patch("apps.parks.services.media_service.MediaService.process_image")
@patch("apps.parks.services.media_service.MediaService.generate_default_caption")
@patch("apps.parks.services.media_service.MediaService.extract_exif_date")
def test__upload_photo__auto_approve__creates_approved_photo(
self, mock_exif, mock_caption, mock_process, mock_validate
):
"""Test uploading photo with auto_approve creates approved photo."""
mock_validate.return_value = (True, None)
mock_process.return_value = Mock()
mock_caption.return_value = "Photo by testuser"
mock_exif.return_value = None
park = ParkFactory()
user = UserFactory()
image = SimpleUploadedFile("test.jpg", b"image data", content_type="image/jpeg")
photo = ParkMediaService.upload_photo(
park=park,
image_file=image,
user=user,
auto_approve=True,
)
assert photo.is_approved is True
@pytest.mark.django_db
class TestPhotoModerationWorkflow(TestCase):
"""Integration tests for photo moderation workflow."""
def test__approve_photo__marks_as_approved(self):
"""Test approving a photo marks it as approved."""
photo = ParkPhotoFactory(is_approved=False)
moderator = StaffUserFactory()
result = ParkMediaService.approve_photo(photo, moderator)
photo.refresh_from_db()
assert result is True
assert photo.is_approved is True
def test__bulk_approve_photos__approves_all(self):
"""Test bulk approving photos approves all photos."""
park = ParkFactory()
photos = [
ParkPhotoFactory(park=park, is_approved=False),
ParkPhotoFactory(park=park, is_approved=False),
ParkPhotoFactory(park=park, is_approved=False),
]
moderator = StaffUserFactory()
count = ParkMediaService.bulk_approve_photos(photos, moderator)
assert count == 3
for photo in photos:
photo.refresh_from_db()
assert photo.is_approved is True
@pytest.mark.django_db
class TestPrimaryPhotoWorkflow(TestCase):
"""Integration tests for primary photo workflow."""
def test__set_primary_photo__unsets_previous_primary(self):
"""Test setting primary photo unsets previous primary."""
park = ParkFactory()
old_primary = ParkPhotoFactory(park=park, is_primary=True)
new_primary = ParkPhotoFactory(park=park, is_primary=False)
result = ParkMediaService.set_primary_photo(park, new_primary)
old_primary.refresh_from_db()
new_primary.refresh_from_db()
assert result is True
assert old_primary.is_primary is False
assert new_primary.is_primary is True
def test__get_primary_photo__returns_correct_photo(self):
"""Test get_primary_photo returns the primary photo."""
park = ParkFactory()
ParkPhotoFactory(park=park, is_primary=False, is_approved=True)
primary = ParkPhotoFactory(park=park, is_primary=True, is_approved=True)
ParkPhotoFactory(park=park, is_primary=False, is_approved=True)
result = ParkMediaService.get_primary_photo(park)
assert result == primary
@pytest.mark.django_db
class TestPhotoStatsWorkflow(TestCase):
"""Integration tests for photo statistics workflow."""
def test__get_photo_stats__returns_accurate_counts(self):
"""Test get_photo_stats returns accurate statistics."""
park = ParkFactory()
# Create various photos
ParkPhotoFactory(park=park, is_approved=True)
ParkPhotoFactory(park=park, is_approved=True)
ParkPhotoFactory(park=park, is_approved=False)
ParkPhotoFactory(park=park, is_approved=True, is_primary=True)
stats = ParkMediaService.get_photo_stats(park)
assert stats["total_photos"] == 4
assert stats["approved_photos"] == 3
assert stats["pending_photos"] == 1
assert stats["has_primary"] is True
@pytest.mark.django_db
class TestPhotoDeleteWorkflow(TestCase):
"""Integration tests for photo deletion workflow."""
def test__delete_photo__removes_photo(self):
"""Test deleting a photo removes it from database."""
photo = ParkPhotoFactory()
photo_id = photo.pk
moderator = StaffUserFactory()
result = ParkMediaService.delete_photo(photo, moderator)
assert result is True
assert not ParkPhoto.objects.filter(pk=photo_id).exists()
def test__delete_primary_photo__removes_primary(self):
"""Test deleting primary photo removes primary status."""
park = ParkFactory()
primary = ParkPhotoFactory(park=park, is_primary=True)
moderator = StaffUserFactory()
ParkMediaService.delete_photo(primary, moderator)
# Park should no longer have a primary photo
result = ParkMediaService.get_primary_photo(park)
assert result is None
@pytest.mark.django_db
class TestRidePhotoWorkflow(TestCase):
"""Integration tests for ride photo workflow."""
def test__ride_photo__includes_park_info(self):
"""Test ride photo includes park information."""
ride = RideFactory()
photo = RidePhotoFactory(ride=ride)
# Photo should have access to park through ride
assert photo.ride.park is not None
assert photo.ride.park.name is not None
def test__ride_photo__different_types(self):
"""Test ride photos can have different types."""
ride = RideFactory()
exterior = RidePhotoFactory(ride=ride, photo_type="exterior")
queue = RidePhotoFactory(ride=ride, photo_type="queue")
onride = RidePhotoFactory(ride=ride, photo_type="onride")
assert ride.photos.filter(photo_type="exterior").count() == 1
assert ride.photos.filter(photo_type="queue").count() == 1
assert ride.photos.filter(photo_type="onride").count() == 1

View File

@@ -0,0 +1,6 @@
"""
Manager and QuerySet tests.
This module contains tests for custom managers and querysets
to verify filtering, optimization, and annotation logic.
"""

View File

@@ -0,0 +1,354 @@
"""
Tests for Core managers and querysets.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from django.test import TestCase
from django.utils import timezone
from datetime import timedelta
from unittest.mock import Mock, patch
from apps.core.managers import (
BaseQuerySet,
BaseManager,
LocationQuerySet,
LocationManager,
ReviewableQuerySet,
ReviewableManager,
HierarchicalQuerySet,
HierarchicalManager,
TimestampedQuerySet,
TimestampedManager,
StatusQuerySet,
StatusManager,
)
from tests.factories import (
ParkFactory,
ParkReviewFactory,
RideFactory,
UserFactory,
)
@pytest.mark.django_db
class TestBaseQuerySet(TestCase):
"""Tests for BaseQuerySet."""
def test__active__filters_active_records(self):
"""Test active filters by is_active field if present."""
# Using User model which has is_active
from django.contrib.auth import get_user_model
User = get_user_model()
active_user = User.objects.create_user(
username="active", email="active@test.com", password="test", is_active=True
)
inactive_user = User.objects.create_user(
username="inactive", email="inactive@test.com", password="test", is_active=False
)
result = User.objects.filter(is_active=True)
assert active_user in result
assert inactive_user not in result
def test__recent__filters_recently_created(self):
"""Test recent filters by created_at within days."""
park = ParkFactory()
# Created just now, should be in recent
from apps.parks.models import Park
result = Park.objects.recent(days=30)
assert park in result
def test__search__searches_by_name(self):
"""Test search filters by name field."""
park1 = ParkFactory(name="Cedar Point")
park2 = ParkFactory(name="Kings Island")
from apps.parks.models import Park
result = Park.objects.search(query="Cedar")
assert park1 in result
assert park2 not in result
def test__search__empty_query__returns_all(self):
"""Test search with empty query returns all records."""
park1 = ParkFactory()
park2 = ParkFactory()
from apps.parks.models import Park
result = Park.objects.search(query="")
assert park1 in result
assert park2 in result
@pytest.mark.django_db
class TestLocationQuerySet(TestCase):
"""Tests for LocationQuerySet."""
def test__by_country__filters_by_country(self):
"""Test by_country filters by country field."""
# Create parks with locations through factory
us_park = ParkFactory()
# Location is created by factory post_generation
from apps.parks.models import Park
# This tests the pattern - actual filtering depends on location setup
result = Park.objects.all()
assert us_park in result
@pytest.mark.django_db
class TestReviewableQuerySet(TestCase):
"""Tests for ReviewableQuerySet."""
def test__with_review_stats__annotates_review_count(self):
"""Test with_review_stats adds review count annotation."""
from apps.parks.models import Park
park = ParkFactory()
user1 = UserFactory()
user2 = UserFactory()
ParkReviewFactory(park=park, user=user1, is_published=True)
ParkReviewFactory(park=park, user=user2, is_published=True)
result = Park.objects.with_review_stats().get(pk=park.pk)
assert result.review_count == 2
def test__with_review_stats__calculates_average_rating(self):
"""Test with_review_stats calculates average rating."""
from apps.parks.models import Park
park = ParkFactory()
user1 = UserFactory()
user2 = UserFactory()
ParkReviewFactory(park=park, user=user1, is_published=True, rating=8)
ParkReviewFactory(park=park, user=user2, is_published=True, rating=10)
result = Park.objects.with_review_stats().get(pk=park.pk)
assert result.average_rating == 9.0
def test__with_review_stats__excludes_unpublished(self):
"""Test with_review_stats excludes unpublished reviews."""
from apps.parks.models import Park
park = ParkFactory()
user1 = UserFactory()
user2 = UserFactory()
ParkReviewFactory(park=park, user=user1, is_published=True, rating=10)
ParkReviewFactory(park=park, user=user2, is_published=False, rating=2)
result = Park.objects.with_review_stats().get(pk=park.pk)
assert result.review_count == 1
assert result.average_rating == 10.0
def test__highly_rated__filters_by_minimum_rating(self):
"""Test highly_rated filters by minimum average rating."""
from apps.parks.models import Park
high_rated = ParkFactory()
low_rated = ParkFactory()
user1 = UserFactory()
user2 = UserFactory()
ParkReviewFactory(park=high_rated, user=user1, is_published=True, rating=9)
ParkReviewFactory(park=low_rated, user=user2, is_published=True, rating=4)
result = Park.objects.highly_rated(min_rating=8.0)
assert high_rated in result
assert low_rated not in result
def test__recently_reviewed__filters_by_recent_reviews(self):
"""Test recently_reviewed filters parks with recent reviews."""
from apps.parks.models import Park
reviewed_park = ParkFactory()
user = UserFactory()
ParkReviewFactory(park=reviewed_park, user=user, is_published=True)
result = Park.objects.get_queryset().recently_reviewed(days=30)
assert reviewed_park in result
@pytest.mark.django_db
class TestStatusQuerySet(TestCase):
"""Tests for StatusQuerySet."""
def test__with_status__single_status__filters_correctly(self):
"""Test with_status filters by single status."""
from apps.parks.models import Park
operating = ParkFactory(status="OPERATING")
closed = ParkFactory(status="CLOSED_PERM")
result = Park.objects.get_queryset().with_status(status="OPERATING")
assert operating in result
assert closed not in result
def test__with_status__multiple_statuses__filters_correctly(self):
"""Test with_status filters by multiple statuses."""
from apps.parks.models import Park
operating = ParkFactory(status="OPERATING")
closed_temp = ParkFactory(status="CLOSED_TEMP")
closed_perm = ParkFactory(status="CLOSED_PERM")
result = Park.objects.get_queryset().with_status(status=["CLOSED_TEMP", "CLOSED_PERM"])
assert operating not in result
assert closed_temp in result
assert closed_perm in result
def test__operating__filters_operating_status(self):
"""Test operating filters for OPERATING status."""
from apps.parks.models import Park
operating = ParkFactory(status="OPERATING")
closed = ParkFactory(status="CLOSED_PERM")
result = Park.objects.operating()
assert operating in result
assert closed not in result
def test__closed__filters_closed_statuses(self):
"""Test closed filters for closed statuses."""
from apps.parks.models import Park
operating = ParkFactory(status="OPERATING")
closed_temp = ParkFactory(status="CLOSED_TEMP")
closed_perm = ParkFactory(status="CLOSED_PERM")
result = Park.objects.closed()
assert operating not in result
assert closed_temp in result
assert closed_perm in result
@pytest.mark.django_db
class TestTimestampedQuerySet(TestCase):
"""Tests for TimestampedQuerySet."""
def test__by_creation_date_descending__orders_newest_first(self):
"""Test by_creation_date with descending orders newest first."""
from apps.parks.models import Park
park1 = ParkFactory()
park2 = ParkFactory()
result = list(Park.objects.get_queryset().by_creation_date(descending=True))
# Most recently created should be first
assert result[0] == park2
assert result[1] == park1
def test__by_creation_date_ascending__orders_oldest_first(self):
"""Test by_creation_date with ascending orders oldest first."""
from apps.parks.models import Park
park1 = ParkFactory()
park2 = ParkFactory()
result = list(Park.objects.get_queryset().by_creation_date(descending=False))
# Oldest should be first
assert result[0] == park1
assert result[1] == park2
@pytest.mark.django_db
class TestBaseManager(TestCase):
"""Tests for BaseManager."""
def test__active__delegates_to_queryset(self):
"""Test active method delegates to queryset."""
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.create_user(
username="test", email="test@test.com", password="test", is_active=True
)
# BaseManager's active method should work
result = User.objects.filter(is_active=True)
assert user in result
def test__recent__delegates_to_queryset(self):
"""Test recent method delegates to queryset."""
from apps.parks.models import Park
park = ParkFactory()
result = Park.objects.recent(days=30)
assert park in result
def test__search__delegates_to_queryset(self):
"""Test search method delegates to queryset."""
from apps.parks.models import Park
park = ParkFactory(name="Unique Name")
result = Park.objects.search(query="Unique")
assert park in result
@pytest.mark.django_db
class TestStatusManager(TestCase):
"""Tests for StatusManager."""
def test__operating__delegates_to_queryset(self):
"""Test operating method delegates to queryset."""
from apps.parks.models import Park
operating = ParkFactory(status="OPERATING")
result = Park.objects.operating()
assert operating in result
def test__closed__delegates_to_queryset(self):
"""Test closed method delegates to queryset."""
from apps.parks.models import Park
closed = ParkFactory(status="CLOSED_PERM")
result = Park.objects.closed()
assert closed in result
@pytest.mark.django_db
class TestReviewableManager(TestCase):
"""Tests for ReviewableManager."""
def test__with_review_stats__delegates_to_queryset(self):
"""Test with_review_stats method delegates to queryset."""
from apps.parks.models import Park
park = ParkFactory()
result = Park.objects.with_review_stats()
assert park in result
def test__highly_rated__delegates_to_queryset(self):
"""Test highly_rated method delegates to queryset."""
from apps.parks.models import Park
park = ParkFactory()
user = UserFactory()
ParkReviewFactory(park=park, user=user, is_published=True, rating=9)
result = Park.objects.highly_rated(min_rating=8.0)
assert park in result

View File

@@ -0,0 +1,381 @@
"""
Tests for Park managers and querysets.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from django.test import TestCase
from django.utils import timezone
from datetime import timedelta
from apps.parks.models import Park, ParkArea, ParkReview, Company
from apps.parks.managers import (
ParkQuerySet,
ParkManager,
ParkAreaQuerySet,
ParkAreaManager,
ParkReviewQuerySet,
ParkReviewManager,
CompanyQuerySet,
CompanyManager,
)
from tests.factories import (
ParkFactory,
ParkAreaFactory,
ParkReviewFactory,
RideFactory,
CoasterFactory,
UserFactory,
OperatorCompanyFactory,
ManufacturerCompanyFactory,
)
@pytest.mark.django_db
class TestParkQuerySet(TestCase):
"""Tests for ParkQuerySet."""
def test__with_complete_stats__annotates_ride_counts(self):
"""Test with_complete_stats adds ride count annotations."""
park = ParkFactory()
RideFactory(park=park, category="TR")
RideFactory(park=park, category="TR")
CoasterFactory(park=park, category="RC")
result = Park.objects.with_complete_stats().get(pk=park.pk)
assert result.ride_count_calculated == 3
assert result.coaster_count_calculated == 1
def test__with_complete_stats__annotates_review_stats(self):
"""Test with_complete_stats adds review statistics."""
park = ParkFactory()
user1 = UserFactory()
user2 = UserFactory()
ParkReviewFactory(park=park, user=user1, is_published=True, rating=8)
ParkReviewFactory(park=park, user=user2, is_published=True, rating=6)
result = Park.objects.with_complete_stats().get(pk=park.pk)
assert result.review_count == 2
assert result.average_rating_calculated == 7.0
def test__with_complete_stats__excludes_unpublished_reviews(self):
"""Test review stats exclude unpublished reviews."""
park = ParkFactory()
user1 = UserFactory()
user2 = UserFactory()
ParkReviewFactory(park=park, user=user1, is_published=True, rating=10)
ParkReviewFactory(park=park, user=user2, is_published=False, rating=2)
result = Park.objects.with_complete_stats().get(pk=park.pk)
assert result.review_count == 1
assert result.average_rating_calculated == 10.0
def test__optimized_for_list__returns_prefetched_data(self):
"""Test optimized_for_list prefetches related data."""
ParkFactory()
ParkFactory()
queryset = Park.objects.optimized_for_list()
# Should have prefetch cache populated
assert queryset.count() == 2
def test__by_operator__filters_by_operator_id(self):
"""Test by_operator filters parks by operator."""
operator = OperatorCompanyFactory()
other_operator = OperatorCompanyFactory()
park1 = ParkFactory(operator=operator)
park2 = ParkFactory(operator=other_operator)
result = Park.objects.by_operator(operator_id=operator.pk)
assert park1 in result
assert park2 not in result
def test__by_property_owner__filters_by_owner_id(self):
"""Test by_property_owner filters parks by property owner."""
owner = OperatorCompanyFactory()
park1 = ParkFactory(property_owner=owner)
park2 = ParkFactory()
result = Park.objects.get_queryset().by_property_owner(owner_id=owner.pk)
assert park1 in result
assert park2 not in result
def test__with_minimum_coasters__filters_by_coaster_count(self):
"""Test with_minimum_coasters filters parks with enough coasters."""
park1 = ParkFactory()
park2 = ParkFactory()
# Add 5 coasters to park1
for _ in range(5):
CoasterFactory(park=park1)
# Add only 2 coasters to park2
for _ in range(2):
CoasterFactory(park=park2)
result = Park.objects.with_minimum_coasters(min_coasters=5)
assert park1 in result
assert park2 not in result
def test__large_parks__filters_by_size(self):
"""Test large_parks filters by minimum acreage."""
large_park = ParkFactory(size_acres=200)
small_park = ParkFactory(size_acres=50)
result = Park.objects.large_parks(min_acres=100)
assert large_park in result
assert small_park not in result
def test__seasonal_parks__excludes_empty_operating_season(self):
"""Test seasonal_parks excludes parks with empty operating_season."""
seasonal_park = ParkFactory(operating_season="Summer only")
year_round_park = ParkFactory(operating_season="")
result = Park.objects.get_queryset().seasonal_parks()
assert seasonal_park in result
assert year_round_park not in result
def test__search_autocomplete__searches_by_name(self):
"""Test search_autocomplete searches park names."""
park1 = ParkFactory(name="Cedar Point")
park2 = ParkFactory(name="Kings Island")
result = list(Park.objects.get_queryset().search_autocomplete(query="Cedar"))
assert park1 in result
assert park2 not in result
def test__search_autocomplete__limits_results(self):
"""Test search_autocomplete respects limit parameter."""
for i in range(15):
ParkFactory(name=f"Test Park {i}")
result = list(Park.objects.get_queryset().search_autocomplete(query="Test", limit=5))
assert len(result) == 5
@pytest.mark.django_db
class TestParkManager(TestCase):
"""Tests for ParkManager."""
def test__get_queryset__returns_park_queryset(self):
"""Test get_queryset returns ParkQuerySet."""
queryset = Park.objects.get_queryset()
assert isinstance(queryset, ParkQuerySet)
def test__operating__filters_operating_parks(self):
"""Test operating filters for operating status."""
operating = ParkFactory(status="OPERATING")
closed = ParkFactory(status="CLOSED_PERM")
result = Park.objects.operating()
assert operating in result
assert closed not in result
def test__closed__filters_closed_parks(self):
"""Test closed filters for closed statuses."""
operating = ParkFactory(status="OPERATING")
closed_temp = ParkFactory(status="CLOSED_TEMP")
closed_perm = ParkFactory(status="CLOSED_PERM")
result = Park.objects.closed()
assert operating not in result
assert closed_temp in result
assert closed_perm in result
@pytest.mark.django_db
class TestParkAreaQuerySet(TestCase):
"""Tests for ParkAreaQuerySet."""
def test__with_ride_counts__annotates_ride_count(self):
"""Test with_ride_counts adds ride count annotation."""
park = ParkFactory()
area = ParkAreaFactory(park=park)
RideFactory(park=park, park_area=area)
RideFactory(park=park, park_area=area)
CoasterFactory(park=park, park_area=area)
result = ParkArea.objects.with_ride_counts().get(pk=area.pk)
assert result.ride_count == 3
assert result.coaster_count == 1
def test__by_park__filters_by_park_id(self):
"""Test by_park filters areas by park."""
park1 = ParkFactory()
park2 = ParkFactory()
area1 = ParkAreaFactory(park=park1)
area2 = ParkAreaFactory(park=park2)
result = ParkArea.objects.by_park(park_id=park1.pk)
assert area1 in result
assert area2 not in result
def test__with_rides__filters_areas_with_rides(self):
"""Test with_rides filters areas that have rides."""
park = ParkFactory()
area_with_rides = ParkAreaFactory(park=park)
area_without_rides = ParkAreaFactory(park=park)
RideFactory(park=park, park_area=area_with_rides)
result = ParkArea.objects.get_queryset().with_rides()
assert area_with_rides in result
assert area_without_rides not in result
@pytest.mark.django_db
class TestParkReviewQuerySet(TestCase):
"""Tests for ParkReviewQuerySet."""
def test__for_park__filters_by_park_id(self):
"""Test for_park filters reviews by park."""
park1 = ParkFactory()
park2 = ParkFactory()
user = UserFactory()
review1 = ParkReviewFactory(park=park1, user=user)
user2 = UserFactory()
review2 = ParkReviewFactory(park=park2, user=user2)
result = ParkReview.objects.for_park(park_id=park1.pk)
assert review1 in result
assert review2 not in result
def test__by_user__filters_by_user_id(self):
"""Test by_user filters reviews by user."""
user1 = UserFactory()
user2 = UserFactory()
review1 = ParkReviewFactory(user=user1)
review2 = ParkReviewFactory(user=user2)
result = ParkReview.objects.get_queryset().by_user(user_id=user1.pk)
assert review1 in result
assert review2 not in result
def test__by_rating_range__filters_by_rating(self):
"""Test by_rating_range filters reviews by rating range."""
user1 = UserFactory()
user2 = UserFactory()
user3 = UserFactory()
high_review = ParkReviewFactory(rating=9, user=user1)
mid_review = ParkReviewFactory(rating=5, user=user2)
low_review = ParkReviewFactory(rating=2, user=user3)
result = ParkReview.objects.by_rating_range(min_rating=7, max_rating=10)
assert high_review in result
assert mid_review not in result
assert low_review not in result
def test__moderation_required__filters_unpublished_or_unmoderated(self):
"""Test moderation_required filters reviews needing moderation."""
user1 = UserFactory()
user2 = UserFactory()
published = ParkReviewFactory(is_published=True, user=user1)
unpublished = ParkReviewFactory(is_published=False, user=user2)
result = ParkReview.objects.moderation_required()
# unpublished should definitely be in result
assert unpublished in result
@pytest.mark.django_db
class TestCompanyQuerySet(TestCase):
"""Tests for CompanyQuerySet."""
def test__operators__filters_operator_companies(self):
"""Test operators filters for companies with OPERATOR role."""
operator = OperatorCompanyFactory()
manufacturer = ManufacturerCompanyFactory()
result = Company.objects.operators()
assert operator in result
assert manufacturer not in result
def test__manufacturers__filters_manufacturer_companies(self):
"""Test manufacturers filters for companies with MANUFACTURER role."""
operator = OperatorCompanyFactory()
manufacturer = ManufacturerCompanyFactory()
result = Company.objects.manufacturers()
assert manufacturer in result
assert operator not in result
def test__with_park_counts__annotates_park_counts(self):
"""Test with_park_counts adds park count annotations."""
operator = OperatorCompanyFactory()
ParkFactory(operator=operator)
ParkFactory(operator=operator)
ParkFactory(property_owner=operator)
result = Company.objects.get_queryset().with_park_counts().get(pk=operator.pk)
assert result.operated_parks_count == 2
assert result.owned_parks_count == 1
def test__major_operators__filters_by_minimum_parks(self):
"""Test major_operators filters by minimum park count."""
major_operator = OperatorCompanyFactory()
small_operator = OperatorCompanyFactory()
for _ in range(6):
ParkFactory(operator=major_operator)
for _ in range(2):
ParkFactory(operator=small_operator)
result = Company.objects.major_operators(min_parks=5)
assert major_operator in result
assert small_operator not in result
@pytest.mark.django_db
class TestCompanyManager(TestCase):
"""Tests for CompanyManager."""
def test__manufacturers_with_ride_count__annotates_ride_count(self):
"""Test manufacturers_with_ride_count adds ride count annotation."""
manufacturer = ManufacturerCompanyFactory()
RideFactory(manufacturer=manufacturer)
RideFactory(manufacturer=manufacturer)
RideFactory(manufacturer=manufacturer)
result = list(Company.objects.manufacturers_with_ride_count())
mfr = next((c for c in result if c.pk == manufacturer.pk), None)
assert mfr is not None
assert mfr.ride_count == 3
def test__operators_with_park_count__annotates_park_count(self):
"""Test operators_with_park_count adds park count annotation."""
operator = OperatorCompanyFactory()
ParkFactory(operator=operator)
ParkFactory(operator=operator)
result = list(Company.objects.operators_with_park_count())
op = next((c for c in result if c.pk == operator.pk), None)
assert op is not None
assert op.operated_parks_count == 2

View File

@@ -0,0 +1,408 @@
"""
Tests for Ride managers and querysets.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from django.test import TestCase
from django.utils import timezone
from apps.rides.models import Ride, RideModel, RideReview
from apps.rides.managers import (
RideQuerySet,
RideManager,
RideModelQuerySet,
RideModelManager,
RideReviewQuerySet,
RideReviewManager,
RollerCoasterStatsQuerySet,
RollerCoasterStatsManager,
)
from tests.factories import (
RideFactory,
CoasterFactory,
ParkFactory,
RideModelFactory,
RideReviewFactory,
UserFactory,
ManufacturerCompanyFactory,
DesignerCompanyFactory,
)
@pytest.mark.django_db
class TestRideQuerySet(TestCase):
"""Tests for RideQuerySet."""
def test__by_category__single_category__filters_correctly(self):
"""Test by_category filters by single category."""
coaster = RideFactory(category="RC")
water_ride = RideFactory(category="WR")
result = Ride.objects.get_queryset().by_category(category="RC")
assert coaster in result
assert water_ride not in result
def test__by_category__multiple_categories__filters_correctly(self):
"""Test by_category filters by multiple categories."""
rc = RideFactory(category="RC")
wc = RideFactory(category="WC")
tr = RideFactory(category="TR")
result = Ride.objects.get_queryset().by_category(category=["RC", "WC"])
assert rc in result
assert wc in result
assert tr not in result
def test__coasters__filters_roller_coasters(self):
"""Test coasters filters for RC and WC categories."""
steel = RideFactory(category="RC")
wooden = RideFactory(category="WC")
thrill = RideFactory(category="TR")
result = Ride.objects.coasters()
assert steel in result
assert wooden in result
assert thrill not in result
def test__thrill_rides__filters_thrill_categories(self):
"""Test thrill_rides filters for thrill ride categories."""
coaster = RideFactory(category="RC")
flat_ride = RideFactory(category="FR")
family = RideFactory(category="DR") # Dark ride
result = Ride.objects.thrill_rides()
assert coaster in result
assert flat_ride in result
assert family not in result
def test__family_friendly__filters_by_height_requirement(self):
"""Test family_friendly filters by max height requirement."""
family_ride = RideFactory(min_height_in=36)
thrill_ride = RideFactory(min_height_in=54)
no_restriction = RideFactory(min_height_in=None)
result = Ride.objects.family_friendly(max_height_requirement=42)
assert family_ride in result
assert no_restriction in result
assert thrill_ride not in result
def test__by_park__filters_by_park_id(self):
"""Test by_park filters rides by park."""
park1 = ParkFactory()
park2 = ParkFactory()
ride1 = RideFactory(park=park1)
ride2 = RideFactory(park=park2)
result = Ride.objects.by_park(park_id=park1.pk)
assert ride1 in result
assert ride2 not in result
def test__by_manufacturer__filters_by_manufacturer_id(self):
"""Test by_manufacturer filters by manufacturer."""
mfr1 = ManufacturerCompanyFactory()
mfr2 = ManufacturerCompanyFactory()
ride1 = RideFactory(manufacturer=mfr1)
ride2 = RideFactory(manufacturer=mfr2)
result = Ride.objects.get_queryset().by_manufacturer(manufacturer_id=mfr1.pk)
assert ride1 in result
assert ride2 not in result
def test__by_designer__filters_by_designer_id(self):
"""Test by_designer filters by designer."""
designer1 = DesignerCompanyFactory()
designer2 = DesignerCompanyFactory()
ride1 = RideFactory(designer=designer1)
ride2 = RideFactory(designer=designer2)
result = Ride.objects.get_queryset().by_designer(designer_id=designer1.pk)
assert ride1 in result
assert ride2 not in result
def test__with_capacity_info__annotates_capacity_data(self):
"""Test with_capacity_info adds capacity annotations."""
ride = RideFactory(capacity_per_hour=1500, ride_duration_seconds=180)
result = Ride.objects.get_queryset().with_capacity_info().get(pk=ride.pk)
assert result.estimated_daily_capacity == 15000 # 1500 * 10
assert result.duration_minutes == 3.0 # 180 / 60
def test__high_capacity__filters_by_minimum_capacity(self):
"""Test high_capacity filters by minimum capacity."""
high_cap = RideFactory(capacity_per_hour=2000)
low_cap = RideFactory(capacity_per_hour=500)
result = Ride.objects.high_capacity(min_capacity=1000)
assert high_cap in result
assert low_cap not in result
def test__optimized_for_list__returns_prefetched_data(self):
"""Test optimized_for_list prefetches related data."""
RideFactory()
RideFactory()
queryset = Ride.objects.optimized_for_list()
# Should return results with prefetched data
assert queryset.count() == 2
@pytest.mark.django_db
class TestRideManager(TestCase):
"""Tests for RideManager."""
def test__get_queryset__returns_ride_queryset(self):
"""Test get_queryset returns RideQuerySet."""
queryset = Ride.objects.get_queryset()
assert isinstance(queryset, RideQuerySet)
def test__operating__filters_operating_rides(self):
"""Test operating filters for operating status."""
operating = RideFactory(status="OPERATING")
closed = RideFactory(status="CLOSED_PERM")
result = Ride.objects.operating()
assert operating in result
assert closed not in result
def test__coasters__delegates_to_queryset(self):
"""Test coasters method delegates to queryset."""
coaster = CoasterFactory(category="RC")
thrill = RideFactory(category="TR")
result = Ride.objects.coasters()
assert coaster in result
assert thrill not in result
def test__with_coaster_stats__prefetches_stats(self):
"""Test with_coaster_stats prefetches coaster_stats."""
ride = CoasterFactory()
queryset = Ride.objects.with_coaster_stats()
assert ride in queryset
@pytest.mark.django_db
class TestRideModelQuerySet(TestCase):
"""Tests for RideModelQuerySet."""
def test__by_manufacturer__filters_by_manufacturer_id(self):
"""Test by_manufacturer filters ride models by manufacturer."""
mfr1 = ManufacturerCompanyFactory()
mfr2 = ManufacturerCompanyFactory()
model1 = RideModelFactory(manufacturer=mfr1)
model2 = RideModelFactory(manufacturer=mfr2)
result = RideModel.objects.by_manufacturer(manufacturer_id=mfr1.pk)
assert model1 in result
assert model2 not in result
def test__with_ride_counts__annotates_ride_counts(self):
"""Test with_ride_counts adds ride count annotation."""
model = RideModelFactory()
RideFactory(ride_model=model, status="OPERATING")
RideFactory(ride_model=model, status="OPERATING")
RideFactory(ride_model=model, status="CLOSED_PERM")
result = RideModel.objects.get_queryset().with_ride_counts().get(pk=model.pk)
assert result.ride_count == 3
assert result.operating_rides_count == 2
def test__popular_models__filters_by_minimum_installations(self):
"""Test popular_models filters by minimum ride count."""
popular = RideModelFactory()
unpopular = RideModelFactory()
for _ in range(6):
RideFactory(ride_model=popular)
for _ in range(2):
RideFactory(ride_model=unpopular)
result = RideModel.objects.popular_models(min_installations=5)
assert popular in result
assert unpopular not in result
@pytest.mark.django_db
class TestRideModelManager(TestCase):
"""Tests for RideModelManager."""
def test__get_queryset__returns_ride_model_queryset(self):
"""Test get_queryset returns RideModelQuerySet."""
queryset = RideModel.objects.get_queryset()
assert isinstance(queryset, RideModelQuerySet)
def test__by_manufacturer__delegates_to_queryset(self):
"""Test by_manufacturer delegates to queryset."""
mfr = ManufacturerCompanyFactory()
model = RideModelFactory(manufacturer=mfr)
result = RideModel.objects.by_manufacturer(manufacturer_id=mfr.pk)
assert model in result
@pytest.mark.django_db
class TestRideReviewQuerySet(TestCase):
"""Tests for RideReviewQuerySet."""
def test__for_ride__filters_by_ride_id(self):
"""Test for_ride filters reviews by ride."""
ride1 = RideFactory()
ride2 = RideFactory()
user = UserFactory()
review1 = RideReviewFactory(ride=ride1, user=user)
user2 = UserFactory()
review2 = RideReviewFactory(ride=ride2, user=user2)
result = RideReview.objects.for_ride(ride_id=ride1.pk)
assert review1 in result
assert review2 not in result
def test__by_user__filters_by_user_id(self):
"""Test by_user filters reviews by user."""
user1 = UserFactory()
user2 = UserFactory()
review1 = RideReviewFactory(user=user1)
review2 = RideReviewFactory(user=user2)
result = RideReview.objects.get_queryset().by_user(user_id=user1.pk)
assert review1 in result
assert review2 not in result
def test__by_rating_range__filters_by_rating(self):
"""Test by_rating_range filters by rating range."""
user1 = UserFactory()
user2 = UserFactory()
user3 = UserFactory()
high = RideReviewFactory(rating=9, user=user1)
mid = RideReviewFactory(rating=5, user=user2)
low = RideReviewFactory(rating=2, user=user3)
result = RideReview.objects.by_rating_range(min_rating=7, max_rating=10)
assert high in result
assert mid not in result
assert low not in result
def test__optimized_for_display__selects_related(self):
"""Test optimized_for_display selects related data."""
review = RideReviewFactory()
queryset = RideReview.objects.get_queryset().optimized_for_display()
# Should include the review
assert review in queryset
@pytest.mark.django_db
class TestRideReviewManager(TestCase):
"""Tests for RideReviewManager."""
def test__get_queryset__returns_ride_review_queryset(self):
"""Test get_queryset returns RideReviewQuerySet."""
queryset = RideReview.objects.get_queryset()
assert isinstance(queryset, RideReviewQuerySet)
def test__for_ride__delegates_to_queryset(self):
"""Test for_ride delegates to queryset."""
ride = RideFactory()
user = UserFactory()
review = RideReviewFactory(ride=ride, user=user)
result = RideReview.objects.for_ride(ride_id=ride.pk)
assert review in result
@pytest.mark.django_db
class TestRideQuerySetStatusMethods(TestCase):
"""Tests for status-related RideQuerySet methods."""
def test__operating__filters_operating_rides(self):
"""Test operating filters for OPERATING status."""
operating = RideFactory(status="OPERATING")
sbno = RideFactory(status="SBNO")
closed = RideFactory(status="CLOSED_PERM")
result = Ride.objects.operating()
assert operating in result
assert sbno not in result
assert closed not in result
def test__closed__filters_closed_rides(self):
"""Test closed filters for closed statuses."""
operating = RideFactory(status="OPERATING")
closed_temp = RideFactory(status="CLOSED_TEMP")
closed_perm = RideFactory(status="CLOSED_PERM")
result = Ride.objects.closed()
assert operating not in result
assert closed_temp in result
assert closed_perm in result
@pytest.mark.django_db
class TestRideQuerySetReviewMethods(TestCase):
"""Tests for review-related RideQuerySet methods."""
def test__with_review_stats__annotates_review_data(self):
"""Test with_review_stats adds review statistics."""
ride = RideFactory()
user1 = UserFactory()
user2 = UserFactory()
RideReviewFactory(ride=ride, user=user1, is_published=True, rating=8)
RideReviewFactory(ride=ride, user=user2, is_published=True, rating=6)
result = Ride.objects.get_queryset().with_review_stats().get(pk=ride.pk)
assert result.review_count == 2
assert result.average_rating == 7.0
def test__highly_rated__filters_by_minimum_rating(self):
"""Test highly_rated filters by minimum average rating."""
ride1 = RideFactory()
ride2 = RideFactory()
user1 = UserFactory()
user2 = UserFactory()
# High rated ride
RideReviewFactory(ride=ride1, user=user1, is_published=True, rating=9)
RideReviewFactory(ride=ride1, user=user2, is_published=True, rating=10)
user3 = UserFactory()
user4 = UserFactory()
# Low rated ride
RideReviewFactory(ride=ride2, user=user3, is_published=True, rating=4)
RideReviewFactory(ride=ride2, user=user4, is_published=True, rating=5)
result = Ride.objects.get_queryset().highly_rated(min_rating=8.0)
assert ride1 in result
assert ride2 not in result

View File

@@ -0,0 +1,5 @@
"""
Middleware tests.
This module contains tests for custom middleware classes.
"""

View File

@@ -0,0 +1,368 @@
"""
Tests for ContractValidationMiddleware.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
import json
from unittest.mock import Mock, patch, MagicMock
from django.test import TestCase, RequestFactory, override_settings
from django.http import JsonResponse, HttpResponse
from apps.api.v1.middleware import (
ContractValidationMiddleware,
ContractValidationSettings,
)
class TestContractValidationMiddlewareInit(TestCase):
"""Tests for ContractValidationMiddleware initialization."""
@override_settings(DEBUG=True)
def test__init__debug_true__enables_middleware(self):
"""Test middleware is enabled when DEBUG=True."""
get_response = Mock()
middleware = ContractValidationMiddleware(get_response)
assert middleware.enabled is True
@override_settings(DEBUG=False)
def test__init__debug_false__disables_middleware(self):
"""Test middleware is disabled when DEBUG=False."""
get_response = Mock()
middleware = ContractValidationMiddleware(get_response)
assert middleware.enabled is False
class TestContractValidationMiddlewareProcessResponse(TestCase):
"""Tests for ContractValidationMiddleware.process_response."""
def setUp(self):
self.factory = RequestFactory()
self.get_response = Mock()
self.middleware = ContractValidationMiddleware(self.get_response)
self.middleware.enabled = True
def test__process_response__non_api_path__skips_validation(self):
"""Test process_response skips non-API paths."""
request = self.factory.get("/some/path/")
response = JsonResponse({"data": "value"})
result = self.middleware.process_response(request, response)
assert result == response
def test__process_response__non_json_response__skips_validation(self):
"""Test process_response skips non-JSON responses."""
request = self.factory.get("/api/v1/parks/")
response = HttpResponse("HTML content")
result = self.middleware.process_response(request, response)
assert result == response
def test__process_response__error_status_code__skips_validation(self):
"""Test process_response skips error responses."""
request = self.factory.get("/api/v1/parks/")
response = JsonResponse({"error": "Not found"}, status=404)
result = self.middleware.process_response(request, response)
assert result == response
@override_settings(DEBUG=False)
def test__process_response__middleware_disabled__skips_validation(self):
"""Test process_response skips when middleware is disabled."""
self.middleware.enabled = False
request = self.factory.get("/api/v1/parks/")
response = JsonResponse({"data": "value"})
result = self.middleware.process_response(request, response)
assert result == response
class TestContractValidationMiddlewareFilterValidation(TestCase):
"""Tests for filter metadata validation."""
def setUp(self):
self.factory = RequestFactory()
self.get_response = Mock()
self.middleware = ContractValidationMiddleware(self.get_response)
self.middleware.enabled = True
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_filter_metadata__valid_categorical_filters__no_violation(
self, mock_log
):
"""Test valid categorical filter format doesn't log violation."""
request = self.factory.get("/api/v1/parks/filter-options/")
valid_data = {
"categorical": {
"status": [
{"value": "OPERATING", "label": "Operating", "count": 10},
{"value": "CLOSED", "label": "Closed", "count": 5},
]
}
}
response = JsonResponse(valid_data)
self.middleware.process_response(request, response)
# Should not log CATEGORICAL_OPTION_IS_STRING
for call in mock_log.call_args_list:
assert "CATEGORICAL_OPTION_IS_STRING" not in str(call)
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_filter_metadata__string_options__logs_violation(self, mock_log):
"""Test string filter options logs contract violation."""
request = self.factory.get("/api/v1/parks/filter-options/")
invalid_data = {
"categorical": {
"status": ["OPERATING", "CLOSED"] # Strings instead of objects
}
}
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
# Should log CATEGORICAL_OPTION_IS_STRING violation
mock_log.assert_called()
call_args = [str(call) for call in mock_log.call_args_list]
assert any("CATEGORICAL_OPTION_IS_STRING" in arg for arg in call_args)
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_filter_metadata__missing_value_property__logs_violation(
self, mock_log
):
"""Test filter option missing 'value' property logs violation."""
request = self.factory.get("/api/v1/parks/filter-options/")
invalid_data = {
"categorical": {
"status": [
{"label": "Operating", "count": 10} # Missing 'value'
]
}
}
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
mock_log.assert_called()
call_args = [str(call) for call in mock_log.call_args_list]
assert any("MISSING_VALUE_PROPERTY" in arg for arg in call_args)
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_filter_metadata__missing_label_property__logs_violation(
self, mock_log
):
"""Test filter option missing 'label' property logs violation."""
request = self.factory.get("/api/v1/parks/filter-options/")
invalid_data = {
"categorical": {
"status": [
{"value": "OPERATING", "count": 10} # Missing 'label'
]
}
}
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
mock_log.assert_called()
call_args = [str(call) for call in mock_log.call_args_list]
assert any("MISSING_LABEL_PROPERTY" in arg for arg in call_args)
class TestContractValidationMiddlewareRangeValidation(TestCase):
"""Tests for range filter validation."""
def setUp(self):
self.factory = RequestFactory()
self.get_response = Mock()
self.middleware = ContractValidationMiddleware(self.get_response)
self.middleware.enabled = True
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_range_filter__valid_range__no_violation(self, mock_log):
"""Test valid range filter format doesn't log violation."""
request = self.factory.get("/api/v1/rides/filter-options/")
valid_data = {
"ranges": {
"height": {"min": 0, "max": 500, "step": 10, "unit": "ft"}
}
}
response = JsonResponse(valid_data)
self.middleware.process_response(request, response)
# Should not log RANGE_FILTER_NOT_OBJECT
for call in mock_log.call_args_list:
assert "RANGE_FILTER_NOT_OBJECT" not in str(call)
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_range_filter__missing_min_max__logs_violation(self, mock_log):
"""Test range filter missing min/max logs violation."""
request = self.factory.get("/api/v1/rides/filter-options/")
invalid_data = {
"ranges": {
"height": {"step": 10} # Missing 'min' and 'max'
}
}
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
mock_log.assert_called()
call_args = [str(call) for call in mock_log.call_args_list]
assert any("MISSING_RANGE_PROPERTY" in arg for arg in call_args)
class TestContractValidationMiddlewareHybridValidation(TestCase):
"""Tests for hybrid response validation."""
def setUp(self):
self.factory = RequestFactory()
self.get_response = Mock()
self.middleware = ContractValidationMiddleware(self.get_response)
self.middleware.enabled = True
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_hybrid_response__valid_strategy__no_violation(self, mock_log):
"""Test valid hybrid response strategy doesn't log violation."""
request = self.factory.get("/api/v1/parks/hybrid/")
valid_data = {
"strategy": "client_side",
"data": [],
"filter_metadata": {}
}
response = JsonResponse(valid_data)
self.middleware.process_response(request, response)
# Should not log INVALID_STRATEGY_VALUE
for call in mock_log.call_args_list:
assert "INVALID_STRATEGY_VALUE" not in str(call)
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_hybrid_response__invalid_strategy__logs_violation(
self, mock_log
):
"""Test invalid hybrid strategy logs violation."""
request = self.factory.get("/api/v1/parks/hybrid/")
invalid_data = {
"strategy": "invalid_strategy", # Not 'client_side' or 'server_side'
"data": []
}
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
mock_log.assert_called()
call_args = [str(call) for call in mock_log.call_args_list]
assert any("INVALID_STRATEGY_VALUE" in arg for arg in call_args)
class TestContractValidationMiddlewarePaginationValidation(TestCase):
"""Tests for pagination response validation."""
def setUp(self):
self.factory = RequestFactory()
self.get_response = Mock()
self.middleware = ContractValidationMiddleware(self.get_response)
self.middleware.enabled = True
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_pagination__valid_response__no_violation(self, mock_log):
"""Test valid pagination response doesn't log violation."""
request = self.factory.get("/api/v1/parks/")
valid_data = {
"count": 10,
"next": None,
"previous": None,
"results": [{"id": 1}, {"id": 2}]
}
response = JsonResponse(valid_data)
self.middleware.process_response(request, response)
# Should not log MISSING_PAGINATION_FIELD or RESULTS_NOT_ARRAY
for call in mock_log.call_args_list:
assert "MISSING_PAGINATION_FIELD" not in str(call)
assert "RESULTS_NOT_ARRAY" not in str(call)
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_pagination__results_not_array__logs_violation(self, mock_log):
"""Test pagination with non-array results logs violation."""
request = self.factory.get("/api/v1/parks/")
invalid_data = {
"count": 10,
"results": "not an array" # Should be array
}
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
mock_log.assert_called()
call_args = [str(call) for call in mock_log.call_args_list]
assert any("RESULTS_NOT_ARRAY" in arg for arg in call_args)
class TestContractValidationSettings(TestCase):
"""Tests for ContractValidationSettings."""
def test__should_validate_path__regular_api_path__returns_true(self):
"""Test should_validate_path returns True for regular API paths."""
result = ContractValidationSettings.should_validate_path("/api/v1/parks/")
assert result is True
def test__should_validate_path__docs_path__returns_false(self):
"""Test should_validate_path returns False for docs paths."""
result = ContractValidationSettings.should_validate_path("/api/docs/")
assert result is False
def test__should_validate_path__schema_path__returns_false(self):
"""Test should_validate_path returns False for schema paths."""
result = ContractValidationSettings.should_validate_path("/api/schema/")
assert result is False
def test__should_validate_path__auth_path__returns_false(self):
"""Test should_validate_path returns False for auth paths."""
result = ContractValidationSettings.should_validate_path("/api/v1/auth/login/")
assert result is False
class TestContractValidationMiddlewareViolationSuggestions(TestCase):
"""Tests for violation suggestion messages."""
def setUp(self):
self.get_response = Mock()
self.middleware = ContractValidationMiddleware(self.get_response)
def test__get_violation_suggestion__categorical_string__returns_suggestion(self):
"""Test get_violation_suggestion returns suggestion for CATEGORICAL_OPTION_IS_STRING."""
suggestion = self.middleware._get_violation_suggestion(
"CATEGORICAL_OPTION_IS_STRING"
)
assert "ensure_filter_option_format" in suggestion
assert "object arrays" in suggestion
def test__get_violation_suggestion__missing_value__returns_suggestion(self):
"""Test get_violation_suggestion returns suggestion for MISSING_VALUE_PROPERTY."""
suggestion = self.middleware._get_violation_suggestion("MISSING_VALUE_PROPERTY")
assert "value" in suggestion
assert "FilterOptionSerializer" in suggestion
def test__get_violation_suggestion__unknown_violation__returns_default(self):
"""Test get_violation_suggestion returns default for unknown violation."""
suggestion = self.middleware._get_violation_suggestion("UNKNOWN_VIOLATION_TYPE")
assert "TypeScript interfaces" in suggestion

View File

@@ -0,0 +1,6 @@
"""
Serializer tests.
This module contains tests for DRF serializers to verify
validation, field mapping, and custom logic.
"""

View File

@@ -0,0 +1,514 @@
"""
Tests for Account serializers.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from django.test import TestCase, RequestFactory
from apps.accounts.serializers import (
UserSerializer,
LoginSerializer,
SignupSerializer,
PasswordResetSerializer,
PasswordChangeSerializer,
SocialProviderSerializer,
)
from apps.api.v1.accounts.serializers import (
UserProfileCreateInputSerializer,
UserProfileUpdateInputSerializer,
UserProfileOutputSerializer,
TopListCreateInputSerializer,
TopListUpdateInputSerializer,
TopListOutputSerializer,
TopListItemCreateInputSerializer,
TopListItemUpdateInputSerializer,
TopListItemOutputSerializer,
)
from tests.factories import (
UserFactory,
StaffUserFactory,
)
@pytest.mark.django_db
class TestUserSerializer(TestCase):
"""Tests for UserSerializer."""
def test__serialize__user__returns_expected_fields(self):
"""Test serializing a user returns expected fields."""
user = UserFactory()
serializer = UserSerializer(user)
data = serializer.data
assert "id" in data
assert "username" in data
assert "email" in data
assert "display_name" in data
assert "date_joined" in data
assert "is_active" in data
assert "avatar_url" in data
def test__serialize__user_without_profile__returns_none_avatar(self):
"""Test serializing user without profile returns None for avatar."""
user = UserFactory()
# Ensure no profile
if hasattr(user, "profile"):
user.profile.delete()
serializer = UserSerializer(user)
data = serializer.data
assert data["avatar_url"] is None
def test__get_display_name__user_with_display_name__returns_display_name(self):
"""Test get_display_name returns user's display name."""
user = UserFactory()
user.display_name = "John Doe"
user.save()
serializer = UserSerializer(user)
# get_display_name calls the model method
assert "display_name" in serializer.data
def test__meta__read_only_fields__includes_id_and_dates(self):
"""Test Meta.read_only_fields includes id and date fields."""
assert "id" in UserSerializer.Meta.read_only_fields
assert "date_joined" in UserSerializer.Meta.read_only_fields
assert "is_active" in UserSerializer.Meta.read_only_fields
class TestLoginSerializer(TestCase):
"""Tests for LoginSerializer."""
def test__validate__valid_credentials__returns_data(self):
"""Test validation passes with valid credentials."""
data = {
"username": "testuser",
"password": "testpassword123",
}
serializer = LoginSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["username"] == "testuser"
assert serializer.validated_data["password"] == "testpassword123"
def test__validate__email_as_username__returns_data(self):
"""Test validation passes with email as username."""
data = {
"username": "user@example.com",
"password": "testpassword123",
}
serializer = LoginSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["username"] == "user@example.com"
def test__validate__missing_username__returns_error(self):
"""Test validation fails with missing username."""
data = {"password": "testpassword123"}
serializer = LoginSerializer(data=data)
assert not serializer.is_valid()
assert "username" in serializer.errors
def test__validate__missing_password__returns_error(self):
"""Test validation fails with missing password."""
data = {"username": "testuser"}
serializer = LoginSerializer(data=data)
assert not serializer.is_valid()
assert "password" in serializer.errors
def test__validate__empty_credentials__returns_error(self):
"""Test validation fails with empty credentials."""
data = {"username": "", "password": ""}
serializer = LoginSerializer(data=data)
assert not serializer.is_valid()
@pytest.mark.django_db
class TestSignupSerializer(TestCase):
"""Tests for SignupSerializer."""
def test__validate__valid_data__returns_validated_data(self):
"""Test validation passes with valid signup data."""
data = {
"username": "newuser",
"email": "newuser@example.com",
"display_name": "New User",
"password": "SecurePass123!",
"password_confirm": "SecurePass123!",
}
serializer = SignupSerializer(data=data)
assert serializer.is_valid(), serializer.errors
def test__validate__mismatched_passwords__returns_error(self):
"""Test validation fails with mismatched passwords."""
data = {
"username": "newuser",
"email": "newuser@example.com",
"display_name": "New User",
"password": "SecurePass123!",
"password_confirm": "DifferentPass456!",
}
serializer = SignupSerializer(data=data)
assert not serializer.is_valid()
assert "password_confirm" in serializer.errors
def test__validate_email__duplicate_email__returns_error(self):
"""Test validation fails with duplicate email."""
existing_user = UserFactory(email="existing@example.com")
data = {
"username": "newuser",
"email": "existing@example.com",
"display_name": "New User",
"password": "SecurePass123!",
"password_confirm": "SecurePass123!",
}
serializer = SignupSerializer(data=data)
assert not serializer.is_valid()
assert "email" in serializer.errors
def test__validate_email__case_insensitive__returns_error(self):
"""Test email validation is case insensitive."""
existing_user = UserFactory(email="existing@example.com")
data = {
"username": "newuser",
"email": "EXISTING@EXAMPLE.COM",
"display_name": "New User",
"password": "SecurePass123!",
"password_confirm": "SecurePass123!",
}
serializer = SignupSerializer(data=data)
assert not serializer.is_valid()
assert "email" in serializer.errors
def test__validate_username__duplicate_username__returns_error(self):
"""Test validation fails with duplicate username."""
existing_user = UserFactory(username="existinguser")
data = {
"username": "existinguser",
"email": "new@example.com",
"display_name": "New User",
"password": "SecurePass123!",
"password_confirm": "SecurePass123!",
}
serializer = SignupSerializer(data=data)
assert not serializer.is_valid()
assert "username" in serializer.errors
def test__validate__weak_password__returns_error(self):
"""Test validation fails with weak password."""
data = {
"username": "newuser",
"email": "newuser@example.com",
"display_name": "New User",
"password": "123", # Too weak
"password_confirm": "123",
}
serializer = SignupSerializer(data=data)
assert not serializer.is_valid()
# Password validation error could be in 'password' or 'non_field_errors'
assert "password" in serializer.errors or "non_field_errors" in serializer.errors
def test__create__valid_data__creates_user(self):
"""Test create method creates user correctly."""
data = {
"username": "createuser",
"email": "createuser@example.com",
"display_name": "Create User",
"password": "SecurePass123!",
"password_confirm": "SecurePass123!",
}
serializer = SignupSerializer(data=data)
assert serializer.is_valid(), serializer.errors
user = serializer.save()
assert user.username == "createuser"
assert user.email == "createuser@example.com"
assert user.check_password("SecurePass123!")
def test__meta__password_write_only__excludes_from_output(self):
"""Test password field is write-only."""
assert "password" in SignupSerializer.Meta.fields
assert SignupSerializer.Meta.extra_kwargs.get("password", {}).get("write_only") is True
@pytest.mark.django_db
class TestPasswordResetSerializer(TestCase):
"""Tests for PasswordResetSerializer."""
def test__validate__valid_email__returns_normalized_email(self):
"""Test validation normalizes email."""
user = UserFactory(email="test@example.com")
data = {"email": " TEST@EXAMPLE.COM "}
serializer = PasswordResetSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["email"] == "test@example.com"
def test__validate__nonexistent_email__still_valid(self):
"""Test validation passes with nonexistent email (security)."""
data = {"email": "nonexistent@example.com"}
serializer = PasswordResetSerializer(data=data)
# Should pass validation to prevent email enumeration
assert serializer.is_valid(), serializer.errors
def test__validate__existing_email__attaches_user(self):
"""Test validation attaches user when email exists."""
user = UserFactory(email="exists@example.com")
data = {"email": "exists@example.com"}
serializer = PasswordResetSerializer(data=data)
serializer.is_valid()
assert hasattr(serializer, "user")
assert serializer.user == user
def test__validate__nonexistent_email__no_user_attached(self):
"""Test validation doesn't attach user for nonexistent email."""
data = {"email": "notfound@example.com"}
serializer = PasswordResetSerializer(data=data)
serializer.is_valid()
assert not hasattr(serializer, "user")
@patch("apps.accounts.serializers.EmailService.send_email")
def test__save__existing_user__sends_email(self, mock_send_email):
"""Test save sends email for existing user."""
user = UserFactory(email="reset@example.com")
data = {"email": "reset@example.com"}
factory = RequestFactory()
request = factory.post("/password-reset/")
serializer = PasswordResetSerializer(data=data, context={"request": request})
serializer.is_valid()
serializer.save()
# Email should be sent
mock_send_email.assert_called_once()
@pytest.mark.django_db
class TestPasswordChangeSerializer(TestCase):
"""Tests for PasswordChangeSerializer."""
def test__validate__valid_data__returns_validated_data(self):
"""Test validation passes with valid password change data."""
user = UserFactory()
user.set_password("OldPass123!")
user.save()
factory = RequestFactory()
request = factory.post("/password-change/")
request.user = user
data = {
"old_password": "OldPass123!",
"new_password": "NewSecurePass456!",
"new_password_confirm": "NewSecurePass456!",
}
serializer = PasswordChangeSerializer(data=data, context={"request": request})
assert serializer.is_valid(), serializer.errors
def test__validate_old_password__incorrect__returns_error(self):
"""Test validation fails with incorrect old password."""
user = UserFactory()
user.set_password("CorrectOldPass!")
user.save()
factory = RequestFactory()
request = factory.post("/password-change/")
request.user = user
data = {
"old_password": "WrongOldPass!",
"new_password": "NewSecurePass456!",
"new_password_confirm": "NewSecurePass456!",
}
serializer = PasswordChangeSerializer(data=data, context={"request": request})
assert not serializer.is_valid()
assert "old_password" in serializer.errors
def test__validate__mismatched_new_passwords__returns_error(self):
"""Test validation fails with mismatched new passwords."""
user = UserFactory()
user.set_password("OldPass123!")
user.save()
factory = RequestFactory()
request = factory.post("/password-change/")
request.user = user
data = {
"old_password": "OldPass123!",
"new_password": "NewSecurePass456!",
"new_password_confirm": "DifferentPass789!",
}
serializer = PasswordChangeSerializer(data=data, context={"request": request})
assert not serializer.is_valid()
assert "new_password_confirm" in serializer.errors
def test__save__valid_data__changes_password(self):
"""Test save changes the password."""
user = UserFactory()
user.set_password("OldPass123!")
user.save()
factory = RequestFactory()
request = factory.post("/password-change/")
request.user = user
data = {
"old_password": "OldPass123!",
"new_password": "NewSecurePass456!",
"new_password_confirm": "NewSecurePass456!",
}
serializer = PasswordChangeSerializer(data=data, context={"request": request})
assert serializer.is_valid(), serializer.errors
serializer.save()
user.refresh_from_db()
assert user.check_password("NewSecurePass456!")
class TestSocialProviderSerializer(TestCase):
"""Tests for SocialProviderSerializer."""
def test__validate__valid_provider__returns_data(self):
"""Test validation passes with valid provider data."""
data = {
"id": "google",
"name": "Google",
"login_url": "https://accounts.google.com/oauth/login",
}
serializer = SocialProviderSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["id"] == "google"
assert serializer.validated_data["name"] == "Google"
def test__validate__invalid_url__returns_error(self):
"""Test validation fails with invalid URL."""
data = {
"id": "invalid",
"name": "Invalid Provider",
"login_url": "not-a-valid-url",
}
serializer = SocialProviderSerializer(data=data)
assert not serializer.is_valid()
assert "login_url" in serializer.errors
@pytest.mark.django_db
class TestUserProfileOutputSerializer(TestCase):
"""Tests for UserProfileOutputSerializer."""
def test__serialize__profile__returns_expected_fields(self):
"""Test serializing profile returns expected fields."""
user = UserFactory()
# Create mock profile
mock_profile = Mock()
mock_profile.user = user
mock_profile.avatar = None
serializer = UserProfileOutputSerializer(mock_profile)
# Should include user nested serializer
assert "user" in serializer.data or serializer.data is not None
@pytest.mark.django_db
class TestUserProfileCreateInputSerializer(TestCase):
"""Tests for UserProfileCreateInputSerializer."""
def test__meta__fields__includes_all_fields(self):
"""Test Meta.fields is set to __all__."""
assert UserProfileCreateInputSerializer.Meta.fields == "__all__"
@pytest.mark.django_db
class TestUserProfileUpdateInputSerializer(TestCase):
"""Tests for UserProfileUpdateInputSerializer."""
def test__meta__user_read_only(self):
"""Test user field is read-only for updates."""
extra_kwargs = UserProfileUpdateInputSerializer.Meta.extra_kwargs
assert extra_kwargs.get("user", {}).get("read_only") is True
class TestTopListCreateInputSerializer(TestCase):
"""Tests for TopListCreateInputSerializer."""
def test__meta__fields__includes_all_fields(self):
"""Test Meta.fields is set to __all__."""
assert TopListCreateInputSerializer.Meta.fields == "__all__"
class TestTopListUpdateInputSerializer(TestCase):
"""Tests for TopListUpdateInputSerializer."""
def test__meta__user_read_only(self):
"""Test user field is read-only for updates."""
extra_kwargs = TopListUpdateInputSerializer.Meta.extra_kwargs
assert extra_kwargs.get("user", {}).get("read_only") is True
class TestTopListItemCreateInputSerializer(TestCase):
"""Tests for TopListItemCreateInputSerializer."""
def test__meta__fields__includes_all_fields(self):
"""Test Meta.fields is set to __all__."""
assert TopListItemCreateInputSerializer.Meta.fields == "__all__"
class TestTopListItemUpdateInputSerializer(TestCase):
"""Tests for TopListItemUpdateInputSerializer."""
def test__meta__top_list_not_read_only(self):
"""Test top_list field is not read-only for updates."""
extra_kwargs = TopListItemUpdateInputSerializer.Meta.extra_kwargs
assert extra_kwargs.get("top_list", {}).get("read_only") is False

View File

@@ -0,0 +1,477 @@
"""
Tests for Park serializers.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from unittest.mock import Mock, MagicMock
from django.test import TestCase
from apps.api.v1.parks.serializers import (
ParkPhotoOutputSerializer,
ParkPhotoCreateInputSerializer,
ParkPhotoUpdateInputSerializer,
ParkPhotoListOutputSerializer,
ParkPhotoApprovalInputSerializer,
ParkPhotoStatsOutputSerializer,
ParkPhotoSerializer,
HybridParkSerializer,
ParkSerializer,
)
from tests.factories import (
ParkFactory,
ParkPhotoFactory,
UserFactory,
CloudflareImageFactory,
)
@pytest.mark.django_db
class TestParkPhotoOutputSerializer(TestCase):
"""Tests for ParkPhotoOutputSerializer."""
def test__serialize__valid_photo__returns_all_fields(self):
"""Test serializing a park photo returns all expected fields."""
user = UserFactory()
park = ParkFactory()
image = CloudflareImageFactory()
photo = ParkPhotoFactory(
park=park,
uploaded_by=user,
image=image,
caption="Test caption",
alt_text="Test alt text",
is_primary=True,
is_approved=True,
)
serializer = ParkPhotoOutputSerializer(photo)
data = serializer.data
assert "id" in data
assert data["caption"] == "Test caption"
assert data["alt_text"] == "Test alt text"
assert data["is_primary"] is True
assert data["is_approved"] is True
assert data["uploaded_by_username"] == user.username
assert data["park_slug"] == park.slug
assert data["park_name"] == park.name
def test__serialize__photo_with_image__returns_image_url(self):
"""Test serializing a photo with image returns URL."""
photo = ParkPhotoFactory()
serializer = ParkPhotoOutputSerializer(photo)
data = serializer.data
assert "image_url" in data
assert "image_variants" in data
def test__serialize__photo_without_image__returns_none_for_image_fields(self):
"""Test serializing photo without image returns None for image fields."""
photo = ParkPhotoFactory()
photo.image = None
photo.save()
serializer = ParkPhotoOutputSerializer(photo)
data = serializer.data
assert data["image_url"] is None
assert data["image_variants"] == {}
def test__get_file_size__photo_with_image__returns_file_size(self):
"""Test get_file_size method returns file size."""
photo = ParkPhotoFactory()
serializer = ParkPhotoOutputSerializer(photo)
# file_size comes from the model property
assert "file_size" in serializer.data
def test__get_dimensions__photo_with_image__returns_dimensions(self):
"""Test get_dimensions method returns [width, height]."""
photo = ParkPhotoFactory()
serializer = ParkPhotoOutputSerializer(photo)
assert "dimensions" in serializer.data
def test__get_image_variants__photo_with_image__returns_variant_urls(self):
"""Test get_image_variants returns all variant URLs."""
image = CloudflareImageFactory()
photo = ParkPhotoFactory(image=image)
serializer = ParkPhotoOutputSerializer(photo)
data = serializer.data
if photo.image:
variants = data["image_variants"]
assert "thumbnail" in variants
assert "medium" in variants
assert "large" in variants
assert "public" in variants
@pytest.mark.django_db
class TestParkPhotoCreateInputSerializer(TestCase):
"""Tests for ParkPhotoCreateInputSerializer."""
def test__serialize__valid_data__returns_expected_fields(self):
"""Test serializing valid create data."""
image = CloudflareImageFactory()
data = {
"image": image.pk,
"caption": "New photo caption",
"alt_text": "Description of the image",
"is_primary": False,
}
serializer = ParkPhotoCreateInputSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert "caption" in serializer.validated_data
assert "alt_text" in serializer.validated_data
assert "is_primary" in serializer.validated_data
def test__validate__missing_required_fields__returns_error(self):
"""Test validation fails with missing required fields."""
data = {}
serializer = ParkPhotoCreateInputSerializer(data=data)
# image is required since it's not in read_only_fields
assert not serializer.is_valid()
def test__meta__fields__includes_expected_fields(self):
"""Test Meta.fields includes the expected input fields."""
expected_fields = ["image", "caption", "alt_text", "is_primary"]
assert list(ParkPhotoCreateInputSerializer.Meta.fields) == expected_fields
@pytest.mark.django_db
class TestParkPhotoUpdateInputSerializer(TestCase):
"""Tests for ParkPhotoUpdateInputSerializer."""
def test__serialize__valid_data__returns_expected_fields(self):
"""Test serializing valid update data."""
data = {
"caption": "Updated caption",
"alt_text": "Updated alt text",
"is_primary": True,
}
serializer = ParkPhotoUpdateInputSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["caption"] == "Updated caption"
assert serializer.validated_data["alt_text"] == "Updated alt text"
assert serializer.validated_data["is_primary"] is True
def test__serialize__partial_update__validates_partial_data(self):
"""Test partial update with only some fields."""
photo = ParkPhotoFactory()
data = {"caption": "Only caption updated"}
serializer = ParkPhotoUpdateInputSerializer(photo, data=data, partial=True)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["caption"] == "Only caption updated"
def test__meta__fields__excludes_image_field(self):
"""Test Meta.fields excludes image field for updates."""
expected_fields = ["caption", "alt_text", "is_primary"]
assert list(ParkPhotoUpdateInputSerializer.Meta.fields) == expected_fields
@pytest.mark.django_db
class TestParkPhotoListOutputSerializer(TestCase):
"""Tests for ParkPhotoListOutputSerializer."""
def test__serialize__photo__returns_list_fields_only(self):
"""Test serializing returns only list-appropriate fields."""
user = UserFactory()
photo = ParkPhotoFactory(uploaded_by=user)
serializer = ParkPhotoListOutputSerializer(photo)
data = serializer.data
assert "id" in data
assert "image" in data
assert "caption" in data
assert "is_primary" in data
assert "is_approved" in data
assert "created_at" in data
assert "uploaded_by_username" in data
# Should NOT include detailed fields
assert "image_variants" not in data
assert "file_size" not in data
assert "dimensions" not in data
def test__serialize__multiple_photos__returns_list(self):
"""Test serializing multiple photos returns a list."""
photos = [ParkPhotoFactory() for _ in range(3)]
serializer = ParkPhotoListOutputSerializer(photos, many=True)
assert len(serializer.data) == 3
def test__meta__all_fields_read_only(self):
"""Test all fields are read-only for list serializer."""
assert (
ParkPhotoListOutputSerializer.Meta.read_only_fields
== ParkPhotoListOutputSerializer.Meta.fields
)
class TestParkPhotoApprovalInputSerializer(TestCase):
"""Tests for ParkPhotoApprovalInputSerializer."""
def test__validate__valid_photo_ids__returns_validated_data(self):
"""Test validation with valid photo IDs."""
data = {
"photo_ids": [1, 2, 3],
"approve": True,
}
serializer = ParkPhotoApprovalInputSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["photo_ids"] == [1, 2, 3]
assert serializer.validated_data["approve"] is True
def test__validate__approve_default__defaults_to_true(self):
"""Test approve field defaults to True."""
data = {"photo_ids": [1, 2]}
serializer = ParkPhotoApprovalInputSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["approve"] is True
def test__validate__empty_photo_ids__is_valid(self):
"""Test empty photo_ids list is valid."""
data = {"photo_ids": [], "approve": False}
serializer = ParkPhotoApprovalInputSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["photo_ids"] == []
def test__validate__missing_photo_ids__returns_error(self):
"""Test validation fails without photo_ids."""
data = {"approve": True}
serializer = ParkPhotoApprovalInputSerializer(data=data)
assert not serializer.is_valid()
assert "photo_ids" in serializer.errors
def test__validate__invalid_photo_ids__returns_error(self):
"""Test validation fails with non-integer photo IDs."""
data = {"photo_ids": ["invalid", "ids"]}
serializer = ParkPhotoApprovalInputSerializer(data=data)
assert not serializer.is_valid()
class TestParkPhotoStatsOutputSerializer(TestCase):
"""Tests for ParkPhotoStatsOutputSerializer."""
def test__serialize__stats_dict__returns_all_fields(self):
"""Test serializing stats dictionary."""
stats = {
"total_photos": 100,
"approved_photos": 80,
"pending_photos": 20,
"has_primary": True,
"recent_uploads": 5,
}
serializer = ParkPhotoStatsOutputSerializer(data=stats)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["total_photos"] == 100
assert serializer.validated_data["approved_photos"] == 80
assert serializer.validated_data["pending_photos"] == 20
assert serializer.validated_data["has_primary"] is True
assert serializer.validated_data["recent_uploads"] == 5
def test__validate__missing_fields__returns_error(self):
"""Test validation fails with missing stats fields."""
stats = {"total_photos": 100} # Missing other required fields
serializer = ParkPhotoStatsOutputSerializer(data=stats)
assert not serializer.is_valid()
@pytest.mark.django_db
class TestHybridParkSerializer(TestCase):
"""Tests for HybridParkSerializer."""
def test__serialize__park_with_all_fields__returns_complete_data(self):
"""Test serializing park with all fields populated."""
park = ParkFactory()
serializer = HybridParkSerializer(park)
data = serializer.data
assert "id" in data
assert "name" in data
assert "slug" in data
assert "status" in data
assert "operator_name" in data
def test__serialize__park_without_location__returns_null_location_fields(self):
"""Test serializing park without location returns null for location fields."""
park = ParkFactory()
# Remove location if it exists
if hasattr(park, 'location') and park.location:
park.location.delete()
serializer = HybridParkSerializer(park)
data = serializer.data
# Location fields should be None when no location
assert "city" in data
assert "state" in data
assert "country" in data
def test__get_city__park_with_location__returns_city(self):
"""Test get_city returns city from location."""
park = ParkFactory()
# Create a mock location
mock_location = Mock()
mock_location.city = "Orlando"
mock_location.state = "FL"
mock_location.country = "USA"
mock_location.continent = "North America"
mock_location.coordinates = [-81.3792, 28.5383] # [lon, lat]
park.location = mock_location
serializer = HybridParkSerializer(park)
assert serializer.get_city(park) == "Orlando"
def test__get_latitude__park_with_coordinates__returns_latitude(self):
"""Test get_latitude returns correct value from coordinates."""
park = ParkFactory()
mock_location = Mock()
mock_location.coordinates = [-81.3792, 28.5383] # [lon, lat]
park.location = mock_location
serializer = HybridParkSerializer(park)
# Latitude is index 1 in PostGIS [lon, lat] format
assert serializer.get_latitude(park) == 28.5383
def test__get_longitude__park_with_coordinates__returns_longitude(self):
"""Test get_longitude returns correct value from coordinates."""
park = ParkFactory()
mock_location = Mock()
mock_location.coordinates = [-81.3792, 28.5383] # [lon, lat]
park.location = mock_location
serializer = HybridParkSerializer(park)
# Longitude is index 0 in PostGIS [lon, lat] format
assert serializer.get_longitude(park) == -81.3792
def test__get_banner_image_url__park_with_banner__returns_url(self):
"""Test get_banner_image_url returns URL when banner exists."""
park = ParkFactory()
mock_image = Mock()
mock_image.url = "https://example.com/banner.jpg"
mock_banner = Mock()
mock_banner.image = mock_image
park.banner_image = mock_banner
serializer = HybridParkSerializer(park)
assert serializer.get_banner_image_url(park) == "https://example.com/banner.jpg"
def test__get_banner_image_url__park_without_banner__returns_none(self):
"""Test get_banner_image_url returns None when no banner."""
park = ParkFactory()
park.banner_image = None
serializer = HybridParkSerializer(park)
assert serializer.get_banner_image_url(park) is None
def test__meta__all_fields_read_only(self):
"""Test all fields in HybridParkSerializer are read-only."""
assert (
HybridParkSerializer.Meta.read_only_fields
== HybridParkSerializer.Meta.fields
)
@pytest.mark.django_db
class TestParkSerializer(TestCase):
"""Tests for ParkSerializer (legacy)."""
def test__serialize__park__returns_basic_fields(self):
"""Test serializing park returns basic fields."""
park = ParkFactory()
serializer = ParkSerializer(park)
data = serializer.data
assert "id" in data
assert "name" in data
assert "slug" in data
assert "status" in data
assert "website" in data
def test__serialize__multiple_parks__returns_list(self):
"""Test serializing multiple parks returns a list."""
parks = [ParkFactory() for _ in range(3)]
serializer = ParkSerializer(parks, many=True)
assert len(serializer.data) == 3
@pytest.mark.django_db
class TestParkPhotoSerializer(TestCase):
"""Tests for legacy ParkPhotoSerializer."""
def test__serialize__photo__returns_legacy_fields(self):
"""Test serializing photo returns legacy field set."""
photo = ParkPhotoFactory()
serializer = ParkPhotoSerializer(photo)
data = serializer.data
assert "id" in data
assert "image" in data
assert "caption" in data
assert "alt_text" in data
assert "is_primary" in data
def test__meta__fields__matches_legacy_format(self):
"""Test Meta.fields matches legacy format."""
expected_fields = (
"id",
"image",
"caption",
"alt_text",
"is_primary",
"uploaded_at",
"uploaded_by",
)
assert ParkPhotoSerializer.Meta.fields == expected_fields

View File

@@ -0,0 +1,573 @@
"""
Tests for Ride serializers.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from unittest.mock import Mock, MagicMock
from django.test import TestCase
from apps.api.v1.rides.serializers import (
RidePhotoOutputSerializer,
RidePhotoCreateInputSerializer,
RidePhotoUpdateInputSerializer,
RidePhotoListOutputSerializer,
RidePhotoApprovalInputSerializer,
RidePhotoStatsOutputSerializer,
RidePhotoTypeFilterSerializer,
RidePhotoSerializer,
HybridRideSerializer,
RideSerializer,
)
from tests.factories import (
RideFactory,
RidePhotoFactory,
ParkFactory,
UserFactory,
CloudflareImageFactory,
ManufacturerCompanyFactory,
DesignerCompanyFactory,
)
@pytest.mark.django_db
class TestRidePhotoOutputSerializer(TestCase):
"""Tests for RidePhotoOutputSerializer."""
def test__serialize__valid_photo__returns_all_fields(self):
"""Test serializing a ride photo returns all expected fields."""
user = UserFactory()
ride = RideFactory()
image = CloudflareImageFactory()
photo = RidePhotoFactory(
ride=ride,
uploaded_by=user,
image=image,
caption="Test caption",
alt_text="Test alt text",
is_primary=True,
is_approved=True,
)
serializer = RidePhotoOutputSerializer(photo)
data = serializer.data
assert "id" in data
assert data["caption"] == "Test caption"
assert data["alt_text"] == "Test alt text"
assert data["is_primary"] is True
assert data["is_approved"] is True
assert data["uploaded_by_username"] == user.username
assert data["ride_slug"] == ride.slug
assert data["ride_name"] == ride.name
assert data["park_slug"] == ride.park.slug
assert data["park_name"] == ride.park.name
def test__serialize__photo_with_image__returns_image_url(self):
"""Test serializing a photo with image returns URL."""
photo = RidePhotoFactory()
serializer = RidePhotoOutputSerializer(photo)
data = serializer.data
assert "image_url" in data
assert "image_variants" in data
def test__serialize__photo_without_image__returns_none_for_image_fields(self):
"""Test serializing photo without image returns None for image fields."""
photo = RidePhotoFactory()
photo.image = None
photo.save()
serializer = RidePhotoOutputSerializer(photo)
data = serializer.data
assert data["image_url"] is None
assert data["image_variants"] == {}
def test__get_image_variants__photo_with_image__returns_variant_urls(self):
"""Test get_image_variants returns all variant URLs."""
image = CloudflareImageFactory()
photo = RidePhotoFactory(image=image)
serializer = RidePhotoOutputSerializer(photo)
data = serializer.data
if photo.image:
variants = data["image_variants"]
assert "thumbnail" in variants
assert "medium" in variants
assert "large" in variants
assert "public" in variants
def test__serialize__includes_photo_type(self):
"""Test serializing includes photo_type field."""
photo = RidePhotoFactory()
serializer = RidePhotoOutputSerializer(photo)
data = serializer.data
assert "photo_type" in data
@pytest.mark.django_db
class TestRidePhotoCreateInputSerializer(TestCase):
"""Tests for RidePhotoCreateInputSerializer."""
def test__serialize__valid_data__returns_expected_fields(self):
"""Test serializing valid create data."""
image = CloudflareImageFactory()
data = {
"image": image.pk,
"caption": "New photo caption",
"alt_text": "Description of the image",
"photo_type": "exterior",
"is_primary": False,
}
serializer = RidePhotoCreateInputSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert "caption" in serializer.validated_data
assert "alt_text" in serializer.validated_data
assert "photo_type" in serializer.validated_data
assert "is_primary" in serializer.validated_data
def test__validate__missing_required_fields__returns_error(self):
"""Test validation fails with missing required fields."""
data = {}
serializer = RidePhotoCreateInputSerializer(data=data)
assert not serializer.is_valid()
def test__meta__fields__includes_photo_type(self):
"""Test Meta.fields includes photo_type for ride photos."""
expected_fields = ["image", "caption", "alt_text", "photo_type", "is_primary"]
assert list(RidePhotoCreateInputSerializer.Meta.fields) == expected_fields
@pytest.mark.django_db
class TestRidePhotoUpdateInputSerializer(TestCase):
"""Tests for RidePhotoUpdateInputSerializer."""
def test__serialize__valid_data__returns_expected_fields(self):
"""Test serializing valid update data."""
data = {
"caption": "Updated caption",
"alt_text": "Updated alt text",
"photo_type": "queue",
"is_primary": True,
}
serializer = RidePhotoUpdateInputSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["caption"] == "Updated caption"
assert serializer.validated_data["photo_type"] == "queue"
def test__serialize__partial_update__validates_partial_data(self):
"""Test partial update with only some fields."""
photo = RidePhotoFactory()
data = {"caption": "Only caption updated"}
serializer = RidePhotoUpdateInputSerializer(photo, data=data, partial=True)
assert serializer.is_valid(), serializer.errors
def test__meta__fields__includes_photo_type(self):
"""Test Meta.fields includes photo_type for updates."""
expected_fields = ["caption", "alt_text", "photo_type", "is_primary"]
assert list(RidePhotoUpdateInputSerializer.Meta.fields) == expected_fields
@pytest.mark.django_db
class TestRidePhotoListOutputSerializer(TestCase):
"""Tests for RidePhotoListOutputSerializer."""
def test__serialize__photo__returns_list_fields_only(self):
"""Test serializing returns only list-appropriate fields."""
user = UserFactory()
photo = RidePhotoFactory(uploaded_by=user)
serializer = RidePhotoListOutputSerializer(photo)
data = serializer.data
assert "id" in data
assert "image" in data
assert "caption" in data
assert "photo_type" in data
assert "is_primary" in data
assert "is_approved" in data
assert "created_at" in data
assert "uploaded_by_username" in data
# Should NOT include detailed fields
assert "image_variants" not in data
assert "file_size" not in data
assert "dimensions" not in data
def test__serialize__multiple_photos__returns_list(self):
"""Test serializing multiple photos returns a list."""
photos = [RidePhotoFactory() for _ in range(3)]
serializer = RidePhotoListOutputSerializer(photos, many=True)
assert len(serializer.data) == 3
def test__meta__all_fields_read_only(self):
"""Test all fields are read-only for list serializer."""
assert (
RidePhotoListOutputSerializer.Meta.read_only_fields
== RidePhotoListOutputSerializer.Meta.fields
)
class TestRidePhotoApprovalInputSerializer(TestCase):
"""Tests for RidePhotoApprovalInputSerializer."""
def test__validate__valid_photo_ids__returns_validated_data(self):
"""Test validation with valid photo IDs."""
data = {
"photo_ids": [1, 2, 3],
"approve": True,
}
serializer = RidePhotoApprovalInputSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["photo_ids"] == [1, 2, 3]
assert serializer.validated_data["approve"] is True
def test__validate__approve_default__defaults_to_true(self):
"""Test approve field defaults to True."""
data = {"photo_ids": [1, 2]}
serializer = RidePhotoApprovalInputSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["approve"] is True
def test__validate__empty_photo_ids__is_valid(self):
"""Test empty photo_ids list is valid."""
data = {"photo_ids": [], "approve": False}
serializer = RidePhotoApprovalInputSerializer(data=data)
assert serializer.is_valid(), serializer.errors
def test__validate__missing_photo_ids__returns_error(self):
"""Test validation fails without photo_ids."""
data = {"approve": True}
serializer = RidePhotoApprovalInputSerializer(data=data)
assert not serializer.is_valid()
assert "photo_ids" in serializer.errors
class TestRidePhotoStatsOutputSerializer(TestCase):
"""Tests for RidePhotoStatsOutputSerializer."""
def test__serialize__stats_dict__returns_all_fields(self):
"""Test serializing stats dictionary."""
stats = {
"total_photos": 50,
"approved_photos": 40,
"pending_photos": 10,
"has_primary": True,
"recent_uploads": 3,
"by_type": {"exterior": 20, "queue": 10, "onride": 10, "other": 10},
}
serializer = RidePhotoStatsOutputSerializer(data=stats)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["total_photos"] == 50
assert serializer.validated_data["by_type"]["exterior"] == 20
def test__validate__includes_by_type_field(self):
"""Test stats include by_type breakdown."""
stats = {
"total_photos": 10,
"approved_photos": 8,
"pending_photos": 2,
"has_primary": False,
"recent_uploads": 1,
"by_type": {"exterior": 10},
}
serializer = RidePhotoStatsOutputSerializer(data=stats)
assert serializer.is_valid(), serializer.errors
assert "by_type" in serializer.validated_data
class TestRidePhotoTypeFilterSerializer(TestCase):
"""Tests for RidePhotoTypeFilterSerializer."""
def test__validate__valid_photo_type__returns_validated_data(self):
"""Test validation with valid photo type."""
data = {"photo_type": "exterior"}
serializer = RidePhotoTypeFilterSerializer(data=data)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["photo_type"] == "exterior"
def test__validate__all_photo_types__are_valid(self):
"""Test all defined photo types are valid."""
valid_types = ["exterior", "queue", "station", "onride", "construction", "other"]
for photo_type in valid_types:
serializer = RidePhotoTypeFilterSerializer(data={"photo_type": photo_type})
assert serializer.is_valid(), f"Photo type {photo_type} should be valid"
def test__validate__invalid_photo_type__returns_error(self):
"""Test invalid photo type returns error."""
data = {"photo_type": "invalid_type"}
serializer = RidePhotoTypeFilterSerializer(data=data)
assert not serializer.is_valid()
assert "photo_type" in serializer.errors
def test__validate__empty_photo_type__is_valid(self):
"""Test empty/missing photo_type is valid (optional field)."""
data = {}
serializer = RidePhotoTypeFilterSerializer(data=data)
assert serializer.is_valid(), serializer.errors
@pytest.mark.django_db
class TestHybridRideSerializer(TestCase):
"""Tests for HybridRideSerializer."""
def test__serialize__ride_with_all_fields__returns_complete_data(self):
"""Test serializing ride with all fields populated."""
ride = RideFactory()
serializer = HybridRideSerializer(ride)
data = serializer.data
assert "id" in data
assert "name" in data
assert "slug" in data
assert "category" in data
assert "status" in data
assert "park_name" in data
assert "park_slug" in data
assert "manufacturer_name" in data
def test__serialize__ride_with_manufacturer__returns_manufacturer_fields(self):
"""Test serializing includes manufacturer information."""
manufacturer = ManufacturerCompanyFactory(name="Test Manufacturer")
ride = RideFactory(manufacturer=manufacturer)
serializer = HybridRideSerializer(ride)
data = serializer.data
assert data["manufacturer_name"] == "Test Manufacturer"
assert "manufacturer_slug" in data
def test__serialize__ride_with_designer__returns_designer_fields(self):
"""Test serializing includes designer information."""
designer = DesignerCompanyFactory(name="Test Designer")
ride = RideFactory(designer=designer)
serializer = HybridRideSerializer(ride)
data = serializer.data
assert data["designer_name"] == "Test Designer"
assert "designer_slug" in data
def test__get_park_city__ride_with_park_location__returns_city(self):
"""Test get_park_city returns city from park location."""
ride = RideFactory()
mock_location = Mock()
mock_location.city = "Orlando"
mock_location.state = "FL"
mock_location.country = "USA"
ride.park.location = mock_location
serializer = HybridRideSerializer(ride)
assert serializer.get_park_city(ride) == "Orlando"
def test__get_park_city__ride_without_park_location__returns_none(self):
"""Test get_park_city returns None when no location."""
ride = RideFactory()
ride.park.location = None
serializer = HybridRideSerializer(ride)
assert serializer.get_park_city(ride) is None
def test__get_coaster_height_ft__ride_with_stats__returns_height(self):
"""Test get_coaster_height_ft returns height from coaster stats."""
ride = RideFactory()
mock_stats = Mock()
mock_stats.height_ft = 205.5
mock_stats.length_ft = 5000
mock_stats.speed_mph = 70
mock_stats.inversions = 4
ride.coaster_stats = mock_stats
serializer = HybridRideSerializer(ride)
assert serializer.get_coaster_height_ft(ride) == 205.5
def test__get_coaster_inversions__ride_with_stats__returns_inversions(self):
"""Test get_coaster_inversions returns inversions count."""
ride = RideFactory()
mock_stats = Mock()
mock_stats.inversions = 7
ride.coaster_stats = mock_stats
serializer = HybridRideSerializer(ride)
assert serializer.get_coaster_inversions(ride) == 7
def test__get_coaster_height_ft__ride_without_stats__returns_none(self):
"""Test coaster stat methods return None when no stats."""
ride = RideFactory()
ride.coaster_stats = None
serializer = HybridRideSerializer(ride)
assert serializer.get_coaster_height_ft(ride) is None
assert serializer.get_coaster_length_ft(ride) is None
assert serializer.get_coaster_speed_mph(ride) is None
assert serializer.get_coaster_inversions(ride) is None
def test__get_banner_image_url__ride_with_banner__returns_url(self):
"""Test get_banner_image_url returns URL when banner exists."""
ride = RideFactory()
mock_image = Mock()
mock_image.url = "https://example.com/ride-banner.jpg"
mock_banner = Mock()
mock_banner.image = mock_image
ride.banner_image = mock_banner
serializer = HybridRideSerializer(ride)
assert serializer.get_banner_image_url(ride) == "https://example.com/ride-banner.jpg"
def test__get_banner_image_url__ride_without_banner__returns_none(self):
"""Test get_banner_image_url returns None when no banner."""
ride = RideFactory()
ride.banner_image = None
serializer = HybridRideSerializer(ride)
assert serializer.get_banner_image_url(ride) is None
def test__meta__all_fields_read_only(self):
"""Test all fields in HybridRideSerializer are read-only."""
assert (
HybridRideSerializer.Meta.read_only_fields
== HybridRideSerializer.Meta.fields
)
def test__serialize__includes_ride_model_fields(self):
"""Test serializing includes ride model information."""
ride = RideFactory()
serializer = HybridRideSerializer(ride)
data = serializer.data
assert "ride_model_name" in data
assert "ride_model_slug" in data
assert "ride_model_category" in data
@pytest.mark.django_db
class TestRideSerializer(TestCase):
"""Tests for RideSerializer (legacy)."""
def test__serialize__ride__returns_basic_fields(self):
"""Test serializing ride returns basic fields."""
ride = RideFactory()
serializer = RideSerializer(ride)
data = serializer.data
assert "id" in data
assert "name" in data
assert "slug" in data
assert "category" in data
assert "status" in data
assert "opening_date" in data
def test__serialize__multiple_rides__returns_list(self):
"""Test serializing multiple rides returns a list."""
rides = [RideFactory() for _ in range(3)]
serializer = RideSerializer(rides, many=True)
assert len(serializer.data) == 3
def test__meta__fields__matches_expected(self):
"""Test Meta.fields matches expected field list."""
expected_fields = [
"id",
"name",
"slug",
"park",
"manufacturer",
"designer",
"category",
"status",
"opening_date",
"closing_date",
]
assert list(RideSerializer.Meta.fields) == expected_fields
@pytest.mark.django_db
class TestRidePhotoSerializer(TestCase):
"""Tests for legacy RidePhotoSerializer."""
def test__serialize__photo__returns_legacy_fields(self):
"""Test serializing photo returns legacy field set."""
photo = RidePhotoFactory()
serializer = RidePhotoSerializer(photo)
data = serializer.data
assert "id" in data
assert "image" in data
assert "caption" in data
assert "alt_text" in data
assert "is_primary" in data
assert "photo_type" in data
def test__meta__fields__matches_legacy_format(self):
"""Test Meta.fields matches legacy format."""
expected_fields = [
"id",
"image",
"caption",
"alt_text",
"is_primary",
"photo_type",
"uploaded_at",
"uploaded_by",
]
assert list(RidePhotoSerializer.Meta.fields) == expected_fields

View File

@@ -0,0 +1,6 @@
"""
Service layer tests.
This module contains tests for service classes that encapsulate
business logic following Django styleguide patterns.
"""

View File

@@ -0,0 +1,290 @@
"""
Tests for ParkMediaService.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from django.test import TestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from apps.parks.services.media_service import ParkMediaService
from apps.parks.models import ParkPhoto
from tests.factories import (
ParkFactory,
ParkPhotoFactory,
UserFactory,
StaffUserFactory,
CloudflareImageFactory,
)
@pytest.mark.django_db
class TestParkMediaServiceUploadPhoto(TestCase):
"""Tests for ParkMediaService.upload_photo."""
@patch("apps.parks.services.media_service.MediaService.validate_image_file")
@patch("apps.parks.services.media_service.MediaService.process_image")
@patch("apps.parks.services.media_service.MediaService.generate_default_caption")
@patch("apps.parks.services.media_service.MediaService.extract_exif_date")
def test__upload_photo__valid_image__creates_photo(
self,
mock_exif,
mock_caption,
mock_process,
mock_validate,
):
"""Test upload_photo creates photo with valid image."""
mock_validate.return_value = (True, None)
mock_process.return_value = Mock()
mock_caption.return_value = "Photo by testuser"
mock_exif.return_value = None
park = ParkFactory()
user = UserFactory()
image_file = SimpleUploadedFile(
"test.jpg", b"fake image content", content_type="image/jpeg"
)
photo = ParkMediaService.upload_photo(
park=park,
image_file=image_file,
user=user,
caption="Test caption",
alt_text="Test alt",
is_primary=False,
auto_approve=True,
)
assert photo.park == park
assert photo.caption == "Test caption"
assert photo.alt_text == "Test alt"
assert photo.uploaded_by == user
assert photo.is_approved is True
@patch("apps.parks.services.media_service.MediaService.validate_image_file")
def test__upload_photo__invalid_image__raises_value_error(self, mock_validate):
"""Test upload_photo raises ValueError for invalid image."""
mock_validate.return_value = (False, "Invalid file type")
park = ParkFactory()
user = UserFactory()
image_file = SimpleUploadedFile(
"test.txt", b"not an image", content_type="text/plain"
)
with pytest.raises(ValueError) as exc_info:
ParkMediaService.upload_photo(
park=park,
image_file=image_file,
user=user,
)
assert "Invalid file type" in str(exc_info.value)
@pytest.mark.django_db
class TestParkMediaServiceGetParkPhotos(TestCase):
"""Tests for ParkMediaService.get_park_photos."""
def test__get_park_photos__approved_only_true__filters_approved(self):
"""Test get_park_photos with approved_only filters unapproved photos."""
park = ParkFactory()
approved = ParkPhotoFactory(park=park, is_approved=True)
unapproved = ParkPhotoFactory(park=park, is_approved=False)
result = ParkMediaService.get_park_photos(park, approved_only=True)
assert approved in result
assert unapproved not in result
def test__get_park_photos__approved_only_false__returns_all(self):
"""Test get_park_photos with approved_only=False returns all photos."""
park = ParkFactory()
approved = ParkPhotoFactory(park=park, is_approved=True)
unapproved = ParkPhotoFactory(park=park, is_approved=False)
result = ParkMediaService.get_park_photos(park, approved_only=False)
assert approved in result
assert unapproved in result
def test__get_park_photos__primary_first__orders_primary_first(self):
"""Test get_park_photos with primary_first orders primary photos first."""
park = ParkFactory()
non_primary = ParkPhotoFactory(park=park, is_primary=False, is_approved=True)
primary = ParkPhotoFactory(park=park, is_primary=True, is_approved=True)
result = ParkMediaService.get_park_photos(park, primary_first=True)
# Primary should be first
assert result[0] == primary
@pytest.mark.django_db
class TestParkMediaServiceGetPrimaryPhoto(TestCase):
"""Tests for ParkMediaService.get_primary_photo."""
def test__get_primary_photo__has_primary__returns_primary(self):
"""Test get_primary_photo returns primary photo when exists."""
park = ParkFactory()
primary = ParkPhotoFactory(park=park, is_primary=True, is_approved=True)
ParkPhotoFactory(park=park, is_primary=False, is_approved=True)
result = ParkMediaService.get_primary_photo(park)
assert result == primary
def test__get_primary_photo__no_primary__returns_none(self):
"""Test get_primary_photo returns None when no primary exists."""
park = ParkFactory()
ParkPhotoFactory(park=park, is_primary=False, is_approved=True)
result = ParkMediaService.get_primary_photo(park)
assert result is None
def test__get_primary_photo__unapproved_primary__returns_none(self):
"""Test get_primary_photo ignores unapproved primary photos."""
park = ParkFactory()
ParkPhotoFactory(park=park, is_primary=True, is_approved=False)
result = ParkMediaService.get_primary_photo(park)
assert result is None
@pytest.mark.django_db
class TestParkMediaServiceSetPrimaryPhoto(TestCase):
"""Tests for ParkMediaService.set_primary_photo."""
def test__set_primary_photo__valid_photo__sets_as_primary(self):
"""Test set_primary_photo sets photo as primary."""
park = ParkFactory()
photo = ParkPhotoFactory(park=park, is_primary=False)
result = ParkMediaService.set_primary_photo(park, photo)
photo.refresh_from_db()
assert result is True
assert photo.is_primary is True
def test__set_primary_photo__unsets_existing_primary(self):
"""Test set_primary_photo unsets existing primary photo."""
park = ParkFactory()
old_primary = ParkPhotoFactory(park=park, is_primary=True)
new_primary = ParkPhotoFactory(park=park, is_primary=False)
ParkMediaService.set_primary_photo(park, new_primary)
old_primary.refresh_from_db()
new_primary.refresh_from_db()
assert old_primary.is_primary is False
assert new_primary.is_primary is True
def test__set_primary_photo__wrong_park__returns_false(self):
"""Test set_primary_photo returns False for photo from different park."""
park1 = ParkFactory()
park2 = ParkFactory()
photo = ParkPhotoFactory(park=park2)
result = ParkMediaService.set_primary_photo(park1, photo)
assert result is False
@pytest.mark.django_db
class TestParkMediaServiceApprovePhoto(TestCase):
"""Tests for ParkMediaService.approve_photo."""
def test__approve_photo__unapproved_photo__approves_it(self):
"""Test approve_photo approves an unapproved photo."""
photo = ParkPhotoFactory(is_approved=False)
staff_user = StaffUserFactory()
result = ParkMediaService.approve_photo(photo, staff_user)
photo.refresh_from_db()
assert result is True
assert photo.is_approved is True
@pytest.mark.django_db
class TestParkMediaServiceDeletePhoto(TestCase):
"""Tests for ParkMediaService.delete_photo."""
def test__delete_photo__valid_photo__deletes_it(self):
"""Test delete_photo deletes a photo."""
photo = ParkPhotoFactory()
photo_id = photo.pk
staff_user = StaffUserFactory()
result = ParkMediaService.delete_photo(photo, staff_user)
assert result is True
assert not ParkPhoto.objects.filter(pk=photo_id).exists()
@pytest.mark.django_db
class TestParkMediaServiceGetPhotoStats(TestCase):
"""Tests for ParkMediaService.get_photo_stats."""
def test__get_photo_stats__returns_correct_counts(self):
"""Test get_photo_stats returns correct statistics."""
park = ParkFactory()
ParkPhotoFactory(park=park, is_approved=True)
ParkPhotoFactory(park=park, is_approved=True)
ParkPhotoFactory(park=park, is_approved=False)
ParkPhotoFactory(park=park, is_approved=True, is_primary=True)
stats = ParkMediaService.get_photo_stats(park)
assert stats["total_photos"] == 4
assert stats["approved_photos"] == 3
assert stats["pending_photos"] == 1
assert stats["has_primary"] is True
def test__get_photo_stats__no_photos__returns_zeros(self):
"""Test get_photo_stats returns zeros when no photos."""
park = ParkFactory()
stats = ParkMediaService.get_photo_stats(park)
assert stats["total_photos"] == 0
assert stats["approved_photos"] == 0
assert stats["pending_photos"] == 0
assert stats["has_primary"] is False
@pytest.mark.django_db
class TestParkMediaServiceBulkApprovePhotos(TestCase):
"""Tests for ParkMediaService.bulk_approve_photos."""
def test__bulk_approve_photos__multiple_photos__approves_all(self):
"""Test bulk_approve_photos approves multiple photos."""
park = ParkFactory()
photo1 = ParkPhotoFactory(park=park, is_approved=False)
photo2 = ParkPhotoFactory(park=park, is_approved=False)
photo3 = ParkPhotoFactory(park=park, is_approved=False)
staff_user = StaffUserFactory()
count = ParkMediaService.bulk_approve_photos([photo1, photo2, photo3], staff_user)
assert count == 3
photo1.refresh_from_db()
photo2.refresh_from_db()
photo3.refresh_from_db()
assert photo1.is_approved is True
assert photo2.is_approved is True
assert photo3.is_approved is True
def test__bulk_approve_photos__empty_list__returns_zero(self):
"""Test bulk_approve_photos with empty list returns 0."""
staff_user = StaffUserFactory()
count = ParkMediaService.bulk_approve_photos([], staff_user)
assert count == 0

View File

@@ -0,0 +1,381 @@
"""
Tests for RideService.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from django.test import TestCase
from django.core.exceptions import ValidationError
from apps.rides.services import RideService
from apps.rides.models import Ride
from tests.factories import (
ParkFactory,
RideFactory,
RideModelFactory,
ParkAreaFactory,
UserFactory,
ManufacturerCompanyFactory,
DesignerCompanyFactory,
)
@pytest.mark.django_db
class TestRideServiceCreateRide(TestCase):
"""Tests for RideService.create_ride."""
def test__create_ride__valid_data__creates_ride(self):
"""Test create_ride creates ride with valid data."""
park = ParkFactory()
user = UserFactory()
ride = RideService.create_ride(
name="Test Ride",
park_id=park.pk,
description="A test ride",
status="OPERATING",
category="TR",
created_by=user,
)
assert ride.name == "Test Ride"
assert ride.park == park
assert ride.description == "A test ride"
assert ride.status == "OPERATING"
assert ride.category == "TR"
def test__create_ride__with_manufacturer__sets_manufacturer(self):
"""Test create_ride sets manufacturer when provided."""
park = ParkFactory()
manufacturer = ManufacturerCompanyFactory()
ride = RideService.create_ride(
name="Test Ride",
park_id=park.pk,
category="RC",
manufacturer_id=manufacturer.pk,
)
assert ride.manufacturer == manufacturer
def test__create_ride__with_designer__sets_designer(self):
"""Test create_ride sets designer when provided."""
park = ParkFactory()
designer = DesignerCompanyFactory()
ride = RideService.create_ride(
name="Test Ride",
park_id=park.pk,
category="RC",
designer_id=designer.pk,
)
assert ride.designer == designer
def test__create_ride__with_ride_model__sets_ride_model(self):
"""Test create_ride sets ride model when provided."""
park = ParkFactory()
ride_model = RideModelFactory()
ride = RideService.create_ride(
name="Test Ride",
park_id=park.pk,
category="RC",
ride_model_id=ride_model.pk,
)
assert ride.ride_model == ride_model
def test__create_ride__with_park_area__sets_park_area(self):
"""Test create_ride sets park area when provided."""
park = ParkFactory()
area = ParkAreaFactory(park=park)
ride = RideService.create_ride(
name="Test Ride",
park_id=park.pk,
category="TR",
park_area_id=area.pk,
)
assert ride.park_area == area
def test__create_ride__invalid_park__raises_exception(self):
"""Test create_ride raises exception for invalid park."""
with pytest.raises(Exception):
RideService.create_ride(
name="Test Ride",
park_id=99999, # Non-existent
category="TR",
)
@pytest.mark.django_db
class TestRideServiceUpdateRide(TestCase):
"""Tests for RideService.update_ride."""
def test__update_ride__valid_updates__updates_ride(self):
"""Test update_ride updates ride with valid data."""
ride = RideFactory(name="Original Name", description="Original desc")
updated_ride = RideService.update_ride(
ride_id=ride.pk,
updates={"name": "Updated Name", "description": "Updated desc"},
)
assert updated_ride.name == "Updated Name"
assert updated_ride.description == "Updated desc"
def test__update_ride__partial_updates__updates_only_specified_fields(self):
"""Test update_ride only updates specified fields."""
ride = RideFactory(name="Original", status="OPERATING")
updated_ride = RideService.update_ride(
ride_id=ride.pk,
updates={"name": "New Name"},
)
assert updated_ride.name == "New Name"
assert updated_ride.status == "OPERATING" # Unchanged
def test__update_ride__nonexistent_ride__raises_exception(self):
"""Test update_ride raises exception for non-existent ride."""
with pytest.raises(Ride.DoesNotExist):
RideService.update_ride(
ride_id=99999,
updates={"name": "New Name"},
)
@pytest.mark.django_db
class TestRideServiceCloseRideTemporarily(TestCase):
"""Tests for RideService.close_ride_temporarily."""
def test__close_ride_temporarily__operating_ride__changes_status(self):
"""Test close_ride_temporarily changes status to CLOSED_TEMP."""
ride = RideFactory(status="OPERATING")
user = UserFactory()
result = RideService.close_ride_temporarily(ride_id=ride.pk, user=user)
assert result.status == "CLOSED_TEMP"
def test__close_ride_temporarily__nonexistent_ride__raises_exception(self):
"""Test close_ride_temporarily raises exception for non-existent ride."""
with pytest.raises(Ride.DoesNotExist):
RideService.close_ride_temporarily(ride_id=99999)
@pytest.mark.django_db
class TestRideServiceMarkRideSBNO(TestCase):
"""Tests for RideService.mark_ride_sbno."""
def test__mark_ride_sbno__operating_ride__changes_status(self):
"""Test mark_ride_sbno changes status to SBNO."""
ride = RideFactory(status="OPERATING")
user = UserFactory()
result = RideService.mark_ride_sbno(ride_id=ride.pk, user=user)
assert result.status == "SBNO"
@pytest.mark.django_db
class TestRideServiceScheduleRideClosing(TestCase):
"""Tests for RideService.schedule_ride_closing."""
def test__schedule_ride_closing__valid_data__schedules_closing(self):
"""Test schedule_ride_closing schedules ride closing."""
from datetime import date, timedelta
ride = RideFactory(status="OPERATING")
user = UserFactory()
closing_date = date.today() + timedelta(days=30)
result = RideService.schedule_ride_closing(
ride_id=ride.pk,
closing_date=closing_date,
post_closing_status="DEMOLISHED",
user=user,
)
assert result.status == "CLOSING"
assert result.closing_date == closing_date
assert result.post_closing_status == "DEMOLISHED"
@pytest.mark.django_db
class TestRideServiceCloseRidePermanently(TestCase):
"""Tests for RideService.close_ride_permanently."""
def test__close_ride_permanently__operating_ride__changes_status(self):
"""Test close_ride_permanently changes status to CLOSED_PERM."""
ride = RideFactory(status="OPERATING")
user = UserFactory()
result = RideService.close_ride_permanently(ride_id=ride.pk, user=user)
assert result.status == "CLOSED_PERM"
@pytest.mark.django_db
class TestRideServiceDemolishRide(TestCase):
"""Tests for RideService.demolish_ride."""
def test__demolish_ride__closed_ride__changes_status(self):
"""Test demolish_ride changes status to DEMOLISHED."""
ride = RideFactory(status="CLOSED_PERM")
user = UserFactory()
result = RideService.demolish_ride(ride_id=ride.pk, user=user)
assert result.status == "DEMOLISHED"
@pytest.mark.django_db
class TestRideServiceRelocateRide(TestCase):
"""Tests for RideService.relocate_ride."""
def test__relocate_ride__valid_data__relocates_ride(self):
"""Test relocate_ride moves ride to new park."""
old_park = ParkFactory()
new_park = ParkFactory()
ride = RideFactory(park=old_park, status="OPERATING")
user = UserFactory()
result = RideService.relocate_ride(
ride_id=ride.pk,
new_park_id=new_park.pk,
user=user,
)
assert result.park == new_park
assert result.status == "RELOCATED"
@pytest.mark.django_db
class TestRideServiceReopenRide(TestCase):
"""Tests for RideService.reopen_ride."""
def test__reopen_ride__closed_temp_ride__changes_status(self):
"""Test reopen_ride changes status to OPERATING."""
ride = RideFactory(status="CLOSED_TEMP")
user = UserFactory()
result = RideService.reopen_ride(ride_id=ride.pk, user=user)
assert result.status == "OPERATING"
@pytest.mark.django_db
class TestRideServiceHandleNewEntitySuggestions(TestCase):
"""Tests for RideService.handle_new_entity_suggestions."""
@patch("apps.rides.services.ModerationService.create_edit_submission_with_queue")
def test__handle_new_entity_suggestions__new_manufacturer__creates_submission(
self, mock_create_submission
):
"""Test handle_new_entity_suggestions creates submission for new manufacturer."""
mock_submission = Mock()
mock_submission.id = 1
mock_create_submission.return_value = mock_submission
user = UserFactory()
form_data = {
"manufacturer_search": "New Manufacturer",
"manufacturer": None,
"designer_search": "",
"designer": None,
"ride_model_search": "",
"ride_model": None,
}
result = RideService.handle_new_entity_suggestions(
form_data=form_data,
submitter=user,
)
assert result["total_submissions"] == 1
assert 1 in result["manufacturers"]
mock_create_submission.assert_called_once()
@patch("apps.rides.services.ModerationService.create_edit_submission_with_queue")
def test__handle_new_entity_suggestions__new_designer__creates_submission(
self, mock_create_submission
):
"""Test handle_new_entity_suggestions creates submission for new designer."""
mock_submission = Mock()
mock_submission.id = 2
mock_create_submission.return_value = mock_submission
user = UserFactory()
form_data = {
"manufacturer_search": "",
"manufacturer": None,
"designer_search": "New Designer",
"designer": None,
"ride_model_search": "",
"ride_model": None,
}
result = RideService.handle_new_entity_suggestions(
form_data=form_data,
submitter=user,
)
assert result["total_submissions"] == 1
assert 2 in result["designers"]
@patch("apps.rides.services.ModerationService.create_edit_submission_with_queue")
def test__handle_new_entity_suggestions__new_ride_model__creates_submission(
self, mock_create_submission
):
"""Test handle_new_entity_suggestions creates submission for new ride model."""
mock_submission = Mock()
mock_submission.id = 3
mock_create_submission.return_value = mock_submission
user = UserFactory()
manufacturer = ManufacturerCompanyFactory()
form_data = {
"manufacturer_search": "",
"manufacturer": manufacturer,
"designer_search": "",
"designer": None,
"ride_model_search": "New Model",
"ride_model": None,
}
result = RideService.handle_new_entity_suggestions(
form_data=form_data,
submitter=user,
)
assert result["total_submissions"] == 1
assert 3 in result["ride_models"]
def test__handle_new_entity_suggestions__no_new_entities__returns_empty(self):
"""Test handle_new_entity_suggestions with no new entities returns empty result."""
user = UserFactory()
manufacturer = ManufacturerCompanyFactory()
form_data = {
"manufacturer_search": "Existing Mfr",
"manufacturer": manufacturer, # Already selected
"designer_search": "",
"designer": None,
"ride_model_search": "",
"ride_model": None,
}
result = RideService.handle_new_entity_suggestions(
form_data=form_data,
submitter=user,
)
assert result["total_submissions"] == 0
assert len(result["manufacturers"]) == 0
assert len(result["designers"]) == 0
assert len(result["ride_models"]) == 0

View File

@@ -0,0 +1,332 @@
"""
Tests for UserDeletionService and AccountService.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from django.test import TestCase, RequestFactory
from django.utils import timezone
from apps.accounts.services import UserDeletionService, AccountService
from apps.accounts.models import User
from tests.factories import (
UserFactory,
StaffUserFactory,
SuperUserFactory,
ParkReviewFactory,
RideReviewFactory,
ParkFactory,
RideFactory,
)
@pytest.mark.django_db
class TestUserDeletionServiceGetOrCreateDeletedUser(TestCase):
"""Tests for UserDeletionService.get_or_create_deleted_user."""
def test__get_or_create_deleted_user__first_call__creates_user(self):
"""Test get_or_create_deleted_user creates deleted user placeholder."""
deleted_user = UserDeletionService.get_or_create_deleted_user()
assert deleted_user.username == UserDeletionService.DELETED_USER_USERNAME
assert deleted_user.email == UserDeletionService.DELETED_USER_EMAIL
assert deleted_user.is_active is False
assert deleted_user.is_banned is True
def test__get_or_create_deleted_user__second_call__returns_existing(self):
"""Test get_or_create_deleted_user returns existing user on subsequent calls."""
first_call = UserDeletionService.get_or_create_deleted_user()
second_call = UserDeletionService.get_or_create_deleted_user()
assert first_call.pk == second_call.pk
@pytest.mark.django_db
class TestUserDeletionServiceCanDeleteUser(TestCase):
"""Tests for UserDeletionService.can_delete_user."""
def test__can_delete_user__regular_user__returns_true(self):
"""Test can_delete_user returns True for regular user."""
user = UserFactory()
can_delete, reason = UserDeletionService.can_delete_user(user)
assert can_delete is True
assert reason is None
def test__can_delete_user__superuser__returns_false(self):
"""Test can_delete_user returns False for superuser."""
user = SuperUserFactory()
can_delete, reason = UserDeletionService.can_delete_user(user)
assert can_delete is False
assert "superuser" in reason.lower()
def test__can_delete_user__deleted_user_placeholder__returns_false(self):
"""Test can_delete_user returns False for deleted user placeholder."""
deleted_user = UserDeletionService.get_or_create_deleted_user()
can_delete, reason = UserDeletionService.can_delete_user(deleted_user)
assert can_delete is False
assert "placeholder" in reason.lower()
@pytest.mark.django_db
class TestUserDeletionServiceDeleteUserPreserveSubmissions(TestCase):
"""Tests for UserDeletionService.delete_user_preserve_submissions."""
def test__delete_user_preserve_submissions__user_with_reviews__preserves_reviews(self):
"""Test delete_user_preserve_submissions preserves user's reviews."""
user = UserFactory()
park = ParkFactory()
ride = RideFactory()
# Create reviews
park_review = ParkReviewFactory(user=user, park=park)
ride_review = RideReviewFactory(user=user, ride=ride)
user_pk = user.pk
result = UserDeletionService.delete_user_preserve_submissions(user)
# User should be deleted
assert not User.objects.filter(pk=user_pk).exists()
# Reviews should still exist
park_review.refresh_from_db()
ride_review.refresh_from_db()
deleted_user = UserDeletionService.get_or_create_deleted_user()
assert park_review.user == deleted_user
assert ride_review.user == deleted_user
def test__delete_user_preserve_submissions__returns_summary(self):
"""Test delete_user_preserve_submissions returns correct summary."""
user = UserFactory()
park = ParkFactory()
ParkReviewFactory(user=user, park=park)
result = UserDeletionService.delete_user_preserve_submissions(user)
assert "deleted_user" in result
assert "preserved_submissions" in result
assert "transferred_to" in result
assert result["preserved_submissions"]["park_reviews"] == 1
def test__delete_user_preserve_submissions__deleted_user_placeholder__raises_error(self):
"""Test delete_user_preserve_submissions raises error for placeholder."""
deleted_user = UserDeletionService.get_or_create_deleted_user()
with pytest.raises(ValueError):
UserDeletionService.delete_user_preserve_submissions(deleted_user)
@pytest.mark.django_db
class TestAccountServiceValidatePassword(TestCase):
"""Tests for AccountService.validate_password."""
def test__validate_password__valid_password__returns_true(self):
"""Test validate_password returns True for valid password."""
result = AccountService.validate_password("SecurePass123")
assert result is True
def test__validate_password__too_short__returns_false(self):
"""Test validate_password returns False for short password."""
result = AccountService.validate_password("Short1")
assert result is False
def test__validate_password__no_uppercase__returns_false(self):
"""Test validate_password returns False for password without uppercase."""
result = AccountService.validate_password("lowercase123")
assert result is False
def test__validate_password__no_lowercase__returns_false(self):
"""Test validate_password returns False for password without lowercase."""
result = AccountService.validate_password("UPPERCASE123")
assert result is False
def test__validate_password__no_numbers__returns_false(self):
"""Test validate_password returns False for password without numbers."""
result = AccountService.validate_password("NoNumbers")
assert result is False
@pytest.mark.django_db
class TestAccountServiceChangePassword(TestCase):
"""Tests for AccountService.change_password."""
def test__change_password__correct_old_password__changes_password(self):
"""Test change_password changes password with correct old password."""
user = UserFactory()
user.set_password("OldPassword123")
user.save()
factory = RequestFactory()
request = factory.post("/change-password/")
request.user = user
request.session = {}
with patch.object(AccountService, "_send_password_change_confirmation"):
result = AccountService.change_password(
user=user,
old_password="OldPassword123",
new_password="NewPassword456",
request=request,
)
assert result["success"] is True
user.refresh_from_db()
assert user.check_password("NewPassword456")
def test__change_password__incorrect_old_password__returns_error(self):
"""Test change_password returns error with incorrect old password."""
user = UserFactory()
user.set_password("CorrectPassword123")
user.save()
factory = RequestFactory()
request = factory.post("/change-password/")
request.user = user
result = AccountService.change_password(
user=user,
old_password="WrongPassword123",
new_password="NewPassword456",
request=request,
)
assert result["success"] is False
assert "incorrect" in result["message"].lower()
def test__change_password__weak_new_password__returns_error(self):
"""Test change_password returns error with weak new password."""
user = UserFactory()
user.set_password("OldPassword123")
user.save()
factory = RequestFactory()
request = factory.post("/change-password/")
request.user = user
result = AccountService.change_password(
user=user,
old_password="OldPassword123",
new_password="weak", # Too weak
request=request,
)
assert result["success"] is False
assert "8 characters" in result["message"]
@pytest.mark.django_db
class TestAccountServiceInitiateEmailChange(TestCase):
"""Tests for AccountService.initiate_email_change."""
@patch("apps.accounts.services.AccountService._send_email_verification")
def test__initiate_email_change__valid_email__initiates_change(self, mock_send):
"""Test initiate_email_change initiates email change for valid email."""
user = UserFactory()
factory = RequestFactory()
request = factory.post("/change-email/")
result = AccountService.initiate_email_change(
user=user,
new_email="newemail@example.com",
request=request,
)
assert result["success"] is True
user.refresh_from_db()
assert user.pending_email == "newemail@example.com"
mock_send.assert_called_once()
def test__initiate_email_change__empty_email__returns_error(self):
"""Test initiate_email_change returns error for empty email."""
user = UserFactory()
factory = RequestFactory()
request = factory.post("/change-email/")
result = AccountService.initiate_email_change(
user=user,
new_email="",
request=request,
)
assert result["success"] is False
assert "required" in result["message"].lower()
def test__initiate_email_change__duplicate_email__returns_error(self):
"""Test initiate_email_change returns error for duplicate email."""
existing_user = UserFactory(email="existing@example.com")
user = UserFactory()
factory = RequestFactory()
request = factory.post("/change-email/")
result = AccountService.initiate_email_change(
user=user,
new_email="existing@example.com",
request=request,
)
assert result["success"] is False
assert "already in use" in result["message"].lower()
@pytest.mark.django_db
class TestUserDeletionServiceRequestUserDeletion(TestCase):
"""Tests for UserDeletionService.request_user_deletion."""
@patch("apps.accounts.services.UserDeletionService.send_deletion_verification_email")
def test__request_user_deletion__regular_user__creates_request(self, mock_send):
"""Test request_user_deletion creates deletion request for regular user."""
user = UserFactory()
deletion_request = UserDeletionService.request_user_deletion(user)
assert deletion_request.user == user
assert deletion_request.verification_code is not None
mock_send.assert_called_once()
def test__request_user_deletion__superuser__raises_error(self):
"""Test request_user_deletion raises error for superuser."""
user = SuperUserFactory()
with pytest.raises(ValueError):
UserDeletionService.request_user_deletion(user)
@pytest.mark.django_db
class TestUserDeletionServiceCancelDeletionRequest(TestCase):
"""Tests for UserDeletionService.cancel_deletion_request."""
@patch("apps.accounts.services.UserDeletionService.send_deletion_verification_email")
def test__cancel_deletion_request__existing_request__cancels_it(self, mock_send):
"""Test cancel_deletion_request cancels existing request."""
user = UserFactory()
UserDeletionService.request_user_deletion(user)
result = UserDeletionService.cancel_deletion_request(user)
assert result is True
def test__cancel_deletion_request__no_request__returns_false(self):
"""Test cancel_deletion_request returns False when no request exists."""
user = UserFactory()
result = UserDeletionService.cancel_deletion_request(user)
assert result is False

View File

@@ -0,0 +1 @@
# UX Component Tests

View File

@@ -0,0 +1,193 @@
"""
Tests for breadcrumb utilities.
These tests verify that the breadcrumb system generates
correct navigation structures and Schema.org markup.
"""
import pytest
from django.test import RequestFactory
from django.urls import reverse
from apps.core.utils.breadcrumbs import (
Breadcrumb,
BreadcrumbBuilder,
build_breadcrumb,
)
class TestBreadcrumb:
"""Tests for Breadcrumb dataclass."""
def test_basic_breadcrumb(self):
"""Should create breadcrumb with required fields."""
crumb = Breadcrumb(label="Home", url="/")
assert crumb.label == "Home"
assert crumb.url == "/"
assert crumb.icon is None
assert crumb.is_current is False
def test_breadcrumb_with_icon(self):
"""Should accept icon parameter."""
crumb = Breadcrumb(label="Home", url="/", icon="fas fa-home")
assert crumb.icon == "fas fa-home"
def test_current_breadcrumb(self):
"""Should mark breadcrumb as current."""
crumb = Breadcrumb(label="Current Page", is_current=True)
assert crumb.is_current is True
assert crumb.url is None
def test_schema_position(self):
"""Should have default schema position."""
crumb = Breadcrumb(label="Test")
assert crumb.schema_position == 1
class TestBuildBreadcrumb:
"""Tests for build_breadcrumb helper function."""
def test_basic_breadcrumb(self):
"""Should create breadcrumb dict with defaults."""
crumb = build_breadcrumb("Home", "/")
assert crumb["label"] == "Home"
assert crumb["url"] == "/"
assert crumb["is_current"] is False
def test_current_breadcrumb(self):
"""Should mark as current when specified."""
crumb = build_breadcrumb("Current", is_current=True)
assert crumb["is_current"] is True
def test_breadcrumb_with_icon(self):
"""Should include icon when specified."""
crumb = build_breadcrumb("Home", "/", icon="fas fa-home")
assert crumb["icon"] == "fas fa-home"
class TestBreadcrumbBuilder:
"""Tests for BreadcrumbBuilder class."""
def test_empty_builder(self):
"""Should build empty list when no crumbs added."""
builder = BreadcrumbBuilder()
crumbs = builder.build()
assert crumbs == []
def test_add_home(self):
"""Should add home breadcrumb with defaults."""
builder = BreadcrumbBuilder()
crumbs = builder.add_home().build()
assert len(crumbs) == 1
assert crumbs[0].label == "Home"
assert crumbs[0].url == "/"
assert crumbs[0].icon == "fas fa-home"
def test_add_home_custom(self):
"""Should allow customizing home breadcrumb."""
builder = BreadcrumbBuilder()
crumbs = builder.add_home(
label="Dashboard",
url="/dashboard/",
icon="fas fa-tachometer-alt",
).build()
assert crumbs[0].label == "Dashboard"
assert crumbs[0].url == "/dashboard/"
assert crumbs[0].icon == "fas fa-tachometer-alt"
def test_add_breadcrumb(self):
"""Should add breadcrumb with label and URL."""
builder = BreadcrumbBuilder()
crumbs = builder.add("Parks", "/parks/").build()
assert len(crumbs) == 1
assert crumbs[0].label == "Parks"
assert crumbs[0].url == "/parks/"
def test_add_current(self):
"""Should add current page breadcrumb."""
builder = BreadcrumbBuilder()
crumbs = builder.add_current("Current Page").build()
assert len(crumbs) == 1
assert crumbs[0].label == "Current Page"
assert crumbs[0].is_current is True
assert crumbs[0].url is None
def test_add_current_with_icon(self):
"""Should add current page with icon."""
builder = BreadcrumbBuilder()
crumbs = builder.add_current("Settings", icon="fas fa-cog").build()
assert crumbs[0].icon == "fas fa-cog"
def test_chain_multiple_breadcrumbs(self):
"""Should chain multiple breadcrumbs."""
builder = BreadcrumbBuilder()
crumbs = (
builder.add_home()
.add("Parks", "/parks/")
.add("California", "/parks/california/")
.add_current("Disneyland")
.build()
)
assert len(crumbs) == 4
assert crumbs[0].label == "Home"
assert crumbs[1].label == "Parks"
assert crumbs[2].label == "California"
assert crumbs[3].label == "Disneyland"
assert crumbs[3].is_current is True
def test_schema_positions_auto_assigned(self):
"""Should auto-assign schema positions."""
builder = BreadcrumbBuilder()
crumbs = (
builder.add_home().add("Parks", "/parks/").add_current("Test").build()
)
assert crumbs[0].schema_position == 1
assert crumbs[1].schema_position == 2
assert crumbs[2].schema_position == 3
def test_builder_is_reusable(self):
"""Builder should be reusable after build."""
builder = BreadcrumbBuilder()
builder.add_home()
crumbs1 = builder.build()
builder.add("New", "/new/")
crumbs2 = builder.build()
assert len(crumbs1) == 1
assert len(crumbs2) == 2
class TestBreadcrumbContextProcessor:
"""Tests for breadcrumb context processor."""
def test_empty_breadcrumbs_when_not_set(self):
"""Should return empty list when not set on request."""
from apps.core.context_processors import breadcrumbs
factory = RequestFactory()
request = factory.get("/")
context = breadcrumbs(request)
assert context["breadcrumbs"] == []
def test_returns_breadcrumbs_from_request(self):
"""Should return breadcrumbs when set on request."""
from apps.core.context_processors import breadcrumbs
factory = RequestFactory()
request = factory.get("/")
request.breadcrumbs = [
build_breadcrumb("Home", "/"),
build_breadcrumb("Test", is_current=True),
]
context = breadcrumbs(request)
assert len(context["breadcrumbs"]) == 2

View File

@@ -0,0 +1,357 @@
"""
Tests for UX component templates.
These tests verify that component templates render correctly
with various parameter combinations.
"""
import pytest
from django.template import Context, Template
from django.test import RequestFactory, override_settings
@pytest.mark.django_db
class TestPageHeaderComponent:
"""Tests for page_header.html component."""
def test_renders_title(self):
"""Should render title text."""
template = Template(
"""
{% include 'components/layout/page_header.html' with title='Test Title' %}
"""
)
html = template.render(Context({}))
assert "Test Title" in html
def test_renders_subtitle(self):
"""Should render subtitle when provided."""
template = Template(
"""
{% include 'components/layout/page_header.html' with
title='Title'
subtitle='Subtitle text'
%}
"""
)
html = template.render(Context({}))
assert "Subtitle text" in html
def test_renders_icon(self):
"""Should render icon when provided."""
template = Template(
"""
{% include 'components/layout/page_header.html' with
title='Title'
icon='fas fa-star'
%}
"""
)
html = template.render(Context({}))
assert "fas fa-star" in html
def test_renders_primary_action(self):
"""Should render primary action button."""
template = Template(
"""
{% include 'components/layout/page_header.html' with
title='Title'
primary_action_url='/create/'
primary_action_text='Create'
%}
"""
)
html = template.render(Context({}))
assert "Create" in html
assert "/create/" in html
@pytest.mark.django_db
class TestActionBarComponent:
"""Tests for action_bar.html component."""
def test_renders_primary_action(self):
"""Should render primary action button."""
template = Template(
"""
{% include 'components/ui/action_bar.html' with
primary_action_text='Save'
primary_action_url='/save/'
%}
"""
)
html = template.render(Context({}))
assert "Save" in html
assert "/save/" in html
def test_renders_secondary_action(self):
"""Should render secondary action button."""
template = Template(
"""
{% include 'components/ui/action_bar.html' with
secondary_action_text='Preview'
%}
"""
)
html = template.render(Context({}))
assert "Preview" in html
def test_renders_tertiary_action(self):
"""Should render tertiary action button."""
template = Template(
"""
{% include 'components/ui/action_bar.html' with
tertiary_action_text='Cancel'
tertiary_action_url='/back/'
%}
"""
)
html = template.render(Context({}))
assert "Cancel" in html
assert "/back/" in html
def test_alignment_classes(self):
"""Should apply correct alignment classes."""
template = Template(
"""
{% include 'components/ui/action_bar.html' with
align='between'
primary_action_text='Save'
%}
"""
)
html = template.render(Context({}))
assert "justify-between" in html
@pytest.mark.django_db
class TestSkeletonComponents:
"""Tests for skeleton screen components."""
def test_list_skeleton_renders(self):
"""Should render list skeleton with specified rows."""
template = Template(
"""
{% include 'components/skeletons/list_skeleton.html' with rows=3 %}
"""
)
html = template.render(Context({}))
assert "animate-pulse" in html
def test_card_grid_skeleton_renders(self):
"""Should render card grid skeleton."""
template = Template(
"""
{% include 'components/skeletons/card_grid_skeleton.html' with cards=4 %}
"""
)
html = template.render(Context({}))
assert "animate-pulse" in html
def test_detail_skeleton_renders(self):
"""Should render detail skeleton."""
template = Template(
"""
{% include 'components/skeletons/detail_skeleton.html' %}
"""
)
html = template.render(Context({}))
assert "animate-pulse" in html
def test_form_skeleton_renders(self):
"""Should render form skeleton."""
template = Template(
"""
{% include 'components/skeletons/form_skeleton.html' with fields=3 %}
"""
)
html = template.render(Context({}))
assert "animate-pulse" in html
def test_table_skeleton_renders(self):
"""Should render table skeleton."""
template = Template(
"""
{% include 'components/skeletons/table_skeleton.html' with rows=5 columns=4 %}
"""
)
html = template.render(Context({}))
assert "animate-pulse" in html
@pytest.mark.django_db
class TestModalComponents:
"""Tests for modal components."""
def test_modal_base_renders(self):
"""Should render modal base structure."""
template = Template(
"""
{% include 'components/modals/modal_base.html' with
modal_id='test-modal'
show_var='showModal'
title='Test Modal'
%}
"""
)
html = template.render(Context({}))
assert "test-modal" in html
assert "Test Modal" in html
assert "showModal" in html
def test_modal_confirm_renders(self):
"""Should render confirmation modal."""
template = Template(
"""
{% include 'components/modals/modal_confirm.html' with
modal_id='confirm-modal'
show_var='showConfirm'
title='Confirm Action'
message='Are you sure?'
confirm_text='Yes'
%}
"""
)
html = template.render(Context({}))
assert "confirm-modal" in html
assert "Confirm Action" in html
assert "Are you sure?" in html
assert "Yes" in html
def test_modal_confirm_destructive_variant(self):
"""Should apply destructive styling."""
template = Template(
"""
{% include 'components/modals/modal_confirm.html' with
modal_id='delete-modal'
show_var='showDelete'
title='Delete'
message='Delete this item?'
confirm_variant='destructive'
%}
"""
)
html = template.render(Context({}))
assert "btn-destructive" in html
@pytest.mark.django_db
class TestBreadcrumbComponent:
"""Tests for breadcrumb component."""
def test_renders_breadcrumbs(self):
"""Should render breadcrumb navigation."""
template = Template(
"""
{% include 'components/navigation/breadcrumbs.html' %}
"""
)
breadcrumbs = [
{"label": "Home", "url": "/", "is_current": False},
{"label": "Parks", "url": "/parks/", "is_current": False},
{"label": "Test Park", "url": None, "is_current": True},
]
html = template.render(Context({"breadcrumbs": breadcrumbs}))
assert "Home" in html
assert "Parks" in html
assert "Test Park" in html
def test_renders_schema_org_markup(self):
"""Should include Schema.org BreadcrumbList."""
template = Template(
"""
{% include 'components/navigation/breadcrumbs.html' %}
"""
)
breadcrumbs = [
{"label": "Home", "url": "/", "is_current": False, "schema_position": 1},
{"label": "Test", "url": None, "is_current": True, "schema_position": 2},
]
html = template.render(Context({"breadcrumbs": breadcrumbs}))
assert "BreadcrumbList" in html
def test_empty_breadcrumbs(self):
"""Should handle empty breadcrumbs gracefully."""
template = Template(
"""
{% include 'components/navigation/breadcrumbs.html' %}
"""
)
html = template.render(Context({"breadcrumbs": []}))
# Should not error, may render nothing or empty nav
assert html is not None
@pytest.mark.django_db
class TestStatusBadgeComponent:
"""Tests for status badge component."""
def test_renders_status_text(self):
"""Should render status label."""
template = Template(
"""
{% include 'components/status_badge.html' with status='published' label='Published' %}
"""
)
html = template.render(Context({}))
assert "Published" in html
def test_applies_status_colors(self):
"""Should apply appropriate color classes for status."""
# Test published/active status
template = Template(
"""
{% include 'components/status_badge.html' with status='published' %}
"""
)
html = template.render(Context({}))
# Should have some indication of success/green styling
assert "green" in html.lower() or "success" in html.lower() or "published" in html.lower()
@pytest.mark.django_db
class TestLoadingIndicatorComponent:
"""Tests for loading indicator component."""
def test_renders_loading_indicator(self):
"""Should render loading indicator."""
template = Template(
"""
{% include 'htmx/components/loading_indicator.html' with text='Loading...' %}
"""
)
html = template.render(Context({}))
assert "Loading" in html
def test_renders_with_id(self):
"""Should render with specified ID for htmx-indicator."""
template = Template(
"""
{% include 'htmx/components/loading_indicator.html' with id='my-loader' %}
"""
)
html = template.render(Context({}))
assert "my-loader" in html

View File

@@ -0,0 +1,282 @@
"""
Tests for HTMX utility functions.
These tests verify that the HTMX response helpers generate
correct responses with proper headers and content.
"""
import json
import pytest
from django.http import HttpRequest
from django.test import RequestFactory
from apps.core.htmx_utils import (
get_htmx_target,
get_htmx_trigger,
htmx_error,
htmx_modal_close,
htmx_redirect,
htmx_refresh,
htmx_refresh_section,
htmx_success,
htmx_trigger,
htmx_validation_response,
htmx_warning,
is_htmx_request,
)
class TestIsHtmxRequest:
"""Tests for is_htmx_request function."""
def test_returns_true_for_htmx_request(self):
"""Should return True when HX-Request header is 'true'."""
factory = RequestFactory()
request = factory.get("/", HTTP_HX_REQUEST="true")
assert is_htmx_request(request) is True
def test_returns_false_for_regular_request(self):
"""Should return False for regular requests without HTMX header."""
factory = RequestFactory()
request = factory.get("/")
assert is_htmx_request(request) is False
def test_returns_false_for_wrong_value(self):
"""Should return False when HX-Request header has wrong value."""
factory = RequestFactory()
request = factory.get("/", HTTP_HX_REQUEST="false")
assert is_htmx_request(request) is False
class TestGetHtmxTarget:
"""Tests for get_htmx_target function."""
def test_returns_target_when_present(self):
"""Should return target ID when HX-Target header is present."""
factory = RequestFactory()
request = factory.get("/", HTTP_HX_TARGET="my-target")
assert get_htmx_target(request) == "my-target"
def test_returns_none_when_missing(self):
"""Should return None when HX-Target header is missing."""
factory = RequestFactory()
request = factory.get("/")
assert get_htmx_target(request) is None
class TestGetHtmxTrigger:
"""Tests for get_htmx_trigger function."""
def test_returns_trigger_when_present(self):
"""Should return trigger ID when HX-Trigger header is present."""
factory = RequestFactory()
request = factory.get("/", HTTP_HX_TRIGGER="my-button")
assert get_htmx_trigger(request) == "my-button"
def test_returns_none_when_missing(self):
"""Should return None when HX-Trigger header is missing."""
factory = RequestFactory()
request = factory.get("/")
assert get_htmx_trigger(request) is None
class TestHtmxRedirect:
"""Tests for htmx_redirect function."""
def test_sets_redirect_header(self):
"""Should set HX-Redirect header with correct URL."""
response = htmx_redirect("/parks/")
assert response["HX-Redirect"] == "/parks/"
def test_returns_empty_body(self):
"""Should return empty response body."""
response = htmx_redirect("/parks/")
assert response.content == b""
class TestHtmxTrigger:
"""Tests for htmx_trigger function."""
def test_simple_trigger(self):
"""Should set simple trigger name."""
response = htmx_trigger("myEvent")
assert response["HX-Trigger"] == "myEvent"
def test_trigger_with_payload(self):
"""Should set trigger with JSON payload."""
response = htmx_trigger("myEvent", {"key": "value"})
trigger_data = json.loads(response["HX-Trigger"])
assert trigger_data == {"myEvent": {"key": "value"}}
class TestHtmxRefresh:
"""Tests for htmx_refresh function."""
def test_sets_refresh_header(self):
"""Should set HX-Refresh header to 'true'."""
response = htmx_refresh()
assert response["HX-Refresh"] == "true"
class TestHtmxSuccess:
"""Tests for htmx_success function."""
def test_basic_success_message(self):
"""Should create success response with toast trigger."""
response = htmx_success("Item saved!")
trigger_data = json.loads(response["HX-Trigger"])
assert "showToast" in trigger_data
assert trigger_data["showToast"]["type"] == "success"
assert trigger_data["showToast"]["message"] == "Item saved!"
assert trigger_data["showToast"]["duration"] == 5000
def test_success_with_custom_duration(self):
"""Should allow custom duration."""
response = htmx_success("Quick message", duration=2000)
trigger_data = json.loads(response["HX-Trigger"])
assert trigger_data["showToast"]["duration"] == 2000
def test_success_with_title(self):
"""Should include title when provided."""
response = htmx_success("Details here", title="Success!")
trigger_data = json.loads(response["HX-Trigger"])
assert trigger_data["showToast"]["title"] == "Success!"
def test_success_with_action(self):
"""Should include action button config."""
response = htmx_success(
"Item deleted",
action={"label": "Undo", "onClick": "undoDelete()"},
)
trigger_data = json.loads(response["HX-Trigger"])
assert trigger_data["showToast"]["action"]["label"] == "Undo"
assert trigger_data["showToast"]["action"]["onClick"] == "undoDelete()"
def test_success_with_html_content(self):
"""Should include HTML in response body."""
response = htmx_success("Done", html="<div>Updated</div>")
assert response.content == b"<div>Updated</div>"
class TestHtmxError:
"""Tests for htmx_error function."""
def test_basic_error_message(self):
"""Should create error response with toast trigger."""
response = htmx_error("Something went wrong")
trigger_data = json.loads(response["HX-Trigger"])
assert response.status_code == 400
assert trigger_data["showToast"]["type"] == "error"
assert trigger_data["showToast"]["message"] == "Something went wrong"
assert trigger_data["showToast"]["duration"] == 0 # Persistent by default
def test_error_with_custom_status(self):
"""Should allow custom HTTP status code."""
response = htmx_error("Validation failed", status=422)
assert response.status_code == 422
def test_error_with_retry_action(self):
"""Should include retry action when requested."""
response = htmx_error("Server error", show_retry=True)
trigger_data = json.loads(response["HX-Trigger"])
assert trigger_data["showToast"]["action"]["label"] == "Retry"
class TestHtmxWarning:
"""Tests for htmx_warning function."""
def test_basic_warning_message(self):
"""Should create warning response with toast trigger."""
response = htmx_warning("Session expiring soon")
trigger_data = json.loads(response["HX-Trigger"])
assert trigger_data["showToast"]["type"] == "warning"
assert trigger_data["showToast"]["message"] == "Session expiring soon"
assert trigger_data["showToast"]["duration"] == 8000
class TestHtmxModalClose:
"""Tests for htmx_modal_close function."""
def test_basic_modal_close(self):
"""Should trigger closeModal event."""
response = htmx_modal_close()
trigger_data = json.loads(response["HX-Trigger"])
assert trigger_data["closeModal"] is True
def test_modal_close_with_message(self):
"""Should include success toast when message provided."""
response = htmx_modal_close(message="Saved successfully!")
trigger_data = json.loads(response["HX-Trigger"])
assert trigger_data["closeModal"] is True
assert trigger_data["showToast"]["message"] == "Saved successfully!"
def test_modal_close_with_refresh(self):
"""Should include refresh section trigger."""
response = htmx_modal_close(
message="Done",
refresh_target="#items-list",
refresh_url="/items/",
)
trigger_data = json.loads(response["HX-Trigger"])
assert trigger_data["refreshSection"]["target"] == "#items-list"
assert trigger_data["refreshSection"]["url"] == "/items/"
class TestHtmxRefreshSection:
"""Tests for htmx_refresh_section function."""
def test_sets_retarget_header(self):
"""Should set HX-Retarget header."""
response = htmx_refresh_section("#my-section", html="<div>New</div>")
assert response["HX-Retarget"] == "#my-section"
assert response["HX-Reswap"] == "innerHTML"
def test_includes_success_message(self):
"""Should include toast when message provided."""
response = htmx_refresh_section(
"#my-section",
html="<div>New</div>",
message="Section updated",
)
trigger_data = json.loads(response["HX-Trigger"])
assert trigger_data["showToast"]["message"] == "Section updated"
@pytest.mark.django_db
class TestHtmxValidationResponse:
"""Tests for htmx_validation_response function."""
def test_validation_error_response(self):
"""Should render error template with errors."""
response = htmx_validation_response(
"email",
errors=["Invalid email format"],
)
assert response.status_code == 200
# Response should contain error markup
def test_validation_success_response(self):
"""Should render success template with message."""
response = htmx_validation_response(
"username",
success_message="Username available",
)
assert response.status_code == 200
# Response should contain success markup
def test_validation_neutral_response(self):
"""Should render empty success when no errors or message."""
response = htmx_validation_response("field")
assert response.status_code == 200

View File

@@ -0,0 +1,139 @@
"""
Tests for standardized message utilities.
These tests verify that message helper functions generate
consistent, user-friendly messages.
"""
import pytest
from apps.core.utils.messages import (
confirm_delete,
error_not_found,
error_permission,
error_validation,
info_no_changes,
success_created,
success_deleted,
success_updated,
warning_unsaved,
)
class TestSuccessMessages:
"""Tests for success message helpers."""
def test_success_created_basic(self):
"""Should generate basic created message."""
message = success_created("Park")
assert "Park" in message
assert "created" in message.lower()
def test_success_created_with_name(self):
"""Should include object name when provided."""
message = success_created("Park", "Disneyland")
assert "Disneyland" in message
def test_success_created_custom(self):
"""Should use custom message when provided."""
message = success_created("Park", custom_message="Your park is ready!")
assert message == "Your park is ready!"
def test_success_updated_basic(self):
"""Should generate basic updated message."""
message = success_updated("Park")
assert "Park" in message
assert "updated" in message.lower()
def test_success_updated_with_name(self):
"""Should include object name when provided."""
message = success_updated("Park", "Disneyland")
assert "Disneyland" in message
def test_success_deleted_basic(self):
"""Should generate basic deleted message."""
message = success_deleted("Park")
assert "Park" in message
assert "deleted" in message.lower()
def test_success_deleted_with_name(self):
"""Should include object name when provided."""
message = success_deleted("Park", "Old Park")
assert "Old Park" in message
class TestErrorMessages:
"""Tests for error message helpers."""
def test_error_validation_generic(self):
"""Should generate generic validation error."""
message = error_validation()
assert "validation" in message.lower() or "invalid" in message.lower()
def test_error_validation_with_field(self):
"""Should include field name when provided."""
message = error_validation("email")
assert "email" in message.lower()
def test_error_validation_custom(self):
"""Should use custom message when provided."""
message = error_validation(custom_message="Email format is invalid")
assert message == "Email format is invalid"
def test_error_not_found_basic(self):
"""Should generate not found message."""
message = error_not_found("Park")
assert "Park" in message
assert "not found" in message.lower() or "could not" in message.lower()
def test_error_permission_basic(self):
"""Should generate permission denied message."""
message = error_permission()
assert "permission" in message.lower() or "authorized" in message.lower()
def test_error_permission_with_action(self):
"""Should include action when provided."""
message = error_permission("delete this park")
assert "delete" in message.lower()
class TestWarningMessages:
"""Tests for warning message helpers."""
def test_warning_unsaved(self):
"""Should generate unsaved changes warning."""
message = warning_unsaved()
assert "unsaved" in message.lower() or "changes" in message.lower()
class TestInfoMessages:
"""Tests for info message helpers."""
def test_info_no_changes(self):
"""Should generate no changes message."""
message = info_no_changes()
assert "no changes" in message.lower() or "nothing" in message.lower()
class TestConfirmMessages:
"""Tests for confirmation message helpers."""
def test_confirm_delete_basic(self):
"""Should generate delete confirmation message."""
message = confirm_delete("Park")
assert "Park" in message
assert "delete" in message.lower()
def test_confirm_delete_with_name(self):
"""Should include object name when provided."""
message = confirm_delete("Park", "Disneyland")
assert "Disneyland" in message
def test_confirm_delete_warning(self):
"""Should include warning about irreversibility."""
message = confirm_delete("Park")
assert (
"cannot be undone" in message.lower()
or "permanent" in message.lower()
or "sure" in message.lower()
)

Some files were not shown because too many files have changed in this diff Show More