Add test utilities and state machine diagrams for FSM models

- Introduced reusable test utilities in `backend/tests/utils` for FSM transitions, HTMX interactions, and common scenarios.
- Added factory functions for creating test submissions, parks, rides, and photo submissions.
- Implemented assertion helpers for verifying state changes, toast notifications, and transition logs.
- Created comprehensive state machine diagrams for all FSM-enabled models in `docs/STATE_DIAGRAMS.md`, detailing states, transitions, and guard conditions.
This commit is contained in:
pacnpal
2025-12-22 08:55:39 -05:00
parent b508434574
commit 45d97b6e68
71 changed files with 8608 additions and 633 deletions

View File

@@ -0,0 +1,37 @@
"""
Context processors for the core app.
This module provides context processors that add useful utilities
and data to template contexts across the application.
"""
from django_fsm import can_proceed
from .state_machine.exceptions import format_transition_error
from .state_machine.mixins import TRANSITION_METADATA
def fsm_context(request):
"""
Add FSM utilities to template context.
This context processor makes FSM-related utilities available in all
templates, enabling easier integration of state machine functionality.
Available context variables:
- can_proceed: Function to check if a transition can proceed
- format_transition_error: Function to format FSM exceptions
- TRANSITION_METADATA: Dictionary of default transition metadata
Usage in templates:
{% if can_proceed(submission.transition_to_approved, request.user) %}
<button>Approve</button>
{% endif %}
Returns:
Dictionary of FSM utilities
"""
return {
'can_proceed': can_proceed,
'format_transition_error': format_transition_error,
'TRANSITION_METADATA': TRANSITION_METADATA,
}

View File

@@ -8,7 +8,7 @@ showing which callbacks are registered for each model and transition.
from django.core.management.base import BaseCommand, CommandParser
from django.apps import apps
from apps.core.state_machine.callbacks import (
from apps.core.state_machine.callback_base import (
callback_registry,
CallbackStage,
)

View File

@@ -9,7 +9,7 @@ from django.core.management.base import BaseCommand, CommandParser, CommandError
from django.apps import apps
from django.contrib.auth import get_user_model
from apps.core.state_machine.callbacks import (
from apps.core.state_machine.callback_base import (
callback_registry,
CallbackStage,
TransitionContext,

View File

@@ -22,7 +22,7 @@ from .registry import (
register_transition_callbacks,
discover_and_register_callbacks,
)
from .callbacks import (
from .callback_base import (
BaseTransitionCallback,
PreTransitionCallback,
PostTransitionCallback,

View File

@@ -20,7 +20,7 @@ Callback Lifecycle:
Example Usage:
Define a custom callback::
from apps.core.state_machine.callbacks import (
from apps.core.state_machine.callback_base import (
PostTransitionCallback,
TransitionContext,
register_post_callback
@@ -91,7 +91,7 @@ class CallbackStage(Enum):
Example:
Register callbacks at different stages::
from apps.core.state_machine.callbacks import (
from apps.core.state_machine.callback_base import (
CallbackStage,
callback_registry
)

View File

@@ -11,7 +11,7 @@ import logging
from django.conf import settings
from django.db import models
from ..callbacks import PostTransitionCallback, TransitionContext
from ..callback_base import PostTransitionCallback, TransitionContext
logger = logging.getLogger(__name__)

View File

@@ -11,7 +11,7 @@ import logging
from django.conf import settings
from django.db import models
from ..callbacks import PostTransitionCallback, TransitionContext
from ..callback_base import PostTransitionCallback, TransitionContext
logger = logging.getLogger(__name__)

View File

@@ -11,7 +11,7 @@ import logging
from django.conf import settings
from django.db import models, transaction
from ..callbacks import PostTransitionCallback, TransitionContext
from ..callback_base import PostTransitionCallback, TransitionContext
logger = logging.getLogger(__name__)

View File

@@ -7,7 +7,7 @@ from django.db import models
from django_fsm import transition
from django_fsm_log.decorators import fsm_log_by
from .callbacks import (
from .callback_base import (
BaseTransitionCallback,
CallbackStage,
TransitionContext,
@@ -275,7 +275,7 @@ def register_method_callbacks(
if not metadata or not metadata.get('callbacks'):
return
from .callbacks import CallbackStage, PostTransitionCallback, PreTransitionCallback
from .callback_base import CallbackStage, PostTransitionCallback, PreTransitionCallback
for callback in metadata['callbacks']:
# Determine stage from callback type

View File

@@ -38,12 +38,72 @@ See Also:
- apps.core.state_machine.fields.RichFSMField: The FSM field implementation
- django_fsm.can_proceed: FSM transition checking utility
"""
from typing import Any, Iterable, Optional
from typing import Any, Dict, Iterable, List, Optional
from django.db import models
from django_fsm import can_proceed
# Default transition metadata for styling
TRANSITION_METADATA = {
# Approval transitions
"approve": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
"transition_to_approved": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
# Rejection transitions
"reject": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
"transition_to_rejected": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
# Escalation transitions
"escalate": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
"transition_to_escalated": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
# Assignment transitions
"assign": {"style": "blue", "icon": "user-plus", "requires_confirm": False},
"unassign": {"style": "gray", "icon": "user-minus", "requires_confirm": False},
# Status transitions
"start": {"style": "blue", "icon": "play", "requires_confirm": False},
"complete": {"style": "green", "icon": "check-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to complete this?"},
"cancel": {"style": "red", "icon": "ban", "requires_confirm": True, "confirm_message": "Are you sure you want to cancel this?"},
"reopen": {"style": "blue", "icon": "redo", "requires_confirm": False},
# Resolution transitions
"resolve": {"style": "green", "icon": "check-double", "requires_confirm": True, "confirm_message": "Are you sure you want to resolve this?"},
"dismiss": {"style": "gray", "icon": "times-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to dismiss this?"},
# Default
"default": {"style": "gray", "icon": "arrow-right", "requires_confirm": False},
}
def _get_transition_metadata(transition_name: str) -> Dict[str, Any]:
"""Get metadata for a transition by name."""
if transition_name in TRANSITION_METADATA:
return TRANSITION_METADATA[transition_name].copy()
for key, metadata in TRANSITION_METADATA.items():
if key in transition_name.lower() or transition_name.lower() in key:
return metadata.copy()
return TRANSITION_METADATA["default"].copy()
def _format_transition_label(transition_name: str) -> str:
"""Format a transition method name into a human-readable label."""
label = transition_name
for prefix in ['transition_to_', 'transition_', 'do_']:
if label.startswith(prefix):
label = label[len(prefix):]
break
if label.endswith('ed') and len(label) > 3:
if label.endswith('ied'):
label = label[:-3] + 'y'
elif label[-3] == label[-4]:
label = label[:-3]
else:
label = label[:-1]
if not label.endswith('e'):
label = label[:-1]
return label.replace('_', ' ').title()
class StateMachineMixin(models.Model):
"""
Common helpers for models that use django-fsm.
@@ -148,5 +208,57 @@ class StateMachineMixin(models.Model):
current_state = self.get_state_value(field_name)
return current_state == state
def get_available_user_transitions(self, user) -> List[Dict[str, Any]]:
"""
Get transitions available to the given user.
__all__ = ["StateMachineMixin"]
This method returns a list of transition metadata dictionaries for
transitions that the given user can execute based on their permissions.
Args:
user: The user to check permissions for
Returns:
List of dictionaries containing:
- name: The transition method name
- label: Human-readable label
- icon: Font Awesome icon name
- style: Button style (green, red, yellow, blue, gray)
- requires_confirm: Whether confirmation is needed
- confirm_message: Message to show in confirmation dialog
Example:
transitions = submission.get_available_user_transitions(request.user)
for t in transitions:
print(f"{t['label']}: {t['name']}")
"""
transitions = []
if not user:
return transitions
# Get available transitions from the FSM field
available_transition_names = list(self.get_available_transitions())
for transition_name in available_transition_names:
method = getattr(self, transition_name, None)
if method and callable(method):
try:
if can_proceed(method, user):
metadata = _get_transition_metadata(transition_name)
transitions.append({
'name': transition_name,
'label': _format_transition_label(transition_name),
'icon': metadata.get('icon', 'arrow-right'),
'style': metadata.get('style', 'gray'),
'requires_confirm': metadata.get('requires_confirm', False),
'confirm_message': metadata.get('confirm_message', 'Are you sure?'),
})
except Exception:
# Skip transitions that raise errors
pass
return transitions
__all__ = ["StateMachineMixin", "TRANSITION_METADATA"]

View File

@@ -16,7 +16,7 @@ import threading
from django.conf import settings
from django.db import models
from .callbacks import TransitionContext
from .callback_base import TransitionContext
logger = logging.getLogger(__name__)
@@ -431,7 +431,7 @@ def get_callback_execution_order(
Returns:
List of (stage, callback_name, priority) tuples in execution order.
"""
from .callbacks import callback_registry, CallbackStage
from .callback_base import callback_registry, CallbackStage
order = []

View File

@@ -307,7 +307,7 @@ def register_callback(
callback: The callback instance.
stage: When to execute ('pre', 'post', 'error').
"""
from .callbacks import callback_registry, CallbackStage
from .callback_base import callback_registry, CallbackStage
callback_registry.register(
model_class=model_class,

View File

@@ -11,7 +11,7 @@ import logging
from django.db import models
from django.dispatch import Signal, receiver
from .callbacks import TransitionContext
from .callback_base import TransitionContext
logger = logging.getLogger(__name__)

View File

@@ -452,7 +452,7 @@ class NotificationCallbackTests(TestCase):
instance=None,
):
"""Helper to create a TransitionContext."""
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from django.utils import timezone
if instance is None:
@@ -603,7 +603,7 @@ class CacheCallbackTests(TestCase):
target_state: str = 'CLOSED_TEMP',
):
"""Helper to create a TransitionContext."""
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from django.utils import timezone
instance = Mock()
@@ -716,7 +716,7 @@ class ModelCacheInvalidationTests(TestCase):
model_name: str = 'Ride',
instance_id: int = 789,
):
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from django.utils import timezone
instance = Mock()
@@ -783,7 +783,7 @@ class RelatedUpdateCallbackTests(TestCase):
instance=None,
target_state: str = 'OPERATING',
):
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from django.utils import timezone
if instance is None:
@@ -920,7 +920,7 @@ class CallbackErrorHandlingTests(TestCase):
"""Tests for callback error handling paths."""
def _create_transition_context(self):
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from django.utils import timezone
instance = Mock()
@@ -979,7 +979,7 @@ class CallbackErrorHandlingTests(TestCase):
def test_callback_with_none_user(self):
"""Test callbacks handle None user gracefully."""
from ..callbacks import TransitionContext
from ..callback_base import TransitionContext
from ..callbacks.notifications import NotificationCallback
from django.utils import timezone

View File

@@ -0,0 +1 @@
# Template tags for the core app

View File

@@ -0,0 +1,434 @@
"""
Template tags for FSM (Finite State Machine) operations.
This module provides template tags and filters for working with FSM-enabled
models in Django templates, including transition buttons, status displays,
and permission checks.
Usage:
{% load fsm_tags %}
{# Get available transitions for an object #}
{% get_available_transitions submission request.user as transitions %}
{# Check if a specific transition is allowed #}
{% can_transition submission 'approve' request.user as can_approve %}
{# Get the current state value #}
{{ submission|get_state_value }}
{# Get the current state display #}
{{ submission|get_state_display }}
{# Render a transition button #}
{% transition_button submission 'approve' request.user %}
"""
from typing import Any, Dict, List, Optional
from django import template
from django.urls import reverse, NoReverseMatch
from django_fsm import can_proceed
from apps.core.views.views import get_transition_metadata, TRANSITION_METADATA
register = template.Library()
# =============================================================================
# Filters for State Machine Properties
# =============================================================================
@register.filter
def get_state_value(obj) -> Optional[str]:
"""
Get the current state value of an FSM-enabled object.
Usage:
{{ object|get_state_value }}
Args:
obj: An FSM-enabled model instance
Returns:
The current state value or None
"""
if hasattr(obj, 'get_state_value'):
return obj.get_state_value()
if hasattr(obj, 'state_field_name'):
return getattr(obj, obj.state_field_name, None)
# Try common field names
for field in ['status', 'state']:
if hasattr(obj, field):
return getattr(obj, field, None)
return None
@register.filter
def get_state_display(obj) -> str:
"""
Get the display value for the current state.
Usage:
{{ object|get_state_display }}
Args:
obj: An FSM-enabled model instance
Returns:
The human-readable state display value
"""
if hasattr(obj, 'get_state_display_value'):
return obj.get_state_display_value()
if hasattr(obj, 'state_field_name'):
field_name = obj.state_field_name
getter = getattr(obj, f'get_{field_name}_display', None)
if callable(getter):
return getter()
# Try common field names
for field in ['status', 'state']:
getter = getattr(obj, f'get_{field}_display', None)
if callable(getter):
return getter()
return str(get_state_value(obj) or '')
@register.filter
def get_state_choice(obj):
"""
Get the RichChoice object for the current state.
Usage:
{% with choice=object|get_state_choice %}
{{ choice.metadata.icon }}
{% endwith %}
Args:
obj: An FSM-enabled model instance
Returns:
The RichChoice object or None
"""
if hasattr(obj, 'get_state_choice'):
return obj.get_state_choice()
return None
@register.filter
def app_label(obj) -> str:
"""
Get the app label of a model instance.
Usage:
{{ object|app_label }}
Args:
obj: A Django model instance
Returns:
The app label string
"""
return obj._meta.app_label
@register.filter
def model_name(obj) -> str:
"""
Get the model name (lowercase) of a model instance.
Usage:
{{ object|model_name }}
Args:
obj: A Django model instance
Returns:
The model name in lowercase
"""
return obj._meta.model_name
@register.filter
def default_target_id(obj) -> str:
"""
Get the default HTMX target ID for an object.
Usage:
{{ object|default_target_id }}
Args:
obj: A Django model instance
Returns:
The target ID string (e.g., "editsubmission-123")
"""
return f"{obj._meta.model_name}-{obj.pk}"
# =============================================================================
# Assignment Tags for Transition Operations
# =============================================================================
@register.simple_tag
def get_available_transitions(obj, user) -> List[Dict[str, Any]]:
"""
Get all available transitions for an object that the user can execute.
This tag checks each transition method on the object and returns metadata
for transitions that can proceed for the given user.
Usage:
{% get_available_transitions submission request.user as transitions %}
{% for transition in transitions %}
<button>{{ transition.label }}</button>
{% endfor %}
Args:
obj: An FSM-enabled model instance
user: The user to check permissions for
Returns:
List of transition metadata dictionaries with keys:
- name: The method name
- label: Human-readable label
- icon: Font Awesome icon name
- style: Button style (green, red, yellow, blue, gray)
- requires_confirm: Whether to show confirmation dialog
- confirm_message: Confirmation message to display
"""
transitions = []
if not obj or not user:
return transitions
# Get list of available transitions
available_transition_names = []
if hasattr(obj, 'get_available_user_transitions'):
# Use the helper method if available
return obj.get_available_user_transitions(user)
if hasattr(obj, 'get_available_transitions'):
available_transition_names = list(obj.get_available_transitions())
else:
# Fallback: look for transition methods by convention
for attr_name in dir(obj):
if attr_name.startswith('transition_to_') or attr_name in ['approve', 'reject', 'escalate', 'complete', 'cancel']:
method = getattr(obj, attr_name, None)
if callable(method) and hasattr(method, '_django_fsm'):
available_transition_names.append(attr_name)
# Filter transitions by user permission
for transition_name in available_transition_names:
method = getattr(obj, transition_name, None)
if method and callable(method):
try:
if can_proceed(method, user):
metadata = get_transition_metadata(transition_name)
transitions.append({
'name': transition_name,
'label': _format_transition_label(transition_name),
'icon': metadata.get('icon', 'arrow-right'),
'style': metadata.get('style', 'gray'),
'requires_confirm': metadata.get('requires_confirm', False),
'confirm_message': metadata.get('confirm_message', 'Are you sure?'),
})
except Exception:
# Skip transitions that raise errors during can_proceed check
pass
return transitions
@register.simple_tag
def can_transition(obj, transition_name: str, user) -> bool:
"""
Check if a specific transition can be executed by the user.
Usage:
{% can_transition submission 'approve' request.user as can_approve %}
{% if can_approve %}
<button>Approve</button>
{% endif %}
Args:
obj: An FSM-enabled model instance
transition_name: The name of the transition method
user: The user to check permissions for
Returns:
True if the transition can proceed, False otherwise
"""
if not obj or not user or not transition_name:
return False
method = getattr(obj, transition_name, None)
if not method or not callable(method):
return False
try:
return can_proceed(method, user)
except Exception:
return False
@register.simple_tag
def get_transition_url(obj, transition_name: str) -> str:
"""
Get the URL for executing a transition on an object.
Usage:
{% get_transition_url submission 'approve' as approve_url %}
Args:
obj: An FSM-enabled model instance
transition_name: The name of the transition method
Returns:
The URL string for the transition endpoint
"""
try:
return reverse('core:fsm_transition', kwargs={
'app_label': obj._meta.app_label,
'model_name': obj._meta.model_name,
'pk': obj.pk,
'transition_name': transition_name,
})
except NoReverseMatch:
return ''
# =============================================================================
# Inclusion Tags for Rendering Components
# =============================================================================
@register.inclusion_tag('htmx/state_actions.html', takes_context=True)
def render_state_actions(context, obj, user=None, **kwargs):
"""
Render the state action buttons for an FSM-enabled object.
Usage:
{% render_state_actions submission request.user %}
{% render_state_actions submission request.user button_size='sm' %}
Args:
context: Template context
obj: An FSM-enabled model instance
user: The user to check permissions for (defaults to request.user)
**kwargs: Additional template context variables
Returns:
Context for the state_actions.html template
"""
if user is None:
user = context.get('request', {}).user if 'request' in context else None
return {
'object': obj,
'user': user,
'request': context.get('request'),
**kwargs,
}
@register.inclusion_tag('htmx/status_with_actions.html', takes_context=True)
def render_status_with_actions(context, obj, user=None, **kwargs):
"""
Render the status badge with action buttons for an FSM-enabled object.
Usage:
{% render_status_with_actions submission request.user %}
{% render_status_with_actions submission request.user dropdown_actions=True %}
Args:
context: Template context
obj: An FSM-enabled model instance
user: The user to check permissions for (defaults to request.user)
**kwargs: Additional template context variables
Returns:
Context for the status_with_actions.html template
"""
if user is None:
user = context.get('request', {}).user if 'request' in context else None
return {
'object': obj,
'user': user,
'request': context.get('request'),
**kwargs,
}
# =============================================================================
# Helper Functions
# =============================================================================
def _format_transition_label(transition_name: str) -> str:
"""
Format a transition method name into a human-readable label.
Examples:
'transition_to_approved' -> 'Approve'
'approve' -> 'Approve'
'reject_submission' -> 'Reject Submission'
Args:
transition_name: The transition method name
Returns:
Human-readable label
"""
# Remove common prefixes
label = transition_name
for prefix in ['transition_to_', 'transition_', 'do_']:
if label.startswith(prefix):
label = label[len(prefix):]
break
# Remove past tense suffix and capitalize
# e.g., 'approved' -> 'Approve'
if label.endswith('ed') and len(label) > 3:
# Handle special cases
if label.endswith('ied'):
label = label[:-3] + 'y'
elif label[-3] == label[-4]: # doubled consonant (e.g., 'submitted')
label = label[:-3]
else:
label = label[:-1] # Remove 'd'
if label.endswith('e'):
pass # Keep the 'e' for words like 'approve'
else:
label = label[:-1] # Remove 'e' for words like 'rejected' -> 'reject'
# Replace underscores with spaces and title case
label = label.replace('_', ' ').title()
return label
# =============================================================================
# Registration
# =============================================================================
# Ensure all tags and filters are registered
__all__ = [
# Filters
'get_state_value',
'get_state_display',
'get_state_choice',
'app_label',
'model_name',
'default_target_id',
# Tags
'get_available_transitions',
'can_transition',
'get_transition_url',
# Inclusion tags
'render_state_actions',
'render_status_with_actions',
]

View File

@@ -1,26 +0,0 @@
"""
Core app URL configuration.
"""
from django.urls import path, include
from .views.entity_search import (
EntityFuzzySearchView,
EntityNotFoundView,
QuickEntitySuggestionView,
)
app_name = "core"
# Entity search endpoints
entity_patterns = [
path("search/", EntityFuzzySearchView.as_view(), name="entity_fuzzy_search"),
path("not-found/", EntityNotFoundView.as_view(), name="entity_not_found"),
path(
"suggestions/", QuickEntitySuggestionView.as_view(), name="entity_suggestions"
),
]
urlpatterns = [
# Entity fuzzy matching and search endpoints
path("entities/", include(entity_patterns)),
]

View File

@@ -1 +1,47 @@
# URLs package for core app
"""
Core app URL configuration.
"""
from django.urls import path, include
from ..views.entity_search import (
EntityFuzzySearchView,
EntityNotFoundView,
QuickEntitySuggestionView,
)
from ..views.views import FSMTransitionView
app_name = "core"
# Entity search endpoints
entity_patterns = [
path("search/", EntityFuzzySearchView.as_view(), name="entity_fuzzy_search"),
path("not-found/", EntityNotFoundView.as_view(), name="entity_not_found"),
path(
"suggestions/", QuickEntitySuggestionView.as_view(), name="entity_suggestions"
),
]
# FSM transition endpoints
fsm_patterns = [
# Generic FSM transition endpoint
# URL: /core/fsm/<app_label>/<model_name>/<pk>/transition/<transition_name>/
path(
"<str:app_label>/<str:model_name>/<int:pk>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
name="fsm_transition",
),
# Slug-based FSM transition endpoint for models that use slugs
# URL: /core/fsm/<app_label>/<model_name>/by-slug/<slug>/transition/<transition_name>/
path(
"<str:app_label>/<str:model_name>/by-slug/<slug:slug>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
name="fsm_transition_by_slug",
),
]
urlpatterns = [
# Entity fuzzy matching and search endpoints
path("entities/", include(entity_patterns)),
# FSM transition endpoints
path("fsm/", include(fsm_patterns)),
]

View File

@@ -1,14 +1,30 @@
"""
Core views for the application.
"""
import json
import logging
from typing import Any, Dict, Optional, Type
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Model
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, render
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import redirect, render, get_object_or_404
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_protect
from django.views.generic import DetailView, TemplateView
from django_fsm import can_proceed, TransitionNotAllowed
from apps.core.state_machine.exceptions import (
TransitionPermissionDenied,
TransitionValidationError,
TransitionNotAvailable,
format_transition_error,
)
logger = logging.getLogger(__name__)
class SlugRedirectMixin(View):
@@ -91,3 +107,409 @@ class GlobalSearchView(TemplateView):
)
return render(request, self.template_name, context)
# =============================================================================
# FSM Transition View Infrastructure
# =============================================================================
# Default transition metadata for styling
TRANSITION_METADATA = {
# Approval transitions
"approve": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
"transition_to_approved": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
# Rejection transitions
"reject": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
"transition_to_rejected": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
# Escalation transitions
"escalate": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
"transition_to_escalated": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
# Assignment transitions
"assign": {"style": "blue", "icon": "user-plus", "requires_confirm": False},
"unassign": {"style": "gray", "icon": "user-minus", "requires_confirm": False},
# Status transitions
"start": {"style": "blue", "icon": "play", "requires_confirm": False},
"complete": {"style": "green", "icon": "check-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to complete this?"},
"cancel": {"style": "red", "icon": "ban", "requires_confirm": True, "confirm_message": "Are you sure you want to cancel this?"},
"reopen": {"style": "blue", "icon": "redo", "requires_confirm": False},
# Resolution transitions
"resolve": {"style": "green", "icon": "check-double", "requires_confirm": True, "confirm_message": "Are you sure you want to resolve this?"},
"dismiss": {"style": "gray", "icon": "times-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to dismiss this?"},
# Default
"default": {"style": "gray", "icon": "arrow-right", "requires_confirm": False},
}
def get_transition_metadata(transition_name: str) -> Dict[str, Any]:
"""Get metadata for a transition by name."""
# Check for exact match first
if transition_name in TRANSITION_METADATA:
return TRANSITION_METADATA[transition_name].copy()
# Check for partial match (e.g., "transition_to_approved" contains "approve")
for key, metadata in TRANSITION_METADATA.items():
if key in transition_name.lower() or transition_name.lower() in key:
return metadata.copy()
return TRANSITION_METADATA["default"].copy()
def add_toast_trigger(response: HttpResponse, message: str, toast_type: str = "success") -> HttpResponse:
"""
Add HX-Trigger header to trigger Alpine.js toast.
Args:
response: The HTTP response to modify
message: Toast message to display
toast_type: Type of toast ('success', 'error', 'warning', 'info')
Returns:
Modified response with HX-Trigger header
"""
trigger_data = {
"showToast": {
"message": message,
"type": toast_type
}
}
response["HX-Trigger"] = json.dumps(trigger_data)
return response
@method_decorator(csrf_protect, name='dispatch')
class FSMTransitionView(View):
"""
Generic view for handling FSM state transitions via HTMX.
This view handles POST requests to execute FSM transitions on any model
that uses django-fsm. It validates permissions, executes the transition,
and returns either an updated HTML partial (for HTMX) or JSON response.
URL pattern should provide:
- app_label: The app containing the model
- model_name: The model name (lowercase)
- pk: The primary key of the object
- transition_name: The name of the transition method to execute
Example URL patterns:
path('fsm/<str:app_label>/<str:model_name>/<int:pk>/transition/<str:transition_name>/',
FSMTransitionView.as_view(), name='fsm_transition')
"""
# Override these in subclasses or pass via URL kwargs
partial_template = None # Template to render after successful transition
def get_model_class(self, app_label: str, model_name: str) -> Optional[Type[Model]]:
"""
Get the model class from app_label and model_name.
Args:
app_label: The Django app label (e.g., 'moderation')
model_name: The model name in lowercase (e.g., 'editsubmission')
Returns:
The model class or None if not found
"""
try:
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
return content_type.model_class()
except ContentType.DoesNotExist:
return None
def get_object(self, model_class: Type[Model], pk: Any, slug: Optional[str] = None) -> Model:
"""
Get the model instance.
Args:
model_class: The model class
pk: Primary key of the object (can be int or slug)
slug: Optional slug if using slug-based lookup
Returns:
The model instance
Raises:
Http404: If object not found
"""
if slug:
return get_object_or_404(model_class, slug=slug)
return get_object_or_404(model_class, pk=pk)
def get_transition_method(self, obj: Model, transition_name: str):
"""
Get the transition method from the object.
Args:
obj: The model instance
transition_name: The name of the transition method
Returns:
The transition method or None
"""
return getattr(obj, transition_name, None)
def validate_transition(self, obj: Model, transition_name: str, user) -> tuple[bool, Optional[str]]:
"""
Validate that the transition can proceed.
Args:
obj: The model instance
transition_name: The name of the transition method
user: The user attempting the transition
Returns:
Tuple of (can_proceed, error_message)
"""
method = self.get_transition_method(obj, transition_name)
if method is None:
return False, f"Transition '{transition_name}' not found on {obj.__class__.__name__}"
if not callable(method):
return False, f"'{transition_name}' is not a callable method"
# Check if the transition can proceed
if not can_proceed(method, user):
return False, f"Transition '{transition_name}' is not allowed from current state"
return True, None
def execute_transition(self, obj: Model, transition_name: str, user, **kwargs) -> None:
"""
Execute the transition on the object.
Args:
obj: The model instance
transition_name: The name of the transition method
user: The user performing the transition
**kwargs: Additional arguments to pass to the transition method
Raises:
TransitionNotAllowed: If transition fails
"""
method = self.get_transition_method(obj, transition_name)
# Execute the transition with user parameter
method(user=user, **kwargs)
obj.save()
def get_success_message(self, obj: Model, transition_name: str) -> str:
"""Generate a success message for the transition."""
# Clean up transition name for display
display_name = transition_name.replace("transition_to_", "").replace("_", " ").title()
model_name = obj._meta.verbose_name.title()
return f"{model_name} has been {display_name.lower()}d successfully."
def get_error_message(self, error: Exception) -> str:
"""Generate an error message from an exception."""
if hasattr(error, "user_message"):
return error.user_message
return str(error) or "An error occurred during the transition."
def get_partial_template(self, obj: Model, request: HttpRequest) -> Optional[str]:
"""
Get the template to render after a successful transition.
Override this method to return a custom template based on the object.
Uses Django's template loader to find model-specific templates.
"""
if self.partial_template:
return self.partial_template
app_label = obj._meta.app_label
model_name = obj._meta.model_name
# Special handling for parks and rides - return status section
if app_label == 'parks' and model_name == 'park':
return "parks/partials/park_status_actions.html"
elif app_label == 'rides' and model_name == 'ride':
return "rides/partials/ride_status_actions.html"
# Check for model-specific templates in order of preference
possible_templates = [
f"{app_label}/partials/{model_name}_row.html",
f"{app_label}/partials/{model_name}_item.html",
f"{app_label}/partials/{model_name}.html",
"htmx/updated_row.html",
]
# Use template loader to check if template exists
from django.template.loader import select_template
from django.template import TemplateDoesNotExist
try:
template = select_template(possible_templates)
return template.template.name
except TemplateDoesNotExist:
return "htmx/updated_row.html"
def format_success_response(
self,
request: HttpRequest,
obj: Model,
transition_name: str
) -> HttpResponse:
"""
Format a successful transition response.
For HTMX requests: renders the partial template with toast trigger
For regular requests: returns JSON response
"""
message = self.get_success_message(obj, transition_name)
if request.headers.get("HX-Request"):
# HTMX request - render partial and add toast trigger
template = self.get_partial_template(obj, request)
if template:
# Build context with object and model-specific variable names
context = {
"object": obj,
"user": request.user,
"transition_success": True,
"success_message": message,
}
# Add model-specific variable (e.g., 'park' or 'ride') for template compatibility
model_name = obj._meta.model_name
context[model_name] = obj
response = render(request, template, context)
else:
# No template - return empty response with OOB swap for status
response = HttpResponse("")
return add_toast_trigger(response, message, "success")
# Regular request - return JSON
return JsonResponse({
"success": True,
"message": message,
"new_state": getattr(obj, obj.state_field_name, None) if hasattr(obj, "state_field_name") else None,
})
def format_error_response(
self,
request: HttpRequest,
error: Exception,
status_code: int = 400
) -> HttpResponse:
"""
Format an error response.
For HTMX requests: returns error with toast trigger
For regular requests: returns JSON response
"""
message = self.get_error_message(error)
error_data = format_transition_error(error)
if request.headers.get("HX-Request"):
# HTMX request - return error response with toast trigger
response = HttpResponse(status=status_code)
return add_toast_trigger(response, message, "error")
# Regular request - return JSON
return JsonResponse({
"success": False,
"error": error_data,
}, status=status_code)
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Handle POST request to execute a transition."""
app_label = kwargs.get("app_label")
model_name = kwargs.get("model_name")
pk = kwargs.get("pk")
slug = kwargs.get("slug")
transition_name = kwargs.get("transition_name")
# Validate required parameters
if not all([app_label, model_name, transition_name]):
return self.format_error_response(
request,
ValueError("Missing required parameters: app_label, model_name, and transition_name"),
400
)
if not pk and not slug:
return self.format_error_response(
request,
ValueError("Missing required parameter: pk or slug"),
400
)
# Get the model class
model_class = self.get_model_class(app_label, model_name)
if model_class is None:
return self.format_error_response(
request,
ValueError(f"Model '{app_label}.{model_name}' not found"),
404
)
# Get the object
try:
obj = self.get_object(model_class, pk, slug)
except ObjectDoesNotExist:
return self.format_error_response(
request,
ValueError(f"Object not found: {model_name} with pk={pk}"),
404
)
# Validate the transition
can_execute, error_msg = self.validate_transition(obj, transition_name, request.user)
if not can_execute:
return self.format_error_response(
request,
TransitionNotAvailable(
message=error_msg,
user_message=error_msg,
current_state=getattr(obj, "status", None),
requested_transition=transition_name,
),
400
)
# Execute the transition
try:
# Get any additional kwargs from POST data
extra_kwargs = {}
if request.POST.get("notes"):
extra_kwargs["notes"] = request.POST.get("notes")
if request.POST.get("reason"):
extra_kwargs["reason"] = request.POST.get("reason")
self.execute_transition(obj, transition_name, request.user, **extra_kwargs)
logger.info(
f"Transition '{transition_name}' executed on {model_class.__name__}(pk={obj.pk}) by user {request.user}"
)
return self.format_success_response(request, obj, transition_name)
except TransitionPermissionDenied as e:
logger.warning(
f"Permission denied for transition '{transition_name}' on {model_class.__name__}(pk={obj.pk}) by user {request.user}: {e}"
)
return self.format_error_response(request, e, 403)
except TransitionValidationError as e:
logger.warning(
f"Validation error for transition '{transition_name}' on {model_class.__name__}(pk={obj.pk}): {e}"
)
return self.format_error_response(request, e, 400)
except TransitionNotAllowed as e:
logger.warning(
f"Transition not allowed: '{transition_name}' on {model_class.__name__}(pk={obj.pk}): {e}"
)
return self.format_error_response(request, e, 400)
except Exception as e:
logger.exception(
f"Unexpected error during transition '{transition_name}' on {model_class.__name__}(pk={obj.pk})"
)
return self.format_error_response(
request,
ValueError(f"An unexpected error occurred: {str(e)}"),
500
)