mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 03:11:08 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
417
backend/apps/core/templatetags/common_filters.py
Normal file
417
backend/apps/core/templatetags/common_filters.py
Normal 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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
415
backend/apps/core/utils/breadcrumbs.py
Normal file
415
backend/apps/core/utils/breadcrumbs.py
Normal 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,
|
||||||
|
}
|
||||||
463
backend/apps/core/utils/messages.py
Normal file
463
backend/apps/core/utils/messages.py
Normal 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}"
|
||||||
340
backend/apps/core/utils/meta.py
Normal file
340
backend/apps/core/utils/meta.py
Normal 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}..."
|
||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
@@ -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
551
backend/static/js/form-validation.js
Normal file
551
backend/static/js/form-validation.js
Normal 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;
|
||||||
@@ -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
421
backend/templates/README.md
Normal 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` |
|
||||||
@@ -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>© {% now "Y" %} ThrillWiki. All rights reserved.</p>
|
<div class="text-sm text-muted-foreground">
|
||||||
</div>
|
<p>© {% 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>
|
||||||
|
|||||||
172
backend/templates/components/history_panel.html
Normal file
172
backend/templates/components/history_panel.html
Normal 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 %}
|
||||||
141
backend/templates/components/layout/page_header.html
Normal file
141
backend/templates/components/layout/page_header.html
Normal 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
142
backend/templates/components/modals/modal_inner.html
Normal file
142
backend/templates/components/modals/modal_inner.html
Normal 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 %}
|
||||||
133
backend/templates/components/navigation/README.md
Normal file
133
backend/templates/components/navigation/README.md
Normal 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
|
||||||
120
backend/templates/components/navigation/breadcrumbs.html
Normal file
120
backend/templates/components/navigation/breadcrumbs.html
Normal 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
156
backend/templates/components/pagination_inner.html
Normal file
156
backend/templates/components/pagination_inner.html
Normal 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>
|
||||||
@@ -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">
|
||||||
|
|||||||
108
backend/templates/components/skeletons/card_grid_skeleton.html
Normal file
108
backend/templates/components/skeletons/card_grid_skeleton.html
Normal 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 %}
|
||||||
118
backend/templates/components/skeletons/detail_skeleton.html
Normal file
118
backend/templates/components/skeletons/detail_skeleton.html
Normal 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 %}
|
||||||
119
backend/templates/components/skeletons/form_skeleton.html
Normal file
119
backend/templates/components/skeletons/form_skeleton.html
Normal 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 %}
|
||||||
85
backend/templates/components/skeletons/list_skeleton.html
Normal file
85
backend/templates/components/skeletons/list_skeleton.html
Normal 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 %}
|
||||||
137
backend/templates/components/skeletons/table_skeleton.html
Normal file
137
backend/templates/components/skeletons/table_skeleton.html
Normal 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 %}
|
||||||
87
backend/templates/components/stats_card.html
Normal file
87
backend/templates/components/stats_card.html
Normal 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
99
backend/templates/components/status_badge_inner.html
Normal file
99
backend/templates/components/status_badge_inner.html
Normal 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 %}
|
||||||
169
backend/templates/components/ui/action_bar.html
Normal file
169
backend/templates/components/ui/action_bar.html
Normal 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
140
backend/templates/components/ui/dialog.html
Normal file
140
backend/templates/components/ui/dialog.html
Normal 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 %}
|
||||||
227
backend/templates/components/ui/icon.html
Normal file
227
backend/templates/components/ui/icon.html
Normal 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
225
backend/templates/forms/README.md
Normal file
225
backend/templates/forms/README.md
Normal 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`
|
||||||
106
backend/templates/forms/layouts/grid.html
Normal file
106
backend/templates/forms/layouts/grid.html
Normal 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 %}
|
||||||
149
backend/templates/forms/layouts/inline.html
Normal file
149
backend/templates/forms/layouts/inline.html
Normal 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 %}
|
||||||
106
backend/templates/forms/layouts/stacked.html
Normal file
106
backend/templates/forms/layouts/stacked.html
Normal 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
194
backend/templates/forms/partials/form_submission_feedback.html
Normal file
194
backend/templates/forms/partials/form_submission_feedback.html
Normal 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 %}
|
||||||
273
backend/templates/htmx/README.md
Normal file
273
backend/templates/htmx/README.md
Normal 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).
|
||||||
191
backend/templates/htmx/components/README.md
Normal file
191
backend/templates/htmx/components/README.md
Normal 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)
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
282
backend/templates/tests/design-system-test.html
Normal file
282
backend/templates/tests/design-system-test.html
Normal 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 %}
|
||||||
6
backend/tests/api/__init__.py
Normal file
6
backend/tests/api/__init__.py
Normal 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.
|
||||||
|
"""
|
||||||
596
backend/tests/api/test_auth_api.py
Normal file
596
backend/tests/api/test_auth_api.py
Normal 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
|
||||||
|
])
|
||||||
120
backend/tests/api/test_error_handling.py
Normal file
120
backend/tests/api/test_error_handling.py
Normal 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
|
||||||
146
backend/tests/api/test_filters.py
Normal file
146
backend/tests/api/test_filters.py
Normal 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)
|
||||||
118
backend/tests/api/test_pagination.py
Normal file
118
backend/tests/api/test_pagination.py
Normal 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"
|
||||||
|
)
|
||||||
547
backend/tests/api/test_parks_api.py
Normal file
547
backend/tests/api/test_parks_api.py
Normal 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])
|
||||||
120
backend/tests/api/test_response_format.py
Normal file
120
backend/tests/api/test_response_format.py
Normal 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)
|
||||||
770
backend/tests/api/test_rides_api.py
Normal file
770
backend/tests/api/test_rides_api.py
Normal 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
271
backend/tests/conftest.py
Normal 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")
|
||||||
6
backend/tests/e2e/__init__.py
Normal file
6
backend/tests/e2e/__init__.py
Normal 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.
|
||||||
|
"""
|
||||||
@@ -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
|
||||||
|
|||||||
182
backend/tests/e2e/test_park_browsing.py
Normal file
182
backend/tests/e2e/test_park_browsing.py
Normal 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/**")
|
||||||
372
backend/tests/e2e/test_review_submission.py
Normal file
372
backend/tests/e2e/test_review_submission.py
Normal 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)
|
||||||
280
backend/tests/e2e/test_user_registration.py
Normal file
280
backend/tests/e2e/test_user_registration.py
Normal 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/**")
|
||||||
@@ -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)
|
||||||
|
|||||||
6
backend/tests/forms/__init__.py
Normal file
6
backend/tests/forms/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Form tests.
|
||||||
|
|
||||||
|
This module contains tests for Django forms to verify
|
||||||
|
validation, widgets, and custom logic.
|
||||||
|
"""
|
||||||
315
backend/tests/forms/test_park_forms.py
Normal file
315
backend/tests/forms/test_park_forms.py
Normal 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
|
||||||
371
backend/tests/forms/test_ride_forms.py
Normal file
371
backend/tests/forms/test_ride_forms.py
Normal 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
|
||||||
230
backend/tests/integration/test_fsm_transition_workflow.py
Normal file
230
backend/tests/integration/test_fsm_transition_workflow.py
Normal 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"
|
||||||
233
backend/tests/integration/test_park_creation_workflow.py
Normal file
233
backend/tests/integration/test_park_creation_workflow.py
Normal 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
|
||||||
224
backend/tests/integration/test_photo_upload_workflow.py
Normal file
224
backend/tests/integration/test_photo_upload_workflow.py
Normal 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
|
||||||
6
backend/tests/managers/__init__.py
Normal file
6
backend/tests/managers/__init__.py
Normal 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.
|
||||||
|
"""
|
||||||
354
backend/tests/managers/test_core_managers.py
Normal file
354
backend/tests/managers/test_core_managers.py
Normal 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
|
||||||
381
backend/tests/managers/test_park_managers.py
Normal file
381
backend/tests/managers/test_park_managers.py
Normal 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
|
||||||
408
backend/tests/managers/test_ride_managers.py
Normal file
408
backend/tests/managers/test_ride_managers.py
Normal 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
|
||||||
5
backend/tests/middleware/__init__.py
Normal file
5
backend/tests/middleware/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Middleware tests.
|
||||||
|
|
||||||
|
This module contains tests for custom middleware classes.
|
||||||
|
"""
|
||||||
368
backend/tests/middleware/test_contract_validation_middleware.py
Normal file
368
backend/tests/middleware/test_contract_validation_middleware.py
Normal 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
|
||||||
6
backend/tests/serializers/__init__.py
Normal file
6
backend/tests/serializers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Serializer tests.
|
||||||
|
|
||||||
|
This module contains tests for DRF serializers to verify
|
||||||
|
validation, field mapping, and custom logic.
|
||||||
|
"""
|
||||||
514
backend/tests/serializers/test_account_serializers.py
Normal file
514
backend/tests/serializers/test_account_serializers.py
Normal 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
|
||||||
477
backend/tests/serializers/test_park_serializers.py
Normal file
477
backend/tests/serializers/test_park_serializers.py
Normal 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
|
||||||
573
backend/tests/serializers/test_ride_serializers.py
Normal file
573
backend/tests/serializers/test_ride_serializers.py
Normal 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
|
||||||
6
backend/tests/services/__init__.py
Normal file
6
backend/tests/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Service layer tests.
|
||||||
|
|
||||||
|
This module contains tests for service classes that encapsulate
|
||||||
|
business logic following Django styleguide patterns.
|
||||||
|
"""
|
||||||
290
backend/tests/services/test_park_media_service.py
Normal file
290
backend/tests/services/test_park_media_service.py
Normal 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
|
||||||
381
backend/tests/services/test_ride_service.py
Normal file
381
backend/tests/services/test_ride_service.py
Normal 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
|
||||||
332
backend/tests/services/test_user_deletion_service.py
Normal file
332
backend/tests/services/test_user_deletion_service.py
Normal 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
|
||||||
1
backend/tests/ux/__init__.py
Normal file
1
backend/tests/ux/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# UX Component Tests
|
||||||
193
backend/tests/ux/test_breadcrumbs.py
Normal file
193
backend/tests/ux/test_breadcrumbs.py
Normal 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
|
||||||
357
backend/tests/ux/test_components.py
Normal file
357
backend/tests/ux/test_components.py
Normal 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
|
||||||
282
backend/tests/ux/test_htmx_utils.py
Normal file
282
backend/tests/ux/test_htmx_utils.py
Normal 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
|
||||||
139
backend/tests/ux/test_messages.py
Normal file
139
backend/tests/ux/test_messages.py
Normal 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
Reference in New Issue
Block a user