""" Declarative Pydantic-based transition system for FSM engine. This module provides a framework for defining state transitions as first-class Pydantic models with built-in validation, context passing, and middleware-like functionality for enhanced declarative state management. """ from abc import ABC, abstractmethod from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, Generic, Optional, TypeVar from django.db.models import Model from pydantic import BaseModel, ConfigDict, Field if TYPE_CHECKING: from fsm.state_models import BaseState # Type variables for generic transition context EntityType = TypeVar('EntityType', bound=Model) StateModelType = TypeVar('StateModelType', bound=BaseState) else: EntityType = TypeVar('EntityType') StateModelType = TypeVar('StateModelType') class TransitionContext(BaseModel, Generic[EntityType, StateModelType]): """ Context object passed to all transitions containing middleware-like information. This provides access to current state, entity, user, and other contextual information needed for transition validation and execution. """ model_config = ConfigDict(arbitrary_types_allowed=True) # Core context information entity: Any = Field(..., description='The entity being transitioned') current_user: Optional[Any] = Field(None, description='User triggering the transition (request user)') current_state_object: Optional[Any] = Field(None, description='Full current state object') current_state: Optional[str] = Field(None, description='Current state as string') target_state: Optional[str] = Field( None, description='Target state for this transition (None for side-effect only transitions)' ) # Timing and metadata timestamp: datetime = Field(default_factory=datetime.now, description='When transition was initiated') transition_name: Optional[str] = Field(None, description='Name of the transition method') # Additional context data request_data: Dict[str, Any] = Field(default_factory=dict, description='Additional request/context data') metadata: Dict[str, Any] = Field(default_factory=dict, description='Transition-specific metadata') # Organizational context organization_id: Optional[int] = Field(None, description='Organization context for the transition') # Validation context, for cases where we want to skip validation for the transition skip_validation: Optional[bool] = Field(default=False, description='Whether to skip validation for the transition') # Reason override - if provided, takes precedence over Transition.get_reason() # This allows callers to provide context-specific reasons for transitions # (e.g., "Project moved from Sandbox to FSM Testing workspace") reason: Optional[str] = Field( None, description='Override reason for this transition (takes precedence over get_reason)' ) # Additional context data to be merged with transition's context_data # This allows callers to add extra data to be stored in the state record's JSONB context_data # (e.g., workspace_from_id, workspace_to_id for workspace change transitions) context_data: Dict[str, Any] = Field( default_factory=dict, description='Additional context data to store with state record' ) @property def has_current_state(self) -> bool: """Check if entity has a current state""" return self.current_state is not None @property def is_initial_transition(self) -> bool: """Check if this is the first state transition for the entity""" return not self.has_current_state class TransitionValidationError(Exception): """Exception raised when transition validation fails""" def __init__(self, message: str, context: Optional[Dict[str, Any]] = None): super().__init__(message) self.context = context or {} class BaseTransition(BaseModel, ABC, Generic[EntityType, StateModelType]): """ Abstract base class for all declarative state transitions. This provides the framework for implementing transitions as first-class Pydantic models with built-in validation, context handling, and execution logic. Example usage: class StartTaskTransition(BaseTransition[Task, TaskState]): assigned_user_id: int = Field(..., description="User assigned to start the task") estimated_duration: Optional[int] = Field(None, description="Estimated completion time in hours") def get_target_state(self, context: Optional[TransitionContext[Task, TaskState]]) -> str: return TaskStateChoices.IN_PROGRESS def validate_transition(self, context: TransitionContext[Task, TaskState]) -> bool: if context.current_state == TaskStateChoices.COMPLETED: raise TransitionValidationError("Cannot start an already completed task") return True def transition(self, context: TransitionContext[Task, TaskState]) -> Dict[str, Any]: return { "assigned_user_id": self.assigned_user_id, "estimated_duration": self.estimated_duration, "started_at": context.timestamp.isoformat() } """ model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, use_enum_values=True) def __init__(self, **data): super().__init__(**data) self.__context: Optional[TransitionContext[EntityType, StateModelType]] = None @property def context(self) -> Optional[TransitionContext[EntityType, StateModelType]]: """Access the current transition context""" return getattr(self, '_BaseTransition__context', None) @context.setter def context(self, value: TransitionContext[EntityType, StateModelType]): """Set the transition context""" self.__context = value @abstractmethod def get_target_state( self, context: Optional[TransitionContext[EntityType, StateModelType]] = None ) -> Optional[str]: """ Get the target state this transition leads to. Can optionally use context to compute target_state dynamically. If context is None, should return a static target_state or None. Args: context: Optional transition context (may be minimal with just entity) Returns: String representation of the target state, or None for side-effect only transitions that don't create state records (e.g., audit-only or notification-only transitions) """ pass @property def transition_name(self) -> str: """ Name of this transition for audit purposes. Returns the registered transition name from the decorator, or falls back to the class name in snake_case. """ # Use the registered name if available (set by @register_state_transition decorator) if hasattr(self.__class__, '_transition_name'): return self.__class__._transition_name # Fallback to class name in snake_case for backward compatibility class_name = self.__class__.__name__ result = '' for i, char in enumerate(class_name): if char.isupper() and i > 0: result += '_' result += char.lower() return result @classmethod def can_transition_from_state(cls, context: TransitionContext[EntityType, StateModelType]) -> bool: """ Class-level validation for whether this transition type is allowed from the current state. This method checks if the transition is structurally valid (e.g., allowed state transitions) without needing the actual transition data. Override this to implement state-based rules. Args: context: The transition context containing entity, user, and state information Returns: True if transition type is allowed from current state, False otherwise """ return True def validate_transition(self, context: TransitionContext[EntityType, StateModelType]) -> bool: """ Validate whether this specific transition instance can be performed. This method validates both the transition type (via can_transition_from_state) and the specific transition data. Override to add data-specific validation. Args: context: The transition context containing entity, user, and state information Returns: True if transition is valid, False otherwise Raises: TransitionValidationError: If transition validation fails with specific reason """ # First check if this transition type is allowed if not self.can_transition_from_state(context): return False # Then perform instance-specific validation return True def pre_transition_hook(self, context: TransitionContext[EntityType, StateModelType]) -> None: """ Hook called before the transition is executed. Use this for any setup or preparation needed before state change. Override in subclasses as needed. Args: context: The transition context """ pass @abstractmethod def transition(self, context: TransitionContext[EntityType, StateModelType]) -> Dict[str, Any]: """ Execute the transition and return context data for the state record. This is the core method that implements the transition logic. Must be implemented by all concrete transition classes. Args: context: The transition context containing all necessary information Returns: Dictionary of context data to be stored with the state record Raises: TransitionValidationError: If transition cannot be completed """ pass def post_transition_hook( self, context: TransitionContext[EntityType, StateModelType], state_record: StateModelType ) -> None: """ Hook called after the transition has been successfully executed. Use this for any cleanup, notifications, or side effects after state change. Override in subclasses as needed. Args: context: The transition context state_record: The newly created state record """ pass def get_reason(self, context: TransitionContext[EntityType, StateModelType]) -> str: """ Get a human-readable reason for this transition. Override in subclasses to provide more specific reasons. Note: If `context.reason` is set, it takes precedence over this method. This allows callers to provide context-specific reasons when executing transitions (e.g., "Project moved from Sandbox to shared workspace"). Args: context: The transition context Returns: Human-readable reason string """ user_info = f'by {context.current_user}' if context.current_user else 'automatically' return f'{self.__class__.__name__} executed {user_info}' def prepare_and_validate(self, context: TransitionContext[EntityType, StateModelType]) -> Dict[str, Any]: """ Prepare and validate the transition, returning the transition data. This method handles the preparation phase of the transition: 1. Set context on the transition instance 2. Validate the transition if not skipped 3. Execute pre-transition hooks 4. Perform the actual transition logic Args: context: The transition context Returns: Dictionary of transition data to be stored with the state record Raises: TransitionValidationError: If validation fails """ # Set context for access during transition self.context = context # Update context with transition name context.transition_name = self.transition_name try: # Validate transition if not context.skip_validation and not self.validate_transition(context): raise TransitionValidationError( f'Transition validation failed for {self.transition_name}', {'current_state': context.current_state, 'target_state': context.target_state}, ) # Pre-transition hook self.pre_transition_hook(context) # Execute the transition logic transition_data = self.transition(context) return transition_data except Exception: # Clear context on error self.context = None raise def finalize(self, context: TransitionContext[EntityType, StateModelType], state_record: StateModelType) -> None: """ Finalize the transition after the state record has been created. This method handles post-transition activities: 1. Execute post-transition hooks 2. Clear the context Args: context: The transition context state_record: The newly created state record """ try: # Post-transition hook self.post_transition_hook(context, state_record) finally: # Always clear context when done self.context = None class ModelChangeTransition(BaseTransition, Generic[EntityType, StateModelType]): """ Specialized transition class for model-triggered state changes. This class extends BaseTransition with additional context about model changes, making it ideal for transitions triggered by FsmHistoryStateModel.save() operations. Features: - Access to changed fields (old vs new values) - Knowledge of whether entity is being created or updated - Automatic integration with FsmHistoryStateModel lifecycle - Declarative trigger field specification Example usage: @register_state_transition('task', 'task_created', triggers_on_create=True) class TaskCreatedTransition(ModelChangeTransition[Task, TaskState]): def get_target_state(self, context: Optional[TransitionContext] = None) -> str: return 'CREATED' def transition(self, context: TransitionContext) -> Dict[str, Any]: return {'reason': 'Task created'} @register_state_transition('task', 'task_labeled', triggers_on=['is_labeled']) class TaskLabeledTransition(ModelChangeTransition[Task, TaskState]): def get_target_state(self, context: Optional[TransitionContext] = None) -> str: return 'ANNOTATION_COMPLETE' def transition(self, context: TransitionContext) -> Dict[str, Any]: return {'reason': 'Task became labeled'} """ # Additional fields specific to model changes changed_fields: Dict[str, Dict[str, Any]] = Field( default_factory=dict, description="Fields that changed: {field_name: {'old': value, 'new': value}}" ) is_creating: bool = Field(default=False, description='Whether this is a new entity creation') # Class-level metadata for trigger configuration (set by decorator) _triggers_on_create: bool = False _triggers_on_update: bool = True _trigger_fields: list = [] # Fields that trigger this transition def should_execute(self, context: TransitionContext[EntityType, StateModelType]) -> bool: """ Determine if this transition should execute based on model changes. Override in subclasses to provide specific logic based on: - Whether entity is being created (is_creating) - Which fields changed (changed_fields) - Current and target states Default implementation always returns True. Args: context: The transition context with entity and state information Returns: True if transition should execute, False to skip Example: def should_execute(self, context: TransitionContext) -> bool: # Only execute if is_labeled changed to True if 'is_labeled' in self.changed_fields: old_val = self.changed_fields['is_labeled']['old'] new_val = self.changed_fields['is_labeled']['new'] return not old_val and new_val return False """ return True def validate_transition(self, context: TransitionContext[EntityType, StateModelType]) -> bool: """ Validate whether this transition should execute. Extends parent validation with should_execute() check. Args: context: The transition context Returns: True if transition is valid and should execute Raises: TransitionValidationError: If validation fails """ # First check parent validation if not super().validate_transition(context): return False # Then check if we should execute based on model changes if not self.should_execute(context): return False return True @classmethod def from_model_change( cls, is_creating: bool, changed_fields: Dict[str, tuple], **extra_data ) -> 'ModelChangeTransition': """ Factory method to create a transition from model change data. This is called by FsmHistoryStateModel when a transition needs to be executed. Args: is_creating: Whether the model is being created changed_fields: Dict of changed fields (field_name -> (old, new)) **extra_data: Additional data to pass to the transition Returns: Configured transition instance """ # Convert changed_fields from tuple format to dict format converted_fields = { field_name: {'old': old_val, 'new': new_val} for field_name, (old_val, new_val) in changed_fields.items() } return cls(is_creating=is_creating, changed_fields=converted_fields, **extra_data) def get_reason(self, context: TransitionContext[EntityType, StateModelType]) -> str: """ Get a human-readable reason for this model change transition. Override to provide more specific reasons based on model changes. Args: context: The transition context Returns: Human-readable reason string """ if self.is_creating: return f'{context.entity.__class__.__name__} created' if self.changed_fields: fields = ', '.join(self.changed_fields.keys()) return f'{context.entity.__class__.__name__} updated ({fields} changed)' return f'{context.entity.__class__.__name__} modified'