536 lines
20 KiB
Python
536 lines
20 KiB
Python
"""
|
|
Deadline notification and alert service
|
|
Handles automated deadline reminders and notifications with workflow integration
|
|
"""
|
|
from typing import List, Dict, Any, Optional
|
|
from datetime import datetime, date, timezone, timedelta
|
|
from sqlalchemy.orm import Session, joinedload
|
|
from sqlalchemy import and_, func, or_, desc
|
|
|
|
from app.models import (
|
|
Deadline, DeadlineReminder, User, Employee,
|
|
DeadlineStatus, DeadlinePriority, NotificationFrequency
|
|
)
|
|
from app.services.deadlines import DeadlineService
|
|
from app.services.workflow_integration import log_deadline_approaching_sync
|
|
from app.utils.logging import app_logger
|
|
|
|
logger = app_logger
|
|
|
|
|
|
class DeadlineNotificationService:
|
|
"""Service for managing deadline notifications and alerts"""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
self.deadline_service = DeadlineService(db)
|
|
|
|
def process_daily_reminders(self, notification_date: date = None) -> Dict[str, Any]:
|
|
"""Process all reminders that should be sent today"""
|
|
|
|
if notification_date is None:
|
|
notification_date = date.today()
|
|
|
|
logger.info(f"Processing deadline reminders for {notification_date}")
|
|
|
|
# First, check for approaching deadlines and trigger workflow events
|
|
workflow_events_triggered = self.check_approaching_deadlines_for_workflows(notification_date)
|
|
|
|
# Get pending reminders for today
|
|
pending_reminders = self.deadline_service.get_pending_reminders(notification_date)
|
|
|
|
results = {
|
|
"date": notification_date,
|
|
"total_reminders": len(pending_reminders),
|
|
"sent_successfully": 0,
|
|
"failed": 0,
|
|
"workflow_events_triggered": workflow_events_triggered,
|
|
"errors": []
|
|
}
|
|
|
|
for reminder in pending_reminders:
|
|
try:
|
|
# Send the notification
|
|
success = self._send_reminder_notification(reminder)
|
|
|
|
if success:
|
|
# Mark as sent
|
|
self.deadline_service.mark_reminder_sent(
|
|
reminder.id,
|
|
delivery_status="sent"
|
|
)
|
|
results["sent_successfully"] += 1
|
|
logger.info(f"Sent reminder {reminder.id} for deadline '{reminder.deadline.title}'")
|
|
else:
|
|
# Mark as failed
|
|
self.deadline_service.mark_reminder_sent(
|
|
reminder.id,
|
|
delivery_status="failed",
|
|
error_message="Failed to send notification"
|
|
)
|
|
results["failed"] += 1
|
|
results["errors"].append(f"Failed to send reminder {reminder.id}")
|
|
|
|
except Exception as e:
|
|
# Mark as failed with error
|
|
self.deadline_service.mark_reminder_sent(
|
|
reminder.id,
|
|
delivery_status="failed",
|
|
error_message=str(e)
|
|
)
|
|
results["failed"] += 1
|
|
results["errors"].append(f"Error sending reminder {reminder.id}: {str(e)}")
|
|
logger.error(f"Error processing reminder {reminder.id}: {str(e)}")
|
|
|
|
logger.info(f"Reminder processing complete: {results['sent_successfully']} sent, {results['failed']} failed, {workflow_events_triggered} workflow events triggered")
|
|
return results
|
|
|
|
def check_approaching_deadlines_for_workflows(self, check_date: date = None) -> int:
|
|
"""Check for approaching deadlines and trigger workflow events"""
|
|
|
|
if check_date is None:
|
|
check_date = date.today()
|
|
|
|
# Get deadlines approaching within the next 7 days
|
|
end_date = check_date + timedelta(days=7)
|
|
|
|
approaching_deadlines = self.db.query(Deadline).filter(
|
|
Deadline.status == DeadlineStatus.PENDING,
|
|
Deadline.deadline_date.between(check_date, end_date)
|
|
).options(
|
|
joinedload(Deadline.file),
|
|
joinedload(Deadline.client)
|
|
).all()
|
|
|
|
events_triggered = 0
|
|
|
|
for deadline in approaching_deadlines:
|
|
try:
|
|
# Calculate days until deadline
|
|
days_until = (deadline.deadline_date - check_date).days
|
|
|
|
# Determine deadline type for workflow context
|
|
deadline_type = getattr(deadline, 'deadline_type', None)
|
|
deadline_type_str = deadline_type.value if deadline_type else 'other'
|
|
|
|
# Log workflow event for deadline approaching
|
|
log_deadline_approaching_sync(
|
|
db=self.db,
|
|
deadline_id=deadline.id,
|
|
file_no=deadline.file_no,
|
|
client_id=deadline.client_id,
|
|
days_until_deadline=days_until,
|
|
deadline_type=deadline_type_str
|
|
)
|
|
|
|
events_triggered += 1
|
|
logger.debug(f"Triggered workflow event for deadline {deadline.id} '{deadline.title}' ({days_until} days away)")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error triggering workflow event for deadline {deadline.id}: {str(e)}")
|
|
|
|
if events_triggered > 0:
|
|
logger.info(f"Triggered {events_triggered} deadline approaching workflow events")
|
|
|
|
return events_triggered
|
|
|
|
def get_urgent_alerts(
|
|
self,
|
|
user_id: Optional[int] = None,
|
|
employee_id: Optional[str] = None
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get urgent deadline alerts that need immediate attention"""
|
|
|
|
today = date.today()
|
|
|
|
# Build base query for urgent deadlines
|
|
query = self.db.query(Deadline).filter(
|
|
Deadline.status == DeadlineStatus.PENDING
|
|
)
|
|
|
|
if user_id:
|
|
query = query.filter(Deadline.assigned_to_user_id == user_id)
|
|
|
|
if employee_id:
|
|
query = query.filter(Deadline.assigned_to_employee_id == employee_id)
|
|
|
|
# Get overdue and critical upcoming deadlines
|
|
urgent_deadlines = query.filter(
|
|
or_(
|
|
# Overdue deadlines
|
|
Deadline.deadline_date < today,
|
|
# Critical priority deadlines due within 3 days
|
|
and_(
|
|
Deadline.priority == DeadlinePriority.CRITICAL,
|
|
Deadline.deadline_date <= today + timedelta(days=3)
|
|
),
|
|
# High priority deadlines due within 1 day
|
|
and_(
|
|
Deadline.priority == DeadlinePriority.HIGH,
|
|
Deadline.deadline_date <= today + timedelta(days=1)
|
|
)
|
|
)
|
|
).options(
|
|
joinedload(Deadline.file),
|
|
joinedload(Deadline.client),
|
|
joinedload(Deadline.assigned_to_user),
|
|
joinedload(Deadline.assigned_to_employee)
|
|
).order_by(
|
|
Deadline.deadline_date.asc(),
|
|
Deadline.priority.desc()
|
|
).all()
|
|
|
|
alerts = []
|
|
for deadline in urgent_deadlines:
|
|
alert_level = self._determine_alert_level(deadline, today)
|
|
|
|
alerts.append({
|
|
"deadline_id": deadline.id,
|
|
"title": deadline.title,
|
|
"deadline_date": deadline.deadline_date,
|
|
"deadline_time": deadline.deadline_time,
|
|
"priority": deadline.priority.value,
|
|
"deadline_type": deadline.deadline_type.value,
|
|
"alert_level": alert_level,
|
|
"days_until_deadline": deadline.days_until_deadline,
|
|
"is_overdue": deadline.is_overdue,
|
|
"file_no": deadline.file_no,
|
|
"client_name": self._get_client_name(deadline),
|
|
"assigned_to": self._get_assigned_to(deadline),
|
|
"court_name": deadline.court_name,
|
|
"case_number": deadline.case_number
|
|
})
|
|
|
|
return alerts
|
|
|
|
def get_dashboard_summary(
|
|
self,
|
|
user_id: Optional[int] = None,
|
|
employee_id: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Get deadline summary for dashboard display"""
|
|
|
|
today = date.today()
|
|
|
|
# Build base query
|
|
query = self.db.query(Deadline).filter(
|
|
Deadline.status == DeadlineStatus.PENDING
|
|
)
|
|
|
|
if user_id:
|
|
query = query.filter(Deadline.assigned_to_user_id == user_id)
|
|
|
|
if employee_id:
|
|
query = query.filter(Deadline.assigned_to_employee_id == employee_id)
|
|
|
|
# Calculate counts
|
|
overdue_count = query.filter(Deadline.deadline_date < today).count()
|
|
|
|
due_today_count = query.filter(Deadline.deadline_date == today).count()
|
|
|
|
due_tomorrow_count = query.filter(
|
|
Deadline.deadline_date == today + timedelta(days=1)
|
|
).count()
|
|
|
|
due_this_week_count = query.filter(
|
|
Deadline.deadline_date.between(
|
|
today,
|
|
today + timedelta(days=7)
|
|
)
|
|
).count()
|
|
|
|
due_next_week_count = query.filter(
|
|
Deadline.deadline_date.between(
|
|
today + timedelta(days=8),
|
|
today + timedelta(days=14)
|
|
)
|
|
).count()
|
|
|
|
# Critical priority counts
|
|
critical_overdue = query.filter(
|
|
Deadline.priority == DeadlinePriority.CRITICAL,
|
|
Deadline.deadline_date < today
|
|
).count()
|
|
|
|
critical_upcoming = query.filter(
|
|
Deadline.priority == DeadlinePriority.CRITICAL,
|
|
Deadline.deadline_date.between(today, today + timedelta(days=7))
|
|
).count()
|
|
|
|
return {
|
|
"overdue": overdue_count,
|
|
"due_today": due_today_count,
|
|
"due_tomorrow": due_tomorrow_count,
|
|
"due_this_week": due_this_week_count,
|
|
"due_next_week": due_next_week_count,
|
|
"critical_overdue": critical_overdue,
|
|
"critical_upcoming": critical_upcoming,
|
|
"total_pending": query.count(),
|
|
"needs_attention": overdue_count + critical_overdue + critical_upcoming
|
|
}
|
|
|
|
def create_adhoc_reminder(
|
|
self,
|
|
deadline_id: int,
|
|
recipient_user_id: int,
|
|
reminder_date: date,
|
|
custom_message: Optional[str] = None
|
|
) -> DeadlineReminder:
|
|
"""Create an ad-hoc reminder for a specific deadline"""
|
|
|
|
deadline = self.db.query(Deadline).filter(Deadline.id == deadline_id).first()
|
|
if not deadline:
|
|
raise ValueError(f"Deadline {deadline_id} not found")
|
|
|
|
recipient = self.db.query(User).filter(User.id == recipient_user_id).first()
|
|
if not recipient:
|
|
raise ValueError(f"User {recipient_user_id} not found")
|
|
|
|
# Calculate days before deadline
|
|
days_before = (deadline.deadline_date - reminder_date).days
|
|
|
|
reminder = DeadlineReminder(
|
|
deadline_id=deadline_id,
|
|
reminder_date=reminder_date,
|
|
days_before_deadline=days_before,
|
|
recipient_user_id=recipient_user_id,
|
|
recipient_email=recipient.email if hasattr(recipient, 'email') else None,
|
|
subject=f"Custom Reminder: {deadline.title}",
|
|
message=custom_message or f"Custom reminder for deadline '{deadline.title}' due on {deadline.deadline_date}",
|
|
notification_method="email"
|
|
)
|
|
|
|
self.db.add(reminder)
|
|
self.db.commit()
|
|
self.db.refresh(reminder)
|
|
|
|
logger.info(f"Created ad-hoc reminder {reminder.id} for deadline {deadline_id}")
|
|
return reminder
|
|
|
|
def get_notification_preferences(self, user_id: int) -> Dict[str, Any]:
|
|
"""Get user's notification preferences (placeholder for future implementation)"""
|
|
|
|
# This would be expanded to include user-specific notification settings
|
|
# For now, return default preferences
|
|
return {
|
|
"email_enabled": True,
|
|
"in_app_enabled": True,
|
|
"sms_enabled": False,
|
|
"advance_notice_days": {
|
|
"critical": 7,
|
|
"high": 3,
|
|
"medium": 1,
|
|
"low": 1
|
|
},
|
|
"notification_times": ["09:00", "17:00"], # When to send daily notifications
|
|
"quiet_hours": {
|
|
"start": "18:00",
|
|
"end": "08:00"
|
|
}
|
|
}
|
|
|
|
def schedule_court_date_reminders(
|
|
self,
|
|
deadline_id: int,
|
|
court_date: date,
|
|
preparation_days: int = 7
|
|
):
|
|
"""Schedule special reminders for court dates with preparation milestones"""
|
|
|
|
deadline = self.db.query(Deadline).filter(Deadline.id == deadline_id).first()
|
|
if not deadline:
|
|
raise ValueError(f"Deadline {deadline_id} not found")
|
|
|
|
recipient_user_id = deadline.assigned_to_user_id or deadline.created_by_user_id
|
|
|
|
# Schedule preparation milestone reminders
|
|
preparation_milestones = [
|
|
(preparation_days, "Begin case preparation"),
|
|
(3, "Final preparation and document review"),
|
|
(1, "Last-minute preparation and travel arrangements"),
|
|
(0, "Court appearance today")
|
|
]
|
|
|
|
for days_before, milestone_message in preparation_milestones:
|
|
reminder_date = court_date - timedelta(days=days_before)
|
|
|
|
if reminder_date >= date.today():
|
|
reminder = DeadlineReminder(
|
|
deadline_id=deadline_id,
|
|
reminder_date=reminder_date,
|
|
days_before_deadline=days_before,
|
|
recipient_user_id=recipient_user_id,
|
|
subject=f"Court Date Preparation: {deadline.title}",
|
|
message=f"{milestone_message} - Court appearance on {court_date}",
|
|
notification_method="email"
|
|
)
|
|
|
|
self.db.add(reminder)
|
|
|
|
self.db.commit()
|
|
logger.info(f"Scheduled court date reminders for deadline {deadline_id}")
|
|
|
|
# Private helper methods
|
|
|
|
def _send_reminder_notification(self, reminder: DeadlineReminder) -> bool:
|
|
"""Send a reminder notification (placeholder for actual implementation)"""
|
|
|
|
try:
|
|
# In a real implementation, this would:
|
|
# 1. Format the notification message
|
|
# 2. Send via email/SMS/push notification
|
|
# 3. Handle delivery confirmations
|
|
# 4. Retry failed deliveries
|
|
|
|
# For now, just log the notification
|
|
logger.info(
|
|
f"NOTIFICATION: {reminder.subject} to user {reminder.recipient_user_id} "
|
|
f"for deadline '{reminder.deadline.title}' due {reminder.deadline.deadline_date}"
|
|
)
|
|
|
|
# Simulate successful delivery
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send notification: {str(e)}")
|
|
return False
|
|
|
|
def _determine_alert_level(self, deadline: Deadline, today: date) -> str:
|
|
"""Determine the alert level for a deadline"""
|
|
|
|
days_until = deadline.days_until_deadline
|
|
|
|
if deadline.is_overdue:
|
|
return "critical"
|
|
|
|
if deadline.priority == DeadlinePriority.CRITICAL:
|
|
if days_until <= 1:
|
|
return "critical"
|
|
elif days_until <= 3:
|
|
return "high"
|
|
else:
|
|
return "medium"
|
|
|
|
elif deadline.priority == DeadlinePriority.HIGH:
|
|
if days_until <= 0:
|
|
return "critical"
|
|
elif days_until <= 1:
|
|
return "high"
|
|
else:
|
|
return "medium"
|
|
|
|
else:
|
|
if days_until <= 0:
|
|
return "high"
|
|
else:
|
|
return "low"
|
|
|
|
def _get_client_name(self, deadline: Deadline) -> Optional[str]:
|
|
"""Get formatted client name from deadline"""
|
|
|
|
if deadline.client:
|
|
return f"{deadline.client.first or ''} {deadline.client.last or ''}".strip()
|
|
elif deadline.file and deadline.file.owner:
|
|
return f"{deadline.file.owner.first or ''} {deadline.file.owner.last or ''}".strip()
|
|
return None
|
|
|
|
def _get_assigned_to(self, deadline: Deadline) -> Optional[str]:
|
|
"""Get assigned person name from deadline"""
|
|
|
|
if deadline.assigned_to_user:
|
|
return deadline.assigned_to_user.username
|
|
elif deadline.assigned_to_employee:
|
|
employee = deadline.assigned_to_employee
|
|
return f"{employee.first_name or ''} {employee.last_name or ''}".strip()
|
|
return None
|
|
|
|
|
|
class DeadlineAlertManager:
|
|
"""Manager for deadline alert workflows and automation"""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
self.notification_service = DeadlineNotificationService(db)
|
|
|
|
def run_daily_alert_processing(self, process_date: date = None) -> Dict[str, Any]:
|
|
"""Run the daily deadline alert processing workflow"""
|
|
|
|
if process_date is None:
|
|
process_date = date.today()
|
|
|
|
logger.info(f"Starting daily deadline alert processing for {process_date}")
|
|
|
|
results = {
|
|
"process_date": process_date,
|
|
"reminders_processed": {},
|
|
"urgent_alerts_generated": 0,
|
|
"errors": []
|
|
}
|
|
|
|
try:
|
|
# Process scheduled reminders
|
|
reminder_results = self.notification_service.process_daily_reminders(process_date)
|
|
results["reminders_processed"] = reminder_results
|
|
|
|
# Generate urgent alerts for overdue items
|
|
urgent_alerts = self.notification_service.get_urgent_alerts()
|
|
results["urgent_alerts_generated"] = len(urgent_alerts)
|
|
|
|
# Log summary
|
|
logger.info(
|
|
f"Daily processing complete: {reminder_results['sent_successfully']} reminders sent, "
|
|
f"{results['urgent_alerts_generated']} urgent alerts generated"
|
|
)
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error in daily alert processing: {str(e)}"
|
|
results["errors"].append(error_msg)
|
|
logger.error(error_msg)
|
|
|
|
return results
|
|
|
|
def escalate_overdue_deadlines(
|
|
self,
|
|
escalation_days: int = 1
|
|
) -> List[Dict[str, Any]]:
|
|
"""Escalate deadlines that have been overdue for specified days"""
|
|
|
|
cutoff_date = date.today() - timedelta(days=escalation_days)
|
|
|
|
overdue_deadlines = self.db.query(Deadline).filter(
|
|
Deadline.status == DeadlineStatus.PENDING,
|
|
Deadline.deadline_date <= cutoff_date
|
|
).options(
|
|
joinedload(Deadline.file),
|
|
joinedload(Deadline.assigned_to_user),
|
|
joinedload(Deadline.assigned_to_employee)
|
|
).all()
|
|
|
|
escalations = []
|
|
|
|
for deadline in overdue_deadlines:
|
|
# Create escalation record
|
|
escalation = {
|
|
"deadline_id": deadline.id,
|
|
"title": deadline.title,
|
|
"deadline_date": deadline.deadline_date,
|
|
"days_overdue": (date.today() - deadline.deadline_date).days,
|
|
"priority": deadline.priority.value,
|
|
"assigned_to": self.notification_service._get_assigned_to(deadline),
|
|
"file_no": deadline.file_no,
|
|
"escalation_date": date.today()
|
|
}
|
|
|
|
escalations.append(escalation)
|
|
|
|
# In a real system, this would:
|
|
# 1. Send escalation notifications to supervisors
|
|
# 2. Create escalation tasks
|
|
# 3. Update deadline status if needed
|
|
|
|
logger.warning(
|
|
f"ESCALATION: Deadline '{deadline.title}' (ID: {deadline.id}) "
|
|
f"overdue by {escalation['days_overdue']} days"
|
|
)
|
|
|
|
return escalations |