mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 18:11:09 -05:00
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:
@@ -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"):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
"""
|
||||
|
||||
372
backend/apps/core/state_machine/tests/fixtures.py
Normal file
372
backend/apps/core/state_machine/tests/fixtures.py
Normal 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})'
|
||||
340
backend/apps/core/state_machine/tests/helpers.py
Normal file
340
backend/apps/core/state_machine/tests/helpers.py
Normal 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
|
||||
}
|
||||
1005
backend/apps/core/state_machine/tests/test_callbacks.py
Normal file
1005
backend/apps/core/state_machine/tests/test_callbacks.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user