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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,74 @@ from django import template
register = template.Library()
# 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
def has_reviewed_park(user, park):
"""Check if a user has reviewed a park"""