mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 23:11:08 -05:00
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:
37
backend/apps/core/context_processors.py
Normal file
37
backend/apps/core/context_processors.py
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,7 +22,7 @@ from .registry import (
|
||||
register_transition_callbacks,
|
||||
discover_and_register_callbacks,
|
||||
)
|
||||
from .callbacks import (
|
||||
from .callback_base import (
|
||||
BaseTransitionCallback,
|
||||
PreTransitionCallback,
|
||||
PostTransitionCallback,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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__)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
1
backend/apps/core/templatetags/__init__.py
Normal file
1
backend/apps/core/templatetags/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Template tags for the core app
|
||||
434
backend/apps/core/templatetags/fsm_tags.py
Normal file
434
backend/apps/core/templatetags/fsm_tags.py
Normal 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',
|
||||
]
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user