mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 08:51:09 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
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}..."
|
||||
Reference in New Issue
Block a user