902 lines
32 KiB
Python
902 lines
32 KiB
Python
"""
|
|
Scheduled Trigger Execution Service
|
|
|
|
This module provides functionality for parsing cron expressions and executing
|
|
scheduled triggers based on their cron schedule, including deadline reminders.
|
|
"""
|
|
|
|
import uuid
|
|
import logging
|
|
import time
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import Optional, List, Dict, Any, Tuple, Set
|
|
|
|
from croniter import croniter
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import func
|
|
|
|
from app.models import Trigger, TriggerLog, Task, Project, ProjectMember, User, Role
|
|
from app.services.notification_service import NotificationService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Key prefix for tracking deadline reminders already sent
|
|
DEADLINE_REMINDER_LOG_TYPE = "deadline_reminder"
|
|
|
|
# Retry configuration
|
|
MAX_RETRIES = 3
|
|
BASE_DELAY_SECONDS = 1 # 1s, 2s, 4s exponential backoff
|
|
|
|
|
|
class TriggerSchedulerService:
|
|
"""Service for scheduling and executing cron-based triggers."""
|
|
|
|
@staticmethod
|
|
def parse_cron_expression(expression: str) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate a cron expression.
|
|
|
|
Args:
|
|
expression: A cron expression string (e.g., "0 9 * * 1-5" for weekdays at 9am)
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
- is_valid: True if the expression is valid
|
|
- error_message: None if valid, otherwise an error description
|
|
"""
|
|
try:
|
|
# croniter requires a base time for initialization
|
|
base_time = datetime.now(timezone.utc)
|
|
croniter(expression, base_time)
|
|
return True, None
|
|
except (ValueError, KeyError) as e:
|
|
return False, f"Invalid cron expression: {str(e)}"
|
|
|
|
@staticmethod
|
|
def get_next_run_time(expression: str, base_time: Optional[datetime] = None) -> Optional[datetime]:
|
|
"""
|
|
Get the next scheduled run time for a cron expression.
|
|
|
|
Args:
|
|
expression: A cron expression string
|
|
base_time: The base time to calculate from (defaults to now)
|
|
|
|
Returns:
|
|
The next datetime when the schedule matches, or None if invalid
|
|
"""
|
|
try:
|
|
if base_time is None:
|
|
base_time = datetime.now(timezone.utc)
|
|
cron = croniter(expression, base_time)
|
|
return cron.get_next(datetime)
|
|
except (ValueError, KeyError):
|
|
return None
|
|
|
|
@staticmethod
|
|
def get_previous_run_time(expression: str, base_time: Optional[datetime] = None) -> Optional[datetime]:
|
|
"""
|
|
Get the previous scheduled run time for a cron expression.
|
|
|
|
Args:
|
|
expression: A cron expression string
|
|
base_time: The base time to calculate from (defaults to now)
|
|
|
|
Returns:
|
|
The previous datetime when the schedule matched, or None if invalid
|
|
"""
|
|
try:
|
|
if base_time is None:
|
|
base_time = datetime.now(timezone.utc)
|
|
cron = croniter(expression, base_time)
|
|
return cron.get_prev(datetime)
|
|
except (ValueError, KeyError):
|
|
return None
|
|
|
|
@staticmethod
|
|
def should_trigger(
|
|
trigger: Trigger,
|
|
current_time: datetime,
|
|
last_execution_time: Optional[datetime] = None,
|
|
) -> bool:
|
|
"""
|
|
Check if a schedule trigger should fire based on its cron expression.
|
|
|
|
A trigger should fire if:
|
|
1. It's a schedule-type trigger and is active
|
|
2. Its conditions contain a valid cron expression
|
|
3. The cron schedule has matched since the last execution
|
|
|
|
Args:
|
|
trigger: The trigger to evaluate
|
|
current_time: The current time to check against
|
|
last_execution_time: The time of the last successful execution
|
|
|
|
Returns:
|
|
True if the trigger should fire, False otherwise
|
|
"""
|
|
# Only process schedule triggers
|
|
if trigger.trigger_type != "schedule":
|
|
return False
|
|
|
|
if not trigger.is_active:
|
|
return False
|
|
|
|
# Get cron expression from conditions
|
|
conditions = trigger.conditions or {}
|
|
cron_expression = conditions.get("cron_expression")
|
|
|
|
if not cron_expression:
|
|
logger.warning(f"Trigger {trigger.id} has no cron_expression in conditions")
|
|
return False
|
|
|
|
# Validate cron expression
|
|
is_valid, error = TriggerSchedulerService.parse_cron_expression(cron_expression)
|
|
if not is_valid:
|
|
logger.warning(f"Trigger {trigger.id} has invalid cron: {error}")
|
|
return False
|
|
|
|
# Get the previous scheduled time before current_time
|
|
prev_scheduled = TriggerSchedulerService.get_previous_run_time(cron_expression, current_time)
|
|
if prev_scheduled is None:
|
|
return False
|
|
|
|
# If no last execution, check if we're within the execution window (5 minutes)
|
|
if last_execution_time is None:
|
|
# Only trigger if the scheduled time was within the last 5 minutes
|
|
window_seconds = 300 # 5 minutes
|
|
time_since_scheduled = (current_time - prev_scheduled).total_seconds()
|
|
return 0 <= time_since_scheduled < window_seconds
|
|
|
|
# Trigger if the previous scheduled time is after the last execution
|
|
return prev_scheduled > last_execution_time
|
|
|
|
@staticmethod
|
|
def get_last_execution_time(db: Session, trigger_id: str) -> Optional[datetime]:
|
|
"""
|
|
Get the last successful execution time for a trigger.
|
|
|
|
Args:
|
|
db: Database session
|
|
trigger_id: The trigger ID
|
|
|
|
Returns:
|
|
The datetime of the last successful execution, or None
|
|
"""
|
|
last_log = db.query(TriggerLog).filter(
|
|
TriggerLog.trigger_id == trigger_id,
|
|
TriggerLog.status == "success",
|
|
).order_by(TriggerLog.executed_at.desc()).first()
|
|
|
|
return last_log.executed_at if last_log else None
|
|
|
|
@staticmethod
|
|
def execute_scheduled_triggers(db: Session) -> List[TriggerLog]:
|
|
"""
|
|
Main execution function that evaluates and executes all scheduled triggers.
|
|
|
|
This function should be called periodically (e.g., every minute) by a scheduler.
|
|
|
|
Args:
|
|
db: Database session
|
|
|
|
Returns:
|
|
List of TriggerLog entries for executed triggers
|
|
"""
|
|
logs: List[TriggerLog] = []
|
|
current_time = datetime.now(timezone.utc)
|
|
|
|
# Get all active schedule-type triggers
|
|
triggers = db.query(Trigger).filter(
|
|
Trigger.trigger_type == "schedule",
|
|
Trigger.is_active == True,
|
|
).all()
|
|
|
|
logger.info(f"Evaluating {len(triggers)} scheduled triggers at {current_time}")
|
|
|
|
for trigger in triggers:
|
|
try:
|
|
# Get last execution time
|
|
last_execution = TriggerSchedulerService.get_last_execution_time(db, trigger.id)
|
|
|
|
# Check if trigger should fire
|
|
if TriggerSchedulerService.should_trigger(trigger, current_time, last_execution):
|
|
logger.info(f"Executing scheduled trigger: {trigger.name} (ID: {trigger.id})")
|
|
log = TriggerSchedulerService._execute_trigger(db, trigger)
|
|
logs.append(log)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error evaluating trigger {trigger.id}: {e}")
|
|
# Log the error
|
|
error_log = TriggerSchedulerService._log_execution(
|
|
db=db,
|
|
trigger=trigger,
|
|
status="failed",
|
|
details={"error_type": type(e).__name__},
|
|
error_message=str(e),
|
|
)
|
|
logs.append(error_log)
|
|
|
|
if logs:
|
|
db.commit()
|
|
logger.info(f"Executed {len(logs)} scheduled triggers")
|
|
|
|
return logs
|
|
|
|
@staticmethod
|
|
def _execute_trigger(db: Session, trigger: Trigger) -> TriggerLog:
|
|
"""
|
|
Execute a scheduled trigger's actions with retry mechanism.
|
|
|
|
Implements exponential backoff retry (1s, 2s, 4s) for transient failures.
|
|
After max retries are exhausted, marks as permanently failed and sends alert.
|
|
|
|
Args:
|
|
db: Database session
|
|
trigger: The trigger to execute
|
|
|
|
Returns:
|
|
TriggerLog entry for this execution
|
|
"""
|
|
return TriggerSchedulerService._execute_trigger_with_retry(
|
|
db=db,
|
|
trigger=trigger,
|
|
task_id=None,
|
|
log_type="schedule",
|
|
)
|
|
|
|
@staticmethod
|
|
def _execute_trigger_with_retry(
|
|
db: Session,
|
|
trigger: Trigger,
|
|
task_id: Optional[str] = None,
|
|
log_type: str = "schedule",
|
|
) -> TriggerLog:
|
|
"""
|
|
Execute trigger actions with exponential backoff retry.
|
|
|
|
Args:
|
|
db: Database session
|
|
trigger: The trigger to execute
|
|
task_id: Optional task ID for context (deadline reminders)
|
|
log_type: Type of trigger execution for logging
|
|
|
|
Returns:
|
|
TriggerLog entry for this execution
|
|
"""
|
|
actions = trigger.actions if isinstance(trigger.actions, list) else [trigger.actions]
|
|
executed_actions = []
|
|
last_error = None
|
|
attempt = 0
|
|
|
|
while attempt < MAX_RETRIES:
|
|
attempt += 1
|
|
executed_actions = []
|
|
last_error = None
|
|
|
|
try:
|
|
logger.info(
|
|
f"Executing trigger {trigger.id} (attempt {attempt}/{MAX_RETRIES})"
|
|
)
|
|
|
|
for action in actions:
|
|
action_type = action.get("type")
|
|
|
|
if action_type == "notify":
|
|
TriggerSchedulerService._execute_notify_action(db, action, trigger)
|
|
executed_actions.append({"type": action_type, "status": "success"})
|
|
|
|
# Add more action types here as needed
|
|
|
|
# Success - return log
|
|
logger.info(f"Trigger {trigger.id} executed successfully on attempt {attempt}")
|
|
return TriggerSchedulerService._log_execution(
|
|
db=db,
|
|
trigger=trigger,
|
|
status="success",
|
|
details={
|
|
"trigger_name": trigger.name,
|
|
"trigger_type": log_type,
|
|
"cron_expression": trigger.conditions.get("cron_expression") if trigger.conditions else None,
|
|
"actions_executed": executed_actions,
|
|
"attempts": attempt,
|
|
},
|
|
error_message=None,
|
|
task_id=task_id,
|
|
)
|
|
|
|
except Exception as e:
|
|
last_error = e
|
|
executed_actions.append({"type": "error", "message": str(e)})
|
|
logger.warning(
|
|
f"Trigger {trigger.id} failed on attempt {attempt}/{MAX_RETRIES}: {e}"
|
|
)
|
|
|
|
# Calculate exponential backoff delay
|
|
if attempt < MAX_RETRIES:
|
|
delay = BASE_DELAY_SECONDS * (2 ** (attempt - 1))
|
|
logger.info(f"Retrying trigger {trigger.id} in {delay}s...")
|
|
time.sleep(delay)
|
|
|
|
# All retries exhausted - permanent failure
|
|
logger.error(
|
|
f"Trigger {trigger.id} permanently failed after {MAX_RETRIES} attempts: {last_error}"
|
|
)
|
|
|
|
# Send alert notification for permanent failure
|
|
TriggerSchedulerService._send_failure_alert(db, trigger, str(last_error), MAX_RETRIES)
|
|
|
|
return TriggerSchedulerService._log_execution(
|
|
db=db,
|
|
trigger=trigger,
|
|
status="permanently_failed",
|
|
details={
|
|
"trigger_name": trigger.name,
|
|
"trigger_type": log_type,
|
|
"cron_expression": trigger.conditions.get("cron_expression") if trigger.conditions else None,
|
|
"actions_executed": executed_actions,
|
|
"attempts": MAX_RETRIES,
|
|
"permanent_failure": True,
|
|
},
|
|
error_message=f"Failed after {MAX_RETRIES} retries: {last_error}",
|
|
task_id=task_id,
|
|
)
|
|
|
|
@staticmethod
|
|
def _send_failure_alert(
|
|
db: Session,
|
|
trigger: Trigger,
|
|
error_message: str,
|
|
attempts: int,
|
|
) -> None:
|
|
"""
|
|
Send alert notification when trigger exhausts all retries.
|
|
|
|
Args:
|
|
db: Database session
|
|
trigger: The failed trigger
|
|
error_message: The last error message
|
|
attempts: Number of attempts made
|
|
"""
|
|
try:
|
|
# Notify the project owner about the failure
|
|
project = trigger.project
|
|
if not project:
|
|
logger.warning(f"Cannot send failure alert: trigger {trigger.id} has no project")
|
|
return
|
|
|
|
target_user_id = project.owner_id
|
|
if not target_user_id:
|
|
logger.warning(f"Cannot send failure alert: project {project.id} has no owner")
|
|
return
|
|
|
|
message = (
|
|
f"Trigger '{trigger.name}' has permanently failed after {attempts} attempts. "
|
|
f"Last error: {error_message}"
|
|
)
|
|
|
|
NotificationService.create_notification(
|
|
db=db,
|
|
user_id=target_user_id,
|
|
notification_type="trigger_failure",
|
|
reference_type="trigger",
|
|
reference_id=trigger.id,
|
|
title=f"Trigger Failed: {trigger.name}",
|
|
message=message,
|
|
)
|
|
|
|
logger.info(f"Sent failure alert for trigger {trigger.id} to user {target_user_id}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send failure alert for trigger {trigger.id}: {e}")
|
|
|
|
@staticmethod
|
|
def _execute_notify_action(db: Session, action: Dict[str, Any], trigger: Trigger) -> None:
|
|
"""
|
|
Execute a notify action for a scheduled trigger.
|
|
|
|
Args:
|
|
db: Database session
|
|
action: The action configuration
|
|
trigger: The parent trigger
|
|
"""
|
|
target = action.get("target", "project_owner")
|
|
template = action.get("template", "Scheduled trigger '{trigger_name}' has fired")
|
|
|
|
# For scheduled triggers, we typically notify project-level users
|
|
project = trigger.project
|
|
if not project:
|
|
logger.warning(f"Trigger {trigger.id} has no associated project")
|
|
return
|
|
|
|
recipient_ids = TriggerSchedulerService._resolve_target(db, project, target)
|
|
if not recipient_ids:
|
|
logger.debug(f"No target user resolved for trigger {trigger.id} with target '{target}'")
|
|
return
|
|
|
|
# Format message with variables
|
|
message = TriggerSchedulerService._format_template(template, trigger, project)
|
|
|
|
for user_id in recipient_ids:
|
|
NotificationService.create_notification(
|
|
db=db,
|
|
user_id=user_id,
|
|
notification_type="scheduled_trigger",
|
|
reference_type="trigger",
|
|
reference_id=trigger.id,
|
|
title=f"Scheduled: {trigger.name}",
|
|
message=message,
|
|
)
|
|
|
|
@staticmethod
|
|
def _resolve_target(db: Session, project: Project, target: str) -> List[str]:
|
|
"""
|
|
Resolve notification target to user IDs.
|
|
|
|
Args:
|
|
project: The project context
|
|
target: Target specification (e.g., "project_owner", "user:<id>")
|
|
|
|
Returns:
|
|
List of user IDs
|
|
"""
|
|
recipients: Set[str] = set()
|
|
|
|
if target == "project_owner":
|
|
if project.owner_id:
|
|
recipients.add(project.owner_id)
|
|
elif target == "project_members":
|
|
if project.owner_id:
|
|
recipients.add(project.owner_id)
|
|
member_rows = db.query(ProjectMember.user_id).join(
|
|
User,
|
|
User.id == ProjectMember.user_id,
|
|
).filter(
|
|
ProjectMember.project_id == project.id,
|
|
User.is_active == True,
|
|
).all()
|
|
recipients.update(row[0] for row in member_rows if row and row[0])
|
|
elif target.startswith("department:"):
|
|
department_id = target.split(":", 1)[1]
|
|
if department_id:
|
|
user_rows = db.query(User.id).filter(
|
|
User.department_id == department_id,
|
|
User.is_active == True,
|
|
).all()
|
|
recipients.update(row[0] for row in user_rows if row and row[0])
|
|
elif target.startswith("role:"):
|
|
role_name = target.split(":", 1)[1].strip()
|
|
if role_name:
|
|
role = db.query(Role).filter(func.lower(Role.name) == role_name.lower()).first()
|
|
if role:
|
|
user_rows = db.query(User.id).filter(
|
|
User.role_id == role.id,
|
|
User.is_active == True,
|
|
).all()
|
|
recipients.update(row[0] for row in user_rows if row and row[0])
|
|
elif target.startswith("user:"):
|
|
user_id = target.split(":", 1)[1]
|
|
if user_id:
|
|
recipients.add(user_id)
|
|
|
|
return list(recipients)
|
|
|
|
@staticmethod
|
|
def _format_template(template: str, trigger: Trigger, project: Project) -> str:
|
|
"""
|
|
Format message template with trigger/project variables.
|
|
|
|
Args:
|
|
template: Template string with {variable} placeholders
|
|
trigger: The trigger context
|
|
project: The project context
|
|
|
|
Returns:
|
|
Formatted message string
|
|
"""
|
|
replacements = {
|
|
"{trigger_name}": trigger.name,
|
|
"{trigger_id}": trigger.id,
|
|
"{project_name}": project.title if project else "Unknown",
|
|
"{project_id}": project.id if project else "Unknown",
|
|
}
|
|
|
|
result = template
|
|
for key, value in replacements.items():
|
|
result = result.replace(key, str(value))
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def _log_execution(
|
|
db: Session,
|
|
trigger: Trigger,
|
|
status: str,
|
|
details: Optional[Dict[str, Any]] = None,
|
|
error_message: Optional[str] = None,
|
|
task_id: Optional[str] = None,
|
|
) -> TriggerLog:
|
|
"""
|
|
Create a trigger execution log entry.
|
|
|
|
Args:
|
|
db: Database session
|
|
trigger: The trigger that was executed
|
|
status: Execution status ("success" or "failed")
|
|
details: Optional execution details
|
|
error_message: Optional error message if failed
|
|
task_id: Optional task ID for deadline reminders
|
|
|
|
Returns:
|
|
The created TriggerLog entry
|
|
"""
|
|
log = TriggerLog(
|
|
id=str(uuid.uuid4()),
|
|
trigger_id=trigger.id,
|
|
task_id=task_id,
|
|
status=status,
|
|
details=details,
|
|
error_message=error_message,
|
|
)
|
|
db.add(log)
|
|
return log
|
|
|
|
# =========================================================================
|
|
# Deadline Reminder Methods
|
|
# =========================================================================
|
|
|
|
@staticmethod
|
|
def execute_deadline_reminders(db: Session) -> List[TriggerLog]:
|
|
"""
|
|
Check all deadline reminder triggers and send notifications for tasks
|
|
that are within N days of their due date.
|
|
|
|
Each task only receives one reminder per trigger configuration.
|
|
|
|
Args:
|
|
db: Database session
|
|
|
|
Returns:
|
|
List of TriggerLog entries for sent reminders
|
|
"""
|
|
logs: List[TriggerLog] = []
|
|
current_time = datetime.now(timezone.utc)
|
|
today = current_time.date()
|
|
|
|
# Get all active schedule triggers with deadline_reminder_days
|
|
triggers = db.query(Trigger).filter(
|
|
Trigger.trigger_type == "schedule",
|
|
Trigger.is_active == True,
|
|
).all()
|
|
|
|
# Filter triggers that have deadline_reminder_days configured
|
|
deadline_triggers = [
|
|
t for t in triggers
|
|
if t.conditions and t.conditions.get("deadline_reminder_days") is not None
|
|
]
|
|
|
|
if not deadline_triggers:
|
|
return logs
|
|
|
|
logger.info(f"Evaluating {len(deadline_triggers)} deadline reminder triggers")
|
|
|
|
for trigger in deadline_triggers:
|
|
try:
|
|
reminder_days = trigger.conditions.get("deadline_reminder_days")
|
|
if not isinstance(reminder_days, int) or reminder_days < 1:
|
|
continue
|
|
|
|
# Calculate the target date range
|
|
# We want to find tasks whose due_date is exactly N days from today
|
|
target_date = today + timedelta(days=reminder_days)
|
|
|
|
# Get tasks in this project that:
|
|
# 1. Have a due_date matching the target date
|
|
# 2. Are not deleted
|
|
# 3. Have not already received a reminder for this trigger
|
|
tasks = TriggerSchedulerService._get_tasks_for_deadline_reminder(
|
|
db, trigger, target_date
|
|
)
|
|
|
|
for task in tasks:
|
|
try:
|
|
log = TriggerSchedulerService._send_deadline_reminder(
|
|
db, trigger, task, reminder_days
|
|
)
|
|
logs.append(log)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error sending deadline reminder for task {task.id}: {e}"
|
|
)
|
|
error_log = TriggerSchedulerService._log_execution(
|
|
db=db,
|
|
trigger=trigger,
|
|
status="failed",
|
|
details={
|
|
"trigger_type": DEADLINE_REMINDER_LOG_TYPE,
|
|
"task_id": task.id,
|
|
"reminder_days": reminder_days,
|
|
},
|
|
error_message=str(e),
|
|
task_id=task.id,
|
|
)
|
|
logs.append(error_log)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing deadline trigger {trigger.id}: {e}")
|
|
|
|
if logs:
|
|
db.commit()
|
|
logger.info(f"Processed {len(logs)} deadline reminders")
|
|
|
|
return logs
|
|
|
|
@staticmethod
|
|
def _get_tasks_for_deadline_reminder(
|
|
db: Session,
|
|
trigger: Trigger,
|
|
target_date,
|
|
) -> List[Task]:
|
|
"""
|
|
Get tasks that need deadline reminders for a specific trigger.
|
|
|
|
Args:
|
|
db: Database session
|
|
trigger: The deadline reminder trigger
|
|
target_date: The date that matches (today + N days)
|
|
|
|
Returns:
|
|
List of tasks that need reminders
|
|
"""
|
|
# Get IDs of tasks that already received reminders for this trigger
|
|
already_notified = db.query(TriggerLog.task_id).filter(
|
|
TriggerLog.trigger_id == trigger.id,
|
|
TriggerLog.status == "success",
|
|
TriggerLog.task_id.isnot(None),
|
|
).all()
|
|
notified_task_ids: Set[str] = {t[0] for t in already_notified if t[0]}
|
|
|
|
# Use date range comparison for cross-database compatibility
|
|
# target_date is a date object, we need to find tasks due on that date
|
|
target_start = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=timezone.utc)
|
|
target_end = datetime.combine(target_date, datetime.max.time()).replace(tzinfo=timezone.utc)
|
|
|
|
# Query tasks matching criteria
|
|
tasks = db.query(Task).filter(
|
|
Task.project_id == trigger.project_id,
|
|
Task.is_deleted == False,
|
|
Task.due_date.isnot(None),
|
|
Task.due_date >= target_start,
|
|
Task.due_date <= target_end,
|
|
).all()
|
|
|
|
# Filter out tasks that already received reminders
|
|
return [t for t in tasks if t.id not in notified_task_ids]
|
|
|
|
@staticmethod
|
|
def _send_deadline_reminder(
|
|
db: Session,
|
|
trigger: Trigger,
|
|
task: Task,
|
|
reminder_days: int,
|
|
) -> TriggerLog:
|
|
"""
|
|
Send a deadline reminder notification for a task.
|
|
|
|
Args:
|
|
db: Database session
|
|
trigger: The trigger configuration
|
|
task: The task approaching its deadline
|
|
reminder_days: Number of days before deadline
|
|
|
|
Returns:
|
|
TriggerLog entry for this reminder
|
|
"""
|
|
actions = trigger.actions if isinstance(trigger.actions, list) else [trigger.actions]
|
|
executed_actions = []
|
|
error_message = None
|
|
|
|
try:
|
|
for action in actions:
|
|
action_type = action.get("type")
|
|
|
|
if action_type == "notify":
|
|
TriggerSchedulerService._execute_deadline_notify_action(
|
|
db, action, trigger, task, reminder_days
|
|
)
|
|
executed_actions.append({"type": action_type, "status": "success"})
|
|
|
|
status = "success"
|
|
|
|
except Exception as e:
|
|
status = "failed"
|
|
error_message = str(e)
|
|
executed_actions.append({"type": "error", "message": str(e)})
|
|
logger.error(f"Error executing deadline reminder for task {task.id}: {e}")
|
|
|
|
return TriggerSchedulerService._log_execution(
|
|
db=db,
|
|
trigger=trigger,
|
|
status=status,
|
|
details={
|
|
"trigger_name": trigger.name,
|
|
"trigger_type": DEADLINE_REMINDER_LOG_TYPE,
|
|
"reminder_days": reminder_days,
|
|
"task_title": task.title,
|
|
"due_date": str(task.due_date),
|
|
"actions_executed": executed_actions,
|
|
},
|
|
error_message=error_message,
|
|
task_id=task.id,
|
|
)
|
|
|
|
@staticmethod
|
|
def _execute_deadline_notify_action(
|
|
db: Session,
|
|
action: Dict[str, Any],
|
|
trigger: Trigger,
|
|
task: Task,
|
|
reminder_days: int,
|
|
) -> None:
|
|
"""
|
|
Execute a notify action for a deadline reminder.
|
|
|
|
Args:
|
|
db: Database session
|
|
action: The action configuration
|
|
trigger: The parent trigger
|
|
task: The task with approaching deadline
|
|
reminder_days: Days until deadline
|
|
"""
|
|
target = action.get("target", "assignee")
|
|
template = action.get(
|
|
"template",
|
|
"Task '{task_title}' is due in {reminder_days} days"
|
|
)
|
|
|
|
# Resolve target user
|
|
recipient_ids = TriggerSchedulerService._resolve_deadline_target(db, task, target)
|
|
if not recipient_ids:
|
|
logger.debug(
|
|
f"No target user resolved for deadline reminder, task {task.id}, target '{target}'"
|
|
)
|
|
return
|
|
|
|
# Format message with variables
|
|
message = TriggerSchedulerService._format_deadline_template(
|
|
template, trigger, task, reminder_days
|
|
)
|
|
|
|
for user_id in recipient_ids:
|
|
NotificationService.create_notification(
|
|
db=db,
|
|
user_id=user_id,
|
|
notification_type="deadline_reminder",
|
|
reference_type="task",
|
|
reference_id=task.id,
|
|
title=f"Deadline Reminder: {task.title}",
|
|
message=message,
|
|
)
|
|
|
|
@staticmethod
|
|
def _resolve_deadline_target(db: Session, task: Task, target: str) -> List[str]:
|
|
"""
|
|
Resolve notification target for deadline reminders.
|
|
|
|
Args:
|
|
task: The task context
|
|
target: Target specification
|
|
|
|
Returns:
|
|
List of user IDs
|
|
"""
|
|
recipients: Set[str] = set()
|
|
|
|
if target == "assignee":
|
|
if task.assignee_id:
|
|
recipients.add(task.assignee_id)
|
|
elif target == "creator":
|
|
if task.created_by:
|
|
recipients.add(task.created_by)
|
|
elif target == "project_owner":
|
|
if task.project and task.project.owner_id:
|
|
recipients.add(task.project.owner_id)
|
|
elif target == "project_members":
|
|
if task.project:
|
|
if task.project.owner_id:
|
|
recipients.add(task.project.owner_id)
|
|
member_rows = db.query(ProjectMember.user_id).join(
|
|
User,
|
|
User.id == ProjectMember.user_id,
|
|
).filter(
|
|
ProjectMember.project_id == task.project_id,
|
|
User.is_active == True,
|
|
).all()
|
|
recipients.update(row[0] for row in member_rows if row and row[0])
|
|
elif target.startswith("department:"):
|
|
department_id = target.split(":", 1)[1]
|
|
if department_id:
|
|
user_rows = db.query(User.id).filter(
|
|
User.department_id == department_id,
|
|
User.is_active == True,
|
|
).all()
|
|
recipients.update(row[0] for row in user_rows if row and row[0])
|
|
elif target.startswith("role:"):
|
|
role_name = target.split(":", 1)[1].strip()
|
|
if role_name:
|
|
role = db.query(Role).filter(func.lower(Role.name) == role_name.lower()).first()
|
|
if role:
|
|
user_rows = db.query(User.id).filter(
|
|
User.role_id == role.id,
|
|
User.is_active == True,
|
|
).all()
|
|
recipients.update(row[0] for row in user_rows if row and row[0])
|
|
elif target.startswith("user:"):
|
|
user_id = target.split(":", 1)[1]
|
|
if user_id:
|
|
recipients.add(user_id)
|
|
|
|
return list(recipients)
|
|
|
|
@staticmethod
|
|
def _format_deadline_template(
|
|
template: str,
|
|
trigger: Trigger,
|
|
task: Task,
|
|
reminder_days: int,
|
|
) -> str:
|
|
"""
|
|
Format message template for deadline reminders.
|
|
|
|
Args:
|
|
template: Template string with {variable} placeholders
|
|
trigger: The trigger context
|
|
task: The task context
|
|
reminder_days: Days until deadline
|
|
|
|
Returns:
|
|
Formatted message string
|
|
"""
|
|
project = trigger.project
|
|
replacements = {
|
|
"{trigger_name}": trigger.name,
|
|
"{trigger_id}": trigger.id,
|
|
"{task_title}": task.title,
|
|
"{task_id}": task.id,
|
|
"{due_date}": str(task.due_date.date()) if task.due_date else "N/A",
|
|
"{reminder_days}": str(reminder_days),
|
|
"{project_name}": project.title if project else "Unknown",
|
|
"{project_id}": project.id if project else "Unknown",
|
|
}
|
|
|
|
result = template
|
|
for key, value in replacements.items():
|
|
result = result.replace(key, str(value))
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def evaluate_schedule_triggers(db: Session) -> List[TriggerLog]:
|
|
"""
|
|
Main entry point for evaluating all schedule triggers.
|
|
|
|
This method runs both cron-based triggers and deadline reminders.
|
|
Should be called every minute by the scheduler.
|
|
|
|
Args:
|
|
db: Database session
|
|
|
|
Returns:
|
|
Combined list of TriggerLog entries from all evaluations
|
|
"""
|
|
all_logs: List[TriggerLog] = []
|
|
|
|
# Execute cron-based schedule triggers
|
|
cron_logs = TriggerSchedulerService.execute_scheduled_triggers(db)
|
|
all_logs.extend(cron_logs)
|
|
|
|
# Execute deadline reminder triggers
|
|
deadline_logs = TriggerSchedulerService.execute_deadline_reminders(db)
|
|
all_logs.extend(deadline_logs)
|
|
|
|
return all_logs
|