Add state machine diagrams and code examples for ThrillWiki

- Created a comprehensive documentation file for state machine diagrams, detailing various states and transitions for models such as EditSubmission, ModerationReport, and Park Status.
- Included transition matrices for each state machine to clarify role requirements and guards.
- Developed a new document providing code examples for implementing state machines, including adding new state machines to models, defining custom guards, implementing callbacks, and testing state machines.
- Added examples for document approval workflows, custom guards, email notifications, and cache invalidation callbacks.
- Implemented a test suite for document workflows, covering various scenarios including approval, rejection, and transition logging.
This commit is contained in:
pacnpal
2025-12-21 20:21:54 -05:00
parent 8f6acbdc23
commit b508434574
24 changed files with 9979 additions and 360 deletions

View File

@@ -1,4 +1,65 @@
"""StateTransitionBuilder - Reads RichChoice metadata and generates FSM configurations."""
"""
StateTransitionBuilder - Reads RichChoice metadata and generates FSM configurations.
This module provides utilities for building FSM transition configurations
from rich choice metadata, enabling declarative state machine definitions
where transitions are defined in the choice registry rather than code.
Key Features:
- Extract valid transitions from choice metadata
- Build complete transition graphs
- Extract permission requirements for transitions
- Identify terminal and actionable states
- Generate consistent transition method names
Example Usage:
Create a builder for a choice group::
from apps.core.state_machine.builder import StateTransitionBuilder
builder = StateTransitionBuilder(
choice_group='submission_status',
domain='moderation'
)
# Get valid transitions from a state
targets = builder.extract_valid_transitions('PENDING')
# Returns: ['APPROVED', 'REJECTED', 'ESCALATED']
# Build complete transition graph
graph = builder.build_transition_graph()
# Returns: {
# 'PENDING': ['APPROVED', 'REJECTED', 'ESCALATED'],
# 'ESCALATED': ['APPROVED', 'REJECTED'],
# 'APPROVED': [], # Terminal state
# 'REJECTED': [], # Terminal state
# }
# Check state properties
builder.is_terminal_state('APPROVED') # True
builder.is_actionable_state('PENDING') # True
Generate transition method names::
from apps.core.state_machine.builder import determine_method_name_for_transition
method = determine_method_name_for_transition('PENDING', 'APPROVED')
# Returns: 'transition_to_approved'
Rich Choice Metadata Keys:
The builder reads these metadata keys from RichChoice definitions:
- can_transition_to (List[str]): Valid target states from this state
- is_final (bool): Whether this is a terminal state
- is_actionable (bool): Whether this state requires action
- requires_moderator (bool): Whether moderator role is required
- requires_admin_approval (bool): Whether admin role is required
See Also:
- apps.core.choices.base.RichChoice: Choice definition with metadata
- apps.core.choices.registry: Central choice registry
- apps.core.state_machine.guards: Guard extraction from metadata
"""
from typing import Dict, List, Optional, Any
from django.core.exceptions import ImproperlyConfigured
@@ -7,7 +68,47 @@ from apps.core.choices.base import RichChoice
class StateTransitionBuilder:
"""Reads RichChoice metadata and generates FSM transition configurations."""
"""
Reads RichChoice metadata and generates FSM transition configurations.
This class provides a bridge between the rich choice registry and FSM
configuration, extracting transition rules, permissions, and state
properties from choice metadata.
Attributes:
choice_group (str): Name of the choice group in the registry
domain (str): Domain namespace for the choice group
choices: List of RichChoice objects for this group
Example:
Basic usage::
builder = StateTransitionBuilder('ride_status', domain='core')
# Get all states
states = builder.get_all_states()
# ['OPERATING', 'CLOSED_TEMP', 'SBNO', 'CLOSED_PERM', ...]
# Get metadata for a state
metadata = builder.get_choice_metadata('SBNO')
# {'can_transition_to': ['OPERATING', 'CLOSED_PERM'], ...}
# Check state properties
builder.is_terminal_state('DEMOLISHED') # True
builder.is_terminal_state('SBNO') # False
Building transition decorators programmatically::
builder = StateTransitionBuilder('park_status')
graph = builder.build_transition_graph()
for source_state, targets in graph.items():
for target in targets:
method_name = determine_method_name_for_transition(
source_state, target
)
# Create transition method dynamically...
"""
def __init__(self, choice_group: str, domain: str = "core"):
"""

View File

@@ -2,7 +2,68 @@
Callback system infrastructure for FSM state transitions.
This module provides the core classes and registry for managing callbacks
that execute during state machine transitions.
that execute during state machine transitions. Callbacks enable side effects
like notifications, cache invalidation, and related model updates.
Key Components:
- CallbackStage: Enum defining when callbacks execute (pre/post/error)
- TransitionContext: Data class with all transition information
- BaseTransitionCallback: Abstract base class for all callbacks
- TransitionCallbackRegistry: Singleton registry for callback management
Callback Lifecycle:
1. PRE callbacks execute before the state change (can abort transition)
2. State transition occurs
3. POST callbacks execute after successful transition
4. ERROR callbacks execute if transition fails
Example Usage:
Define a custom callback::
from apps.core.state_machine.callbacks import (
PostTransitionCallback,
TransitionContext,
register_post_callback
)
class AuditLogCallback(PostTransitionCallback):
name = "AuditLogCallback"
def execute(self, context: TransitionContext) -> bool:
log_entry = AuditLog.objects.create(
model_name=context.model_name,
object_id=context.instance.pk,
from_state=context.source_state,
to_state=context.target_state,
user=context.user,
timestamp=context.timestamp,
)
return True
# Register the callback
register_post_callback(
model_class=EditSubmission,
field_name='status',
source='*', # Any source state
target='APPROVED', # Only for approvals
callback=AuditLogCallback()
)
Conditional callback execution::
class HighPriorityNotification(PostTransitionCallback):
def should_execute(self, context: TransitionContext) -> bool:
# Only execute for high priority items
return getattr(context.instance, 'priority', None) == 'HIGH'
def execute(self, context: TransitionContext) -> bool:
# Send high priority notification
...
See Also:
- apps.core.state_machine.callbacks.notifications: Notification callbacks
- apps.core.state_machine.callbacks.cache: Cache invalidation callbacks
- apps.core.state_machine.callbacks.related_updates: Related model callbacks
"""
from abc import ABC, abstractmethod
@@ -19,7 +80,42 @@ logger = logging.getLogger(__name__)
class CallbackStage(Enum):
"""Stages at which callbacks can be executed during a transition."""
"""
Stages at which callbacks can be executed during a transition.
Attributes:
PRE: Execute before the state change. Can prevent transition by returning False.
POST: Execute after successful state change. Cannot prevent transition.
ERROR: Execute when transition fails. Used for cleanup and error logging.
Example:
Register callbacks at different stages::
from apps.core.state_machine.callbacks import (
CallbackStage,
callback_registry
)
# PRE callback - validate before transition
callback_registry.register(
model_class=Ride,
field_name='status',
source='OPERATING',
target='SBNO',
callback=ValidationCallback(),
stage=CallbackStage.PRE
)
# POST callback - notify after transition
callback_registry.register(
model_class=Ride,
field_name='status',
source='*',
target='CLOSED_PERM',
callback=NotifyCallback(),
stage=CallbackStage.POST
)
"""
PRE = "pre"
POST = "post"
@@ -31,7 +127,40 @@ class TransitionContext:
"""
Context object passed to callbacks containing transition metadata.
Provides all relevant information about the transition being executed.
Provides all relevant information about the transition being executed,
including the model instance, state values, user, and timing.
Attributes:
instance: The model instance undergoing the transition
field_name: Name of the FSM field (e.g., 'status')
source_state: The state before transition (e.g., 'PENDING')
target_state: The state after transition (e.g., 'APPROVED')
user: The user performing the transition (may be None)
timestamp: When the transition occurred
extra_data: Additional data passed to the transition
Properties:
model_class: The model class of the instance
model_name: String name of the model class
Example:
Access context in a callback::
def execute(self, context: TransitionContext) -> bool:
# Access transition details
print(f"Transitioning {context.model_name} #{context.instance.pk}")
print(f"From: {context.source_state} -> To: {context.target_state}")
print(f"By user: {context.user}")
# Access the instance
if hasattr(context.instance, 'notes'):
context.instance.notes = "Processed by callback"
# Use extra data
if context.extra_data.get('urgent'):
self.send_urgent_notification(context)
return True
"""
instance: models.Model

View File

@@ -532,6 +532,35 @@ class ModerationNotificationCallback(NotificationCallback):
return recipient
return None
def _get_notification_title(self, context: TransitionContext, notification_type: str) -> str:
"""Get the notification title based on notification type."""
titles = {
'report_under_review': 'Your report is under review',
'report_resolved': 'Your report has been resolved',
'queue_in_progress': 'Moderation queue item in progress',
'queue_completed': 'Moderation queue item completed',
'bulk_operation_started': 'Bulk operation started',
'bulk_operation_completed': 'Bulk operation completed',
'bulk_operation_failed': 'Bulk operation failed',
}
return titles.get(notification_type, f"{context.model_name} status updated")
def _get_notification_message(self, context: TransitionContext, notification_type: str) -> str:
"""Get the notification message based on notification type."""
messages = {
'report_under_review': 'Your moderation report is now being reviewed by our team.',
'report_resolved': 'Your moderation report has been reviewed and resolved.',
'queue_in_progress': 'A moderation queue item is now being processed.',
'queue_completed': 'A moderation queue item has been completed.',
'bulk_operation_started': 'Your bulk operation has started processing.',
'bulk_operation_completed': 'Your bulk operation has completed successfully.',
'bulk_operation_failed': 'Your bulk operation encountered an error and could not complete.',
}
return messages.get(
notification_type,
f"The {context.model_name} has been updated to {context.target_state}."
)
def execute(self, context: TransitionContext) -> bool:
"""Execute the moderation notification."""
notification_service = self._get_notification_service()
@@ -556,6 +585,8 @@ class ModerationNotificationCallback(NotificationCallback):
notification_service.create_notification(
user=recipient,
notification_type=notification_type,
title=self._get_notification_title(context, notification_type),
message=self._get_notification_message(context, notification_type),
related_object=context.instance,
extra_data=extra_data,
)

View File

@@ -115,6 +115,9 @@ class ParkCountUpdateCallback(RelatedModelUpdateCallback):
return source_affects or target_affects
# Category value for roller coasters (from rides domain choices)
COASTER_CATEGORY = 'RC'
def perform_update(self, context: TransitionContext) -> bool:
"""Update park ride counts."""
instance = context.instance
@@ -142,10 +145,10 @@ class ParkCountUpdateCallback(RelatedModelUpdateCallback):
status__in=active_statuses
).count()
# Count active coasters
# Count active coasters (category='RC' for Roller Coaster)
coaster_count = ride_queryset.filter(
status__in=active_statuses,
ride_type='ROLLER_COASTER'
category=self.COASTER_CATEGORY
).count()
# Update park counts

View File

@@ -1,4 +1,52 @@
"""State machine fields with rich choice integration."""
"""
State machine fields with rich choice integration.
This module provides FSM field implementations that integrate with the rich
choice registry, enabling metadata-driven state machine definitions.
Key Features:
- Automatic choice population from registry
- Deprecated state handling
- Rich choice metadata access on model instances
- Migration support for custom field attributes
Example Usage:
Define a model with a RichFSMField::
from django.db import models
from apps.core.state_machine.fields import RichFSMField
class EditSubmission(models.Model):
status = RichFSMField(
choice_group='submission_status',
domain='moderation',
default='PENDING'
)
Access rich choice metadata on instances::
submission = EditSubmission.objects.first()
rich_choice = submission.get_status_rich_choice()
print(rich_choice.metadata) # {'is_actionable': True, ...}
print(submission.get_status_display()) # "Pending Review"
Define FSM transitions using django-fsm decorators::
from django_fsm import transition
class EditSubmission(models.Model):
status = RichFSMField(...)
@transition(field=status, source='PENDING', target='APPROVED')
def transition_to_approved(self, user=None):
self.handled_by = user
self.handled_at = timezone.now()
See Also:
- apps.core.choices.base.RichChoice: The choice object with metadata
- apps.core.choices.registry: The central choice registry
- apps.core.state_machine.mixins.StateMachineMixin: Convenience helpers
"""
from typing import Any, Optional
from django.core.exceptions import ValidationError
@@ -9,7 +57,54 @@ from apps.core.choices.registry import registry
class RichFSMField(DjangoFSMField):
"""FSMField that uses the rich choice registry for states."""
"""
FSMField that uses the rich choice registry for states.
This field extends django-fsm's FSMField to integrate with the rich choice
registry system, providing metadata-driven state machine definitions with
automatic choice population and validation.
The field automatically:
- Populates choices from the registry based on choice_group and domain
- Validates state values against the registry
- Handles deprecated states appropriately
- Adds convenience methods to the model class for accessing rich choice data
Attributes:
choice_group (str): Name of the choice group in the registry
domain (str): Domain namespace for the choice group (default: "core")
allow_deprecated (bool): Whether to allow deprecated states (default: False)
Auto-generated Model Methods:
- get_{field_name}_rich_choice(): Returns the RichChoice object for current state
- get_{field_name}_display(): Returns the human-readable label
Example:
Basic field definition::
class Ride(models.Model):
status = RichFSMField(
choice_group='ride_status',
domain='core',
default='OPERATING',
max_length=30
)
Using auto-generated methods::
ride = Ride.objects.get(pk=1)
ride.status # 'OPERATING'
ride.get_status_display() # 'Operating'
ride.get_status_rich_choice() # RichChoice(value='OPERATING', ...)
ride.get_status_rich_choice().metadata # {'icon': 'check', ...}
With deprecated states (for historical data)::
status = RichFSMField(
choice_group='legacy_status',
allow_deprecated=True # Include deprecated choices
)
"""
def __init__(
self,

View File

@@ -1,4 +1,43 @@
"""Base mixins for django-fsm state machines."""
"""
Base mixins for django-fsm state machines.
This module provides abstract model mixins that add convenience methods for
working with FSM-enabled models, including state inspection, transition
checking, and display helpers.
Key Features:
- State value and display access
- Transition availability checking
- Rich choice metadata access
- Consistent interface across all FSM models
Example Usage:
Add the mixin to your FSM model::
from django.db import models
from apps.core.state_machine.mixins import StateMachineMixin
from apps.core.state_machine.fields import RichFSMField
class Park(StateMachineMixin, models.Model):
state_field_name = 'status' # Specify your FSM field name
status = RichFSMField(
choice_group='park_status',
default='OPERATING'
)
Use the convenience methods::
park = Park.objects.first()
park.get_state_value() # 'OPERATING'
park.get_state_display_value() # 'Operating'
park.is_in_state('OPERATING') # True
park.can_transition('transition_to_closed_temp') # True
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 django.db import models
@@ -6,7 +45,56 @@ from django_fsm import can_proceed
class StateMachineMixin(models.Model):
"""Common helpers for models that use django-fsm."""
"""
Common helpers for models that use django-fsm.
This abstract model mixin provides a consistent interface for working with
FSM-enabled models, including methods for state inspection, transition
checking, and display formatting.
Class Attributes:
state_field_name (str): The name of the FSM field on the model.
Override this in subclasses if your field is not named 'state'.
Default: 'state'
Example:
Basic usage with custom field name::
class Ride(StateMachineMixin, models.Model):
state_field_name = 'status'
status = RichFSMField(...)
@transition(field=status, source='OPERATING', target='SBNO')
def transition_to_sbno(self, user=None):
pass
ride = Ride.objects.first()
# State inspection
ride.get_state_value() # 'OPERATING'
ride.is_in_state('OPERATING') # True
ride.is_in_state('SBNO') # False
# Transition checking
ride.can_transition('transition_to_sbno') # True
# Display formatting
ride.get_state_display_value() # 'Operating'
# Rich choice access (when using RichFSMField)
choice = ride.get_state_choice()
choice.metadata # {'icon': 'check', ...}
Multiple FSM fields::
class ComplexModel(StateMachineMixin, models.Model):
status = RichFSMField(...)
approval_status = RichFSMField(...)
# Access non-default field
model.get_state_value('approval_status')
"""
state_field_name: str = "state"

View File

@@ -1 +1,8 @@
"""Test package initialization."""
"""
State machine test package.
This package contains comprehensive tests for the state machine system including:
- Guard tests (test_guards.py)
- Callback tests (test_callbacks.py)
- Test fixtures and helpers (fixtures.py, helpers.py)
"""

View File

@@ -0,0 +1,372 @@
"""
Test fixtures for state machine tests.
This module provides reusable fixtures for creating test data:
- User factories for different roles
- Model instance factories for moderation, parks, rides
- Mock objects for testing guards and callbacks
"""
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from typing import Optional, Any, Dict
User = get_user_model()
class UserFactory:
"""Factory for creating users with different roles."""
_counter = 0
@classmethod
def _get_unique_id(cls) -> int:
"""Get a unique counter for creating unique usernames."""
cls._counter += 1
return cls._counter
@classmethod
def create_user(
cls,
role: str = 'USER',
username: Optional[str] = None,
email: Optional[str] = None,
password: str = 'testpass123',
**kwargs
) -> User:
"""
Create a user with specified role.
Args:
role: User role (USER, MODERATOR, ADMIN, SUPERUSER)
username: Username (auto-generated if not provided)
email: Email (auto-generated if not provided)
password: Password for the user
**kwargs: Additional user fields
Returns:
Created User instance
"""
uid = cls._get_unique_id()
if username is None:
username = f"user_{role.lower()}_{uid}"
if email is None:
email = f"{role.lower()}_{uid}@example.com"
return User.objects.create_user(
username=username,
email=email,
password=password,
role=role,
**kwargs
)
@classmethod
def create_regular_user(cls, **kwargs) -> User:
"""Create a regular user."""
return cls.create_user(role='USER', **kwargs)
@classmethod
def create_moderator(cls, **kwargs) -> User:
"""Create a moderator user."""
return cls.create_user(role='MODERATOR', **kwargs)
@classmethod
def create_admin(cls, **kwargs) -> User:
"""Create an admin user."""
return cls.create_user(role='ADMIN', **kwargs)
@classmethod
def create_superuser(cls, **kwargs) -> User:
"""Create a superuser."""
return cls.create_user(role='SUPERUSER', **kwargs)
class CompanyFactory:
"""Factory for creating company instances."""
_counter = 0
@classmethod
def _get_unique_id(cls) -> int:
cls._counter += 1
return cls._counter
@classmethod
def create_operator(cls, name: Optional[str] = None, **kwargs) -> Any:
"""Create an operator company."""
from apps.parks.models import Company
uid = cls._get_unique_id()
if name is None:
name = f"Test Operator {uid}"
defaults = {
'name': name,
'description': f'Test operator company {uid}',
'roles': ['OPERATOR']
}
defaults.update(kwargs)
return Company.objects.create(**defaults)
@classmethod
def create_manufacturer(cls, name: Optional[str] = None, **kwargs) -> Any:
"""Create a manufacturer company."""
from apps.rides.models import Company
uid = cls._get_unique_id()
if name is None:
name = f"Test Manufacturer {uid}"
defaults = {
'name': name,
'description': f'Test manufacturer company {uid}',
'roles': ['MANUFACTURER']
}
defaults.update(kwargs)
return Company.objects.create(**defaults)
class ParkFactory:
"""Factory for creating park instances."""
_counter = 0
@classmethod
def _get_unique_id(cls) -> int:
cls._counter += 1
return cls._counter
@classmethod
def create_park(
cls,
name: Optional[str] = None,
operator: Optional[Any] = None,
status: str = 'OPERATING',
**kwargs
) -> Any:
"""
Create a park with specified status.
Args:
name: Park name (auto-generated if not provided)
operator: Operator company (auto-created if not provided)
status: Park status
**kwargs: Additional park fields
Returns:
Created Park instance
"""
from apps.parks.models import Park
uid = cls._get_unique_id()
if name is None:
name = f"Test Park {uid}"
if operator is None:
operator = CompanyFactory.create_operator()
defaults = {
'name': name,
'slug': f'test-park-{uid}',
'description': f'A test park {uid}',
'operator': operator,
'status': status,
'timezone': 'America/New_York'
}
defaults.update(kwargs)
return Park.objects.create(**defaults)
class RideFactory:
"""Factory for creating ride instances."""
_counter = 0
@classmethod
def _get_unique_id(cls) -> int:
cls._counter += 1
return cls._counter
@classmethod
def create_ride(
cls,
name: Optional[str] = None,
park: Optional[Any] = None,
manufacturer: Optional[Any] = None,
status: str = 'OPERATING',
**kwargs
) -> Any:
"""
Create a ride with specified status.
Args:
name: Ride name (auto-generated if not provided)
park: Park for the ride (auto-created if not provided)
manufacturer: Manufacturer company (auto-created if not provided)
status: Ride status
**kwargs: Additional ride fields
Returns:
Created Ride instance
"""
from apps.rides.models import Ride
uid = cls._get_unique_id()
if name is None:
name = f"Test Ride {uid}"
if park is None:
park = ParkFactory.create_park()
if manufacturer is None:
manufacturer = CompanyFactory.create_manufacturer()
defaults = {
'name': name,
'slug': f'test-ride-{uid}',
'description': f'A test ride {uid}',
'park': park,
'manufacturer': manufacturer,
'status': status
}
defaults.update(kwargs)
return Ride.objects.create(**defaults)
class EditSubmissionFactory:
"""Factory for creating edit submission instances."""
_counter = 0
@classmethod
def _get_unique_id(cls) -> int:
cls._counter += 1
return cls._counter
@classmethod
def create_submission(
cls,
user: Optional[Any] = None,
target_object: Optional[Any] = None,
status: str = 'PENDING',
changes: Optional[Dict[str, Any]] = None,
**kwargs
) -> Any:
"""
Create an edit submission.
Args:
user: User who submitted (auto-created if not provided)
target_object: Object being edited (auto-created if not provided)
status: Submission status
changes: Changes dictionary
**kwargs: Additional fields
Returns:
Created EditSubmission instance
"""
from apps.moderation.models import EditSubmission
from apps.parks.models import Company
uid = cls._get_unique_id()
if user is None:
user = UserFactory.create_regular_user()
if target_object is None:
target_object = Company.objects.create(
name=f'Target Company {uid}',
description='Test company'
)
if changes is None:
changes = {'name': f'Updated Name {uid}'}
content_type = ContentType.objects.get_for_model(target_object)
defaults = {
'user': user,
'content_type': content_type,
'object_id': target_object.id,
'submission_type': 'EDIT',
'changes': changes,
'status': status,
'reason': f'Test reason {uid}'
}
defaults.update(kwargs)
return EditSubmission.objects.create(**defaults)
class ModerationReportFactory:
"""Factory for creating moderation report instances."""
_counter = 0
@classmethod
def _get_unique_id(cls) -> int:
cls._counter += 1
return cls._counter
@classmethod
def create_report(
cls,
reporter: Optional[Any] = None,
target_object: Optional[Any] = None,
status: str = 'PENDING',
**kwargs
) -> Any:
"""
Create a moderation report.
Args:
reporter: User who reported (auto-created if not provided)
target_object: Object being reported (auto-created if not provided)
status: Report status
**kwargs: Additional fields
Returns:
Created ModerationReport instance
"""
from apps.moderation.models import ModerationReport
from apps.parks.models import Company
uid = cls._get_unique_id()
if reporter is None:
reporter = UserFactory.create_regular_user()
if target_object is None:
target_object = Company.objects.create(
name=f'Reported Company {uid}',
description='Test company'
)
content_type = ContentType.objects.get_for_model(target_object)
defaults = {
'report_type': 'CONTENT',
'status': status,
'priority': 'MEDIUM',
'reported_entity_type': target_object._meta.model_name,
'reported_entity_id': target_object.id,
'content_type': content_type,
'reason': f'Test reason {uid}',
'description': f'Test report description {uid}',
'reported_by': reporter
}
defaults.update(kwargs)
return ModerationReport.objects.create(**defaults)
class MockInstance:
"""
Mock instance for testing guards without database.
Example:
instance = MockInstance(
status='PENDING',
created_by=user,
assigned_to=moderator
)
"""
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def __repr__(self):
attrs = ', '.join(f'{k}={v!r}' for k, v in self.__dict__.items())
return f'MockInstance({attrs})'

View File

@@ -0,0 +1,340 @@
"""
Test helper functions for state machine tests.
This module provides utility functions for testing state machine functionality:
- Transition assertion helpers
- State log verification helpers
- Guard testing utilities
"""
from typing import Any, Optional, List, Callable
from django.contrib.contenttypes.models import ContentType
def assert_transition_allowed(
instance: Any,
method_name: str,
user: Optional[Any] = None
) -> bool:
"""
Assert that a transition is allowed.
Args:
instance: Model instance with FSM field
method_name: Name of the transition method
user: User attempting the transition
Returns:
True if transition is allowed
Raises:
AssertionError: If transition is not allowed
Example:
assert_transition_allowed(submission, 'transition_to_approved', moderator)
"""
from django_fsm import can_proceed
method = getattr(instance, method_name)
result = can_proceed(method)
assert result, f"Transition {method_name} should be allowed but was denied"
return True
def assert_transition_denied(
instance: Any,
method_name: str,
user: Optional[Any] = None
) -> bool:
"""
Assert that a transition is denied.
Args:
instance: Model instance with FSM field
method_name: Name of the transition method
user: User attempting the transition
Returns:
True if transition is denied
Raises:
AssertionError: If transition is allowed
Example:
assert_transition_denied(submission, 'transition_to_approved', regular_user)
"""
from django_fsm import can_proceed
method = getattr(instance, method_name)
result = can_proceed(method)
assert not result, f"Transition {method_name} should be denied but was allowed"
return True
def assert_state_log_created(
instance: Any,
expected_state: str,
user: Optional[Any] = None
) -> Any:
"""
Assert that a StateLog entry was created for a transition.
Args:
instance: Model instance that was transitioned
expected_state: The expected final state in the log
user: Expected user who made the transition (optional)
Returns:
The StateLog entry
Raises:
AssertionError: If StateLog entry not found or doesn't match
Example:
log = assert_state_log_created(submission, 'APPROVED', moderator)
"""
from django_fsm_log.models import StateLog
ct = ContentType.objects.get_for_model(instance)
log = StateLog.objects.filter(
content_type=ct,
object_id=instance.id,
state=expected_state
).first()
assert log is not None, f"StateLog for state '{expected_state}' not found"
if user is not None:
assert log.by == user, f"Expected log.by={user}, got {log.by}"
return log
def assert_state_log_count(instance: Any, expected_count: int) -> List[Any]:
"""
Assert the number of StateLog entries for an instance.
Args:
instance: Model instance to check logs for
expected_count: Expected number of log entries
Returns:
List of StateLog entries
Raises:
AssertionError: If count doesn't match
Example:
logs = assert_state_log_count(submission, 2)
"""
from django_fsm_log.models import StateLog
ct = ContentType.objects.get_for_model(instance)
logs = list(StateLog.objects.filter(
content_type=ct,
object_id=instance.id
).order_by('timestamp'))
actual_count = len(logs)
assert actual_count == expected_count, \
f"Expected {expected_count} StateLog entries, got {actual_count}"
return logs
def assert_state_transition_sequence(
instance: Any,
expected_states: List[str]
) -> List[Any]:
"""
Assert that state transitions occurred in a specific sequence.
Args:
instance: Model instance to check
expected_states: List of expected states in order
Returns:
List of StateLog entries
Raises:
AssertionError: If sequence doesn't match
Example:
assert_state_transition_sequence(submission, ['ESCALATED', 'APPROVED'])
"""
from django_fsm_log.models import StateLog
ct = ContentType.objects.get_for_model(instance)
logs = list(StateLog.objects.filter(
content_type=ct,
object_id=instance.id
).order_by('timestamp'))
actual_states = [log.state for log in logs]
assert actual_states == expected_states, \
f"Expected state sequence {expected_states}, got {actual_states}"
return logs
def assert_guard_passes(
guard: Callable,
instance: Any,
user: Optional[Any] = None,
message: str = ""
) -> bool:
"""
Assert that a guard function passes.
Args:
guard: Guard function or callable
instance: Model instance to check
user: User attempting the action
message: Optional message on failure
Returns:
True if guard passes
Raises:
AssertionError: If guard fails
Example:
assert_guard_passes(permission_guard, instance, moderator)
"""
result = guard(instance, user)
fail_message = message or f"Guard should pass but returned {result}"
assert result is True, fail_message
return True
def assert_guard_fails(
guard: Callable,
instance: Any,
user: Optional[Any] = None,
expected_error_code: Optional[str] = None,
message: str = ""
) -> bool:
"""
Assert that a guard function fails.
Args:
guard: Guard function or callable
instance: Model instance to check
user: User attempting the action
expected_error_code: Expected error code from guard
message: Optional message on failure
Returns:
True if guard fails as expected
Raises:
AssertionError: If guard passes or wrong error code
Example:
assert_guard_fails(permission_guard, instance, regular_user, 'PERMISSION_DENIED')
"""
result = guard(instance, user)
fail_message = message or f"Guard should fail but returned {result}"
assert result is False, fail_message
if expected_error_code and hasattr(guard, 'error_code'):
assert guard.error_code == expected_error_code, \
f"Expected error code {expected_error_code}, got {guard.error_code}"
return True
def transition_and_save(
instance: Any,
transition_method: str,
user: Optional[Any] = None,
**kwargs
) -> Any:
"""
Execute a transition and save the instance.
Args:
instance: Model instance with FSM field
transition_method: Name of the transition method
user: User performing the transition
**kwargs: Additional arguments for the transition
Returns:
The saved instance
Example:
submission = transition_and_save(submission, 'transition_to_approved', moderator)
"""
method = getattr(instance, transition_method)
method(user=user, **kwargs)
instance.save()
instance.refresh_from_db()
return instance
def get_available_transitions(instance: Any) -> List[str]:
"""
Get list of available transitions for an instance.
Args:
instance: Model instance with FSM field
Returns:
List of available transition method names
Example:
transitions = get_available_transitions(submission)
# ['transition_to_approved', 'transition_to_rejected', 'transition_to_escalated']
"""
from django_fsm import get_available_FIELD_transitions
# Get the state field name from the instance
state_field = getattr(instance, 'state_field_name', 'status')
# Build the function name dynamically
func_name = f'get_available_{state_field}_transitions'
if hasattr(instance, func_name):
get_transitions = getattr(instance, func_name)
return [t.name for t in get_transitions()]
# Fallback: look for transition methods
transitions = []
for attr_name in dir(instance):
if attr_name.startswith('transition_to_'):
transitions.append(attr_name)
return transitions
def create_transition_context(
instance: Any,
from_state: str,
to_state: str,
user: Optional[Any] = None,
**extra
) -> dict:
"""
Create a mock transition context dictionary.
Args:
instance: Model instance being transitioned
from_state: Source state
to_state: Target state
user: User performing the transition
**extra: Additional context data
Returns:
Dictionary matching TransitionContext structure
Example:
context = create_transition_context(submission, 'PENDING', 'APPROVED', moderator)
"""
return {
'instance': instance,
'from_state': from_state,
'to_state': to_state,
'user': user,
'model_class': type(instance),
'transition_name': f'transition_to_{to_state.lower()}',
**extra
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff