"""Action executor service for automation triggers. This module provides the action execution framework for the automation system. It supports extensible action types through a registry pattern. FEAT-014: update_field - Update task field values FEAT-015: auto_assign - Automatic task assignment with multiple strategies """ import logging import uuid from abc import ABC, abstractmethod from datetime import datetime, date from decimal import Decimal from typing import Any, Dict, List, Optional, Type from sqlalchemy import func from sqlalchemy.orm import Session from app.models import Task, User, TaskStatus logger = logging.getLogger(__name__) class ActionExecutionError(Exception): """Exception raised when action execution fails.""" pass class ActionValidationError(Exception): """Exception raised when action config validation fails.""" pass class BaseAction(ABC): """Base class for all action types.""" action_type: str = "" @abstractmethod def validate_config(self, config: Dict[str, Any], db: Session) -> None: """Validate action configuration. Args: config: Action configuration dict db: Database session for validation queries Raises: ActionValidationError: If config is invalid """ pass @abstractmethod def execute( self, db: Session, task: Task, config: Dict[str, Any], context: Dict[str, Any], ) -> Dict[str, Any]: """Execute the action. Args: db: Database session task: Target task config: Action configuration context: Execution context (old_values, new_values, current_user, etc.) Returns: Dict with execution result details Raises: ActionExecutionError: If execution fails """ pass class UpdateFieldAction(BaseAction): """Action to update task field values (FEAT-014). Supported fields: - priority: low, medium, high, urgent - status_id: Valid status ID for the task's project - due_date: ISO format date string Config format: { "field": "priority", "value": "high" } """ action_type = "update_field" # Standard fields that can be updated UPDATABLE_FIELDS = { "priority": ["low", "medium", "high", "urgent"], "status_id": None, # Validated dynamically "due_date": None, # Date string validation } def validate_config(self, config: Dict[str, Any], db: Session) -> None: """Validate update_field configuration.""" field = config.get("field") value = config.get("value") if not field: raise ActionValidationError("Missing required 'field' in update_field config") if value is None: raise ActionValidationError("Missing required 'value' in update_field config") if field not in self.UPDATABLE_FIELDS: raise ActionValidationError( f"Invalid field '{field}'. Supported fields: {list(self.UPDATABLE_FIELDS.keys())}" ) # Validate priority values if field == "priority": valid_values = self.UPDATABLE_FIELDS["priority"] if value not in valid_values: raise ActionValidationError( f"Invalid priority value '{value}'. Valid values: {valid_values}" ) # Validate due_date format if field == "due_date" and value: try: if isinstance(value, str): datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: raise ActionValidationError( f"Invalid due_date format '{value}'. Use ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)" ) def execute( self, db: Session, task: Task, config: Dict[str, Any], context: Dict[str, Any], ) -> Dict[str, Any]: """Execute field update action.""" field = config["field"] value = config["value"] old_value = getattr(task, field, None) logger.info( f"Executing update_field action: task={task.id}, field={field}, " f"old_value={old_value}, new_value={value}" ) # Validate status_id exists for this project if field == "status_id" and value: status = db.query(TaskStatus).filter( TaskStatus.id == value, TaskStatus.project_id == task.project_id, ).first() if not status: raise ActionExecutionError( f"Status ID '{value}' not found in project {task.project_id}" ) # Convert due_date string to datetime if field == "due_date" and value: if isinstance(value, str): value = datetime.fromisoformat(value.replace("Z", "+00:00")) # Update the field setattr(task, field, value) task.updated_at = datetime.utcnow() return { "action_type": self.action_type, "status": "success", "field": field, "old_value": str(old_value) if old_value else None, "new_value": str(value) if value else None, } class AutoAssignAction(BaseAction): """Action for automatic task assignment (FEAT-015). Strategies: - round_robin: Assign to project members in rotation - least_loaded: Assign to member with lowest workload - specific_user: Assign to a specific user Config format: { "strategy": "round_robin" | "least_loaded" | "specific_user", "user_id": "xxx" // Required only for specific_user strategy } """ action_type = "auto_assign" VALID_STRATEGIES = ["round_robin", "least_loaded", "specific_user"] # Class-level state for round-robin tracking per project _round_robin_index: Dict[str, int] = {} def validate_config(self, config: Dict[str, Any], db: Session) -> None: """Validate auto_assign configuration.""" strategy = config.get("strategy") if not strategy: raise ActionValidationError("Missing required 'strategy' in auto_assign config") if strategy not in self.VALID_STRATEGIES: raise ActionValidationError( f"Invalid strategy '{strategy}'. Valid strategies: {self.VALID_STRATEGIES}" ) if strategy == "specific_user": user_id = config.get("user_id") if not user_id: raise ActionValidationError( "Missing required 'user_id' for specific_user strategy" ) # Validate user exists user = db.query(User).filter( User.id == user_id, User.is_active == True, ).first() if not user: raise ActionValidationError(f"User '{user_id}' not found or inactive") def execute( self, db: Session, task: Task, config: Dict[str, Any], context: Dict[str, Any], ) -> Dict[str, Any]: """Execute auto-assign action.""" strategy = config["strategy"] old_assignee_id = task.assignee_id logger.info( f"Executing auto_assign action: task={task.id}, strategy={strategy}, " f"old_assignee={old_assignee_id}" ) if strategy == "specific_user": new_assignee_id = self._assign_specific_user(db, config) elif strategy == "round_robin": new_assignee_id = self._assign_round_robin(db, task) elif strategy == "least_loaded": new_assignee_id = self._assign_least_loaded(db, task) else: raise ActionExecutionError(f"Unknown strategy: {strategy}") if new_assignee_id: task.assignee_id = new_assignee_id task.updated_at = datetime.utcnow() # Get assignee name for logging assignee = db.query(User).filter(User.id == new_assignee_id).first() assignee_name = assignee.name if assignee else "Unknown" logger.info( f"Task {task.id} assigned to user {new_assignee_id} ({assignee_name}) " f"using {strategy} strategy" ) return { "action_type": self.action_type, "status": "success", "strategy": strategy, "old_assignee_id": old_assignee_id, "new_assignee_id": new_assignee_id, "assignee_name": assignee_name, } else: logger.warning( f"No eligible assignee found for task {task.id} using {strategy} strategy" ) return { "action_type": self.action_type, "status": "skipped", "strategy": strategy, "reason": "No eligible assignee found", } def _assign_specific_user(self, db: Session, config: Dict[str, Any]) -> Optional[str]: """Assign to a specific user.""" user_id = config["user_id"] user = db.query(User).filter( User.id == user_id, User.is_active == True, ).first() return user.id if user else None def _assign_round_robin(self, db: Session, task: Task) -> Optional[str]: """Assign using round-robin strategy among project members. Gets all active users in the same department as the project owner, then rotates through them. """ project = task.project if not project: return None # Get project members: owner + users in same department members = self._get_project_members(db, project) if not members: return None # Get or initialize round-robin index for this project project_id = project.id if project_id not in self._round_robin_index: self._round_robin_index[project_id] = 0 # Get next member in rotation index = self._round_robin_index[project_id] % len(members) selected_user = members[index] # Update index for next assignment self._round_robin_index[project_id] = (index + 1) % len(members) return selected_user.id def _assign_least_loaded(self, db: Session, task: Task) -> Optional[str]: """Assign to the project member with lowest workload. Workload is calculated based on number of incomplete tasks assigned. """ project = task.project if not project: return None members = self._get_project_members(db, project) if not members: return None # Calculate workload for each member (count of incomplete tasks) member_workloads = [] for member in members: # Count incomplete tasks (status.is_done = False or no status) incomplete_count = ( db.query(func.count(Task.id)) .outerjoin(TaskStatus, Task.status_id == TaskStatus.id) .filter( Task.assignee_id == member.id, Task.is_deleted == False, (TaskStatus.is_done == False) | (Task.status_id == None), ) .scalar() ) member_workloads.append((member, incomplete_count)) # Sort by workload (ascending) and return the least loaded member member_workloads.sort(key=lambda x: x[1]) if member_workloads: selected_user, workload = member_workloads[0] logger.debug( f"Least loaded assignment: user={selected_user.id}, " f"current_workload={workload}" ) return selected_user.id return None def _get_project_members(self, db: Session, project) -> List[User]: """Get all potential assignees for a project. Returns active users who are either: - The project owner - In the same department as the project """ owner_id = project.owner_id department_id = project.department_id query = db.query(User).filter(User.is_active == True) if department_id: # Get users in same department OR project owner query = query.filter( (User.department_id == department_id) | (User.id == owner_id) ) else: # No department, just return owner query = query.filter(User.id == owner_id) return query.all() class ActionExecutor: """Registry and executor for action types. Usage: executor = ActionExecutor() result = executor.execute_action(db, task, action_config, context) """ _actions: Dict[str, Type[BaseAction]] = {} @classmethod def register(cls, action_class: Type[BaseAction]) -> None: """Register an action type.""" cls._actions[action_class.action_type] = action_class logger.debug(f"Registered action type: {action_class.action_type}") @classmethod def get_supported_actions(cls) -> List[str]: """Get list of supported action types.""" return list(cls._actions.keys()) @classmethod def validate_action(cls, action: Dict[str, Any], db: Session) -> None: """Validate an action configuration. Args: action: Action dict with 'type' and other config db: Database session Raises: ActionValidationError: If action is invalid """ action_type = action.get("type") if not action_type: raise ActionValidationError("Missing action 'type'") if action_type not in cls._actions: # Allow unknown actions (like 'notify') to pass through return action_class = cls._actions[action_type] action_instance = action_class() config = action.get("config", action) # Support both nested and flat config action_instance.validate_config(config, db) @classmethod def execute_action( cls, db: Session, task: Task, action: Dict[str, Any], context: Dict[str, Any], ) -> Optional[Dict[str, Any]]: """Execute a single action. Args: db: Database session task: Target task action: Action dict with 'type' and other config context: Execution context Returns: Execution result dict or None if action type not registered Raises: ActionExecutionError: If execution fails """ action_type = action.get("type") if action_type not in cls._actions: # Not a registered action (might be 'notify' handled elsewhere) return None action_class = cls._actions[action_type] action_instance = action_class() # Extract config - support both nested and flat config config = action.get("config", action) try: result = action_instance.execute(db, task, config, context) return result except ActionExecutionError: raise except Exception as e: logger.exception(f"Unexpected error executing {action_type} action") raise ActionExecutionError(f"Failed to execute {action_type}: {str(e)}") # Register built-in actions ActionExecutor.register(UpdateFieldAction) ActionExecutor.register(AutoAssignAction)