feat: complete issue fixes and implement remaining features
## Critical Issues (CRIT-001~003) - All Fixed
- JWT secret key validation with pydantic field_validator
- Login audit logging for success/failure attempts
- Frontend API path prefix removal
## High Priority Issues (HIGH-001~008) - All Fixed
- Project soft delete using is_active flag
- Redis session token bytes handling
- Rate limiting with slowapi (5 req/min for login)
- Attachment API permission checks
- Kanban view with drag-and-drop
- Workload heatmap UI (WorkloadPage, WorkloadHeatmap)
- TaskDetailModal integrating Comments/Attachments
- UserSelect component for task assignment
## Medium Priority Issues (MED-001~012) - All Fixed
- MED-001~005: DB commits, N+1 queries, datetime, error format, blocker flag
- MED-006: Project health dashboard (HealthService, ProjectHealthPage)
- MED-007: Capacity update API (PUT /api/users/{id}/capacity)
- MED-008: Schedule triggers (cron parsing, deadline reminders)
- MED-009: Watermark feature (image/PDF watermarking)
- MED-010~012: useEffect deps, DOM operations, PDF export
## New Files
- backend/app/api/health/ - Project health API
- backend/app/services/health_service.py
- backend/app/services/trigger_scheduler.py
- backend/app/services/watermark_service.py
- backend/app/core/rate_limiter.py
- frontend/src/pages/ProjectHealthPage.tsx
- frontend/src/components/ProjectHealthCard.tsx
- frontend/src/components/KanbanBoard.tsx
- frontend/src/components/WorkloadHeatmap.tsx
## Tests
- 113 new tests passing (health: 32, users: 14, triggers: 35, watermark: 32)
## OpenSpec Archives
- add-project-health-dashboard
- add-capacity-update-api
- add-schedule-triggers
- add-watermark-feature
- add-rate-limiting
- enhance-frontend-ux
- add-resource-management-ui
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
701
backend/app/services/trigger_scheduler.py
Normal file
701
backend/app/services/trigger_scheduler.py
Normal file
@@ -0,0 +1,701 @@
|
||||
"""
|
||||
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
|
||||
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 and_
|
||||
|
||||
from app.models import Trigger, TriggerLog, Task, Project
|
||||
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"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
trigger: The trigger to execute
|
||||
|
||||
Returns:
|
||||
TriggerLog entry for this execution
|
||||
"""
|
||||
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_notify_action(db, action, trigger)
|
||||
executed_actions.append({"type": action_type, "status": "success"})
|
||||
|
||||
# Add more action types here as needed
|
||||
|
||||
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 trigger {trigger.id} actions: {e}")
|
||||
|
||||
return TriggerSchedulerService._log_execution(
|
||||
db=db,
|
||||
trigger=trigger,
|
||||
status=status,
|
||||
details={
|
||||
"trigger_name": trigger.name,
|
||||
"trigger_type": "schedule",
|
||||
"cron_expression": trigger.conditions.get("cron_expression"),
|
||||
"actions_executed": executed_actions,
|
||||
},
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
@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
|
||||
|
||||
target_user_id = TriggerSchedulerService._resolve_target(project, target)
|
||||
if not target_user_id:
|
||||
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)
|
||||
|
||||
NotificationService.create_notification(
|
||||
db=db,
|
||||
user_id=target_user_id,
|
||||
notification_type="scheduled_trigger",
|
||||
reference_type="trigger",
|
||||
reference_id=trigger.id,
|
||||
title=f"Scheduled: {trigger.name}",
|
||||
message=message,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_target(project: Project, target: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve notification target to user ID.
|
||||
|
||||
Args:
|
||||
project: The project context
|
||||
target: Target specification (e.g., "project_owner", "user:<id>")
|
||||
|
||||
Returns:
|
||||
User ID or None
|
||||
"""
|
||||
if target == "project_owner":
|
||||
return project.owner_id
|
||||
elif target.startswith("user:"):
|
||||
return target.split(":", 1)[1]
|
||||
return None
|
||||
|
||||
@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
|
||||
target_user_id = TriggerSchedulerService._resolve_deadline_target(task, target)
|
||||
if not target_user_id:
|
||||
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
|
||||
)
|
||||
|
||||
NotificationService.create_notification(
|
||||
db=db,
|
||||
user_id=target_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(task: Task, target: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve notification target for deadline reminders.
|
||||
|
||||
Args:
|
||||
task: The task context
|
||||
target: Target specification
|
||||
|
||||
Returns:
|
||||
User ID or None
|
||||
"""
|
||||
if target == "assignee":
|
||||
return task.assignee_id
|
||||
elif target == "creator":
|
||||
return task.created_by
|
||||
elif target == "project_owner":
|
||||
return task.project.owner_id if task.project else None
|
||||
elif target.startswith("user:"):
|
||||
return target.split(":", 1)[1]
|
||||
return None
|
||||
|
||||
@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
|
||||
Reference in New Issue
Block a user