Files
delphi-database/app/services/deadlines.py
HotSwapp bac8cc4bd5 changes
2025-08-18 20:20:04 -05:00

684 lines
25 KiB
Python

"""
Deadline management service
Handles deadline creation, tracking, notifications, and reporting
"""
from typing import List, Dict, Any, Optional, Tuple
from datetime import datetime, date, timedelta, timezone
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, func, or_, desc, asc
from decimal import Decimal
from app.models import (
Deadline, DeadlineReminder, DeadlineTemplate, DeadlineHistory, CourtCalendar,
DeadlineType, DeadlinePriority, DeadlineStatus, NotificationFrequency,
File, Rolodex, Employee, User
)
from app.utils.logging import app_logger
logger = app_logger
class DeadlineManagementError(Exception):
"""Exception raised when deadline management operations fail"""
pass
class DeadlineService:
"""Service for deadline management operations"""
def __init__(self, db: Session):
self.db = db
def create_deadline(
self,
title: str,
deadline_date: date,
created_by_user_id: int,
deadline_type: DeadlineType = DeadlineType.OTHER,
priority: DeadlinePriority = DeadlinePriority.MEDIUM,
description: Optional[str] = None,
file_no: Optional[str] = None,
client_id: Optional[str] = None,
assigned_to_user_id: Optional[int] = None,
assigned_to_employee_id: Optional[str] = None,
deadline_time: Optional[datetime] = None,
court_name: Optional[str] = None,
case_number: Optional[str] = None,
advance_notice_days: int = 7,
notification_frequency: NotificationFrequency = NotificationFrequency.WEEKLY
) -> Deadline:
"""Create a new deadline"""
# Validate file exists if provided
if file_no:
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise DeadlineManagementError(f"File {file_no} not found")
# Validate client exists if provided
if client_id:
client_obj = self.db.query(Rolodex).filter(Rolodex.id == client_id).first()
if not client_obj:
raise DeadlineManagementError(f"Client {client_id} not found")
# Validate assigned employee if provided
if assigned_to_employee_id:
employee_obj = self.db.query(Employee).filter(Employee.empl_num == assigned_to_employee_id).first()
if not employee_obj:
raise DeadlineManagementError(f"Employee {assigned_to_employee_id} not found")
# Create deadline
deadline = Deadline(
title=title,
description=description,
deadline_date=deadline_date,
deadline_time=deadline_time,
deadline_type=deadline_type,
priority=priority,
file_no=file_no,
client_id=client_id,
assigned_to_user_id=assigned_to_user_id,
assigned_to_employee_id=assigned_to_employee_id,
created_by_user_id=created_by_user_id,
court_name=court_name,
case_number=case_number,
advance_notice_days=advance_notice_days,
notification_frequency=notification_frequency
)
self.db.add(deadline)
self.db.flush() # Get the ID
# Create history record
self._create_deadline_history(
deadline.id, "created", None, None, None, created_by_user_id, "Deadline created"
)
# Schedule automatic reminders
if notification_frequency != NotificationFrequency.NONE:
self._schedule_reminders(deadline)
self.db.commit()
self.db.refresh(deadline)
logger.info(f"Created deadline {deadline.id}: '{title}' for {deadline_date}")
return deadline
def update_deadline(
self,
deadline_id: int,
user_id: int,
**updates
) -> Deadline:
"""Update an existing deadline"""
deadline = self.db.query(Deadline).filter(Deadline.id == deadline_id).first()
if not deadline:
raise DeadlineManagementError(f"Deadline {deadline_id} not found")
# Track changes for history
changes = []
for field, new_value in updates.items():
if hasattr(deadline, field):
old_value = getattr(deadline, field)
if old_value != new_value:
changes.append((field, old_value, new_value))
setattr(deadline, field, new_value)
# Update timestamp
deadline.updated_at = datetime.now(timezone.utc)
# Create history records for changes
for field, old_value, new_value in changes:
self._create_deadline_history(
deadline_id, "updated", field, str(old_value), str(new_value), user_id
)
# If deadline date changed, reschedule reminders
if any(field == 'deadline_date' for field, _, _ in changes):
self._reschedule_reminders(deadline)
self.db.commit()
self.db.refresh(deadline)
logger.info(f"Updated deadline {deadline_id} - {len(changes)} changes made")
return deadline
def complete_deadline(
self,
deadline_id: int,
user_id: int,
completion_notes: Optional[str] = None
) -> Deadline:
"""Mark a deadline as completed"""
deadline = self.db.query(Deadline).filter(Deadline.id == deadline_id).first()
if not deadline:
raise DeadlineManagementError(f"Deadline {deadline_id} not found")
if deadline.status != DeadlineStatus.PENDING:
raise DeadlineManagementError(f"Only pending deadlines can be completed")
# Update deadline
deadline.status = DeadlineStatus.COMPLETED
deadline.completed_date = datetime.now(timezone.utc)
deadline.completed_by_user_id = user_id
deadline.completion_notes = completion_notes
# Create history record
self._create_deadline_history(
deadline_id, "completed", "status", "pending", "completed", user_id, completion_notes
)
# Cancel pending reminders
self._cancel_pending_reminders(deadline_id)
self.db.commit()
self.db.refresh(deadline)
logger.info(f"Completed deadline {deadline_id}")
return deadline
def extend_deadline(
self,
deadline_id: int,
new_deadline_date: date,
user_id: int,
extension_reason: Optional[str] = None,
extension_granted_by: Optional[str] = None
) -> Deadline:
"""Extend a deadline to a new date"""
deadline = self.db.query(Deadline).filter(Deadline.id == deadline_id).first()
if not deadline:
raise DeadlineManagementError(f"Deadline {deadline_id} not found")
if deadline.status not in [DeadlineStatus.PENDING, DeadlineStatus.EXTENDED]:
raise DeadlineManagementError("Only pending or previously extended deadlines can be extended")
# Store original deadline if this is the first extension
if not deadline.original_deadline_date:
deadline.original_deadline_date = deadline.deadline_date
old_date = deadline.deadline_date
deadline.deadline_date = new_deadline_date
deadline.status = DeadlineStatus.EXTENDED
deadline.extension_reason = extension_reason
deadline.extension_granted_by = extension_granted_by
# Create history record
self._create_deadline_history(
deadline_id, "extended", "deadline_date", str(old_date), str(new_deadline_date),
user_id, f"Extension reason: {extension_reason or 'Not specified'}"
)
# Reschedule reminders for new date
self._reschedule_reminders(deadline)
self.db.commit()
self.db.refresh(deadline)
logger.info(f"Extended deadline {deadline_id} from {old_date} to {new_deadline_date}")
return deadline
def cancel_deadline(
self,
deadline_id: int,
user_id: int,
cancellation_reason: Optional[str] = None
) -> Deadline:
"""Cancel a deadline"""
deadline = self.db.query(Deadline).filter(Deadline.id == deadline_id).first()
if not deadline:
raise DeadlineManagementError(f"Deadline {deadline_id} not found")
deadline.status = DeadlineStatus.CANCELLED
# Create history record
self._create_deadline_history(
deadline_id, "cancelled", "status", deadline.status.value, "cancelled",
user_id, cancellation_reason
)
# Cancel pending reminders
self._cancel_pending_reminders(deadline_id)
self.db.commit()
self.db.refresh(deadline)
logger.info(f"Cancelled deadline {deadline_id}")
return deadline
def get_deadlines_by_file(self, file_no: str) -> List[Deadline]:
"""Get all deadlines for a specific file"""
return self.db.query(Deadline).filter(
Deadline.file_no == file_no
).options(
joinedload(Deadline.assigned_to_user),
joinedload(Deadline.assigned_to_employee),
joinedload(Deadline.created_by)
).order_by(Deadline.deadline_date.asc()).all()
def get_upcoming_deadlines(
self,
days_ahead: int = 30,
user_id: Optional[int] = None,
employee_id: Optional[str] = None,
priority: Optional[DeadlinePriority] = None,
deadline_type: Optional[DeadlineType] = None
) -> List[Deadline]:
"""Get upcoming deadlines within specified timeframe"""
end_date = date.today() + timedelta(days=days_ahead)
query = self.db.query(Deadline).filter(
Deadline.status == DeadlineStatus.PENDING,
Deadline.deadline_date <= end_date
)
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)
if priority:
query = query.filter(Deadline.priority == priority)
if deadline_type:
query = query.filter(Deadline.deadline_type == deadline_type)
return query.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()
def get_overdue_deadlines(
self,
user_id: Optional[int] = None,
employee_id: Optional[str] = None
) -> List[Deadline]:
"""Get overdue deadlines"""
query = self.db.query(Deadline).filter(
Deadline.status == DeadlineStatus.PENDING,
Deadline.deadline_date < date.today()
)
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)
return query.options(
joinedload(Deadline.file),
joinedload(Deadline.client),
joinedload(Deadline.assigned_to_user),
joinedload(Deadline.assigned_to_employee)
).order_by(Deadline.deadline_date.asc()).all()
def get_deadline_statistics(
self,
user_id: Optional[int] = None,
employee_id: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> Dict[str, Any]:
"""Get deadline statistics for reporting"""
base_query = self.db.query(Deadline)
if user_id:
base_query = base_query.filter(Deadline.assigned_to_user_id == user_id)
if employee_id:
base_query = base_query.filter(Deadline.assigned_to_employee_id == employee_id)
if start_date:
base_query = base_query.filter(Deadline.deadline_date >= start_date)
if end_date:
base_query = base_query.filter(Deadline.deadline_date <= end_date)
# Calculate statistics
total_deadlines = base_query.count()
pending_deadlines = base_query.filter(Deadline.status == DeadlineStatus.PENDING).count()
completed_deadlines = base_query.filter(Deadline.status == DeadlineStatus.COMPLETED).count()
overdue_deadlines = base_query.filter(
Deadline.status == DeadlineStatus.PENDING,
Deadline.deadline_date < date.today()
).count()
# Deadlines by priority
priority_counts = {}
for priority in DeadlinePriority:
count = base_query.filter(Deadline.priority == priority).count()
priority_counts[priority.value] = count
# Deadlines by type
type_counts = {}
for deadline_type in DeadlineType:
count = base_query.filter(Deadline.deadline_type == deadline_type).count()
type_counts[deadline_type.value] = count
# Upcoming deadlines (next 7, 14, 30 days)
today = date.today()
upcoming_7_days = base_query.filter(
Deadline.status == DeadlineStatus.PENDING,
Deadline.deadline_date.between(today, today + timedelta(days=7))
).count()
upcoming_14_days = base_query.filter(
Deadline.status == DeadlineStatus.PENDING,
Deadline.deadline_date.between(today, today + timedelta(days=14))
).count()
upcoming_30_days = base_query.filter(
Deadline.status == DeadlineStatus.PENDING,
Deadline.deadline_date.between(today, today + timedelta(days=30))
).count()
return {
"total_deadlines": total_deadlines,
"pending_deadlines": pending_deadlines,
"completed_deadlines": completed_deadlines,
"overdue_deadlines": overdue_deadlines,
"completion_rate": (completed_deadlines / total_deadlines * 100) if total_deadlines > 0 else 0,
"priority_breakdown": priority_counts,
"type_breakdown": type_counts,
"upcoming": {
"next_7_days": upcoming_7_days,
"next_14_days": upcoming_14_days,
"next_30_days": upcoming_30_days
}
}
def create_deadline_from_template(
self,
template_id: int,
user_id: int,
file_no: Optional[str] = None,
client_id: Optional[str] = None,
deadline_date: Optional[date] = None,
**overrides
) -> Deadline:
"""Create a deadline from a template"""
template = self.db.query(DeadlineTemplate).filter(DeadlineTemplate.id == template_id).first()
if not template:
raise DeadlineManagementError(f"Deadline template {template_id} not found")
if not template.active:
raise DeadlineManagementError("Template is not active")
# Calculate deadline date if not provided
if not deadline_date:
if template.days_from_file_open and file_no:
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if file_obj:
deadline_date = file_obj.opened + timedelta(days=template.days_from_file_open)
else:
deadline_date = date.today() + timedelta(days=template.days_from_event or 30)
# Get file and client info for template substitution
file_obj = None
client_obj = None
if file_no:
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if file_obj and file_obj.owner:
client_obj = file_obj.owner
elif client_id:
client_obj = self.db.query(Rolodex).filter(Rolodex.id == client_id).first()
# Process template strings with substitutions
title = self._process_template_string(
template.default_title_template, file_obj, client_obj
)
description = self._process_template_string(
template.default_description_template, file_obj, client_obj
) if template.default_description_template else None
# Create deadline with template defaults and overrides
deadline_data = {
"title": title,
"description": description,
"deadline_date": deadline_date,
"deadline_type": template.deadline_type,
"priority": template.priority,
"file_no": file_no,
"client_id": client_id,
"advance_notice_days": template.default_advance_notice_days,
"notification_frequency": template.default_notification_frequency,
"created_by_user_id": user_id
}
# Apply any overrides
deadline_data.update(overrides)
return self.create_deadline(**deadline_data)
def get_pending_reminders(self, reminder_date: date = None) -> List[DeadlineReminder]:
"""Get pending reminders that need to be sent"""
if reminder_date is None:
reminder_date = date.today()
return self.db.query(DeadlineReminder).join(Deadline).filter(
DeadlineReminder.reminder_date <= reminder_date,
DeadlineReminder.notification_sent == False,
Deadline.status == DeadlineStatus.PENDING
).options(
joinedload(DeadlineReminder.deadline),
joinedload(DeadlineReminder.recipient)
).all()
def mark_reminder_sent(
self,
reminder_id: int,
delivery_status: str = "sent",
error_message: Optional[str] = None
):
"""Mark a reminder as sent"""
reminder = self.db.query(DeadlineReminder).filter(DeadlineReminder.id == reminder_id).first()
if reminder:
reminder.notification_sent = True
reminder.sent_at = datetime.now(timezone.utc)
reminder.delivery_status = delivery_status
if error_message:
reminder.error_message = error_message
self.db.commit()
# Private helper methods
def _create_deadline_history(
self,
deadline_id: int,
change_type: str,
field_changed: Optional[str],
old_value: Optional[str],
new_value: Optional[str],
user_id: int,
change_reason: Optional[str] = None
):
"""Create a deadline history record"""
history_record = DeadlineHistory(
deadline_id=deadline_id,
change_type=change_type,
field_changed=field_changed,
old_value=old_value,
new_value=new_value,
user_id=user_id,
change_reason=change_reason
)
self.db.add(history_record)
def _schedule_reminders(self, deadline: Deadline):
"""Schedule automatic reminders for a deadline"""
if deadline.notification_frequency == NotificationFrequency.NONE:
return
# Calculate reminder dates
reminder_dates = []
advance_days = deadline.advance_notice_days or 7
if deadline.notification_frequency == NotificationFrequency.DAILY:
# Daily reminders starting from advance notice days
for i in range(advance_days, 0, -1):
reminder_date = deadline.deadline_date - timedelta(days=i)
if reminder_date >= date.today():
reminder_dates.append((reminder_date, i))
elif deadline.notification_frequency == NotificationFrequency.WEEKLY:
# Weekly reminders
weeks_ahead = max(1, advance_days // 7)
for week in range(weeks_ahead, 0, -1):
reminder_date = deadline.deadline_date - timedelta(weeks=week)
if reminder_date >= date.today():
reminder_dates.append((reminder_date, week * 7))
elif deadline.notification_frequency == NotificationFrequency.MONTHLY:
# Monthly reminder
reminder_date = deadline.deadline_date - timedelta(days=30)
if reminder_date >= date.today():
reminder_dates.append((reminder_date, 30))
# Create reminder records
for reminder_date, days_before in reminder_dates:
recipient_user_id = deadline.assigned_to_user_id or deadline.created_by_user_id
reminder = DeadlineReminder(
deadline_id=deadline.id,
reminder_date=reminder_date,
days_before_deadline=days_before,
recipient_user_id=recipient_user_id,
subject=f"Deadline Reminder: {deadline.title}",
message=f"Reminder: {deadline.title} is due on {deadline.deadline_date} ({days_before} days from now)"
)
self.db.add(reminder)
def _reschedule_reminders(self, deadline: Deadline):
"""Reschedule reminders after deadline date change"""
# Delete existing unsent reminders
self.db.query(DeadlineReminder).filter(
DeadlineReminder.deadline_id == deadline.id,
DeadlineReminder.notification_sent == False
).delete()
# Schedule new reminders
self._schedule_reminders(deadline)
def _cancel_pending_reminders(self, deadline_id: int):
"""Cancel all pending reminders for a deadline"""
self.db.query(DeadlineReminder).filter(
DeadlineReminder.deadline_id == deadline_id,
DeadlineReminder.notification_sent == False
).delete()
def _process_template_string(
self,
template_string: Optional[str],
file_obj: Optional[File],
client_obj: Optional[Rolodex]
) -> Optional[str]:
"""Process template string with variable substitutions"""
if not template_string:
return None
result = template_string
# File substitutions
if file_obj:
result = result.replace("{file_no}", file_obj.file_no or "")
result = result.replace("{regarding}", file_obj.regarding or "")
result = result.replace("{attorney}", file_obj.empl_num or "")
# Client substitutions
if client_obj:
client_name = f"{client_obj.first or ''} {client_obj.last or ''}".strip()
result = result.replace("{client_name}", client_name)
result = result.replace("{client_id}", client_obj.id or "")
# Date substitutions
today = date.today()
result = result.replace("{today}", today.strftime("%Y-%m-%d"))
result = result.replace("{today_formatted}", today.strftime("%B %d, %Y"))
return result
class DeadlineTemplateService:
"""Service for managing deadline templates"""
def __init__(self, db: Session):
self.db = db
def create_template(
self,
name: str,
deadline_type: DeadlineType,
user_id: int,
description: Optional[str] = None,
priority: DeadlinePriority = DeadlinePriority.MEDIUM,
default_title_template: Optional[str] = None,
default_description_template: Optional[str] = None,
default_advance_notice_days: int = 7,
default_notification_frequency: NotificationFrequency = NotificationFrequency.WEEKLY,
days_from_file_open: Optional[int] = None,
days_from_event: Optional[int] = None
) -> DeadlineTemplate:
"""Create a new deadline template"""
# Check for duplicate name
existing = self.db.query(DeadlineTemplate).filter(DeadlineTemplate.name == name).first()
if existing:
raise DeadlineManagementError(f"Template with name '{name}' already exists")
template = DeadlineTemplate(
name=name,
description=description,
deadline_type=deadline_type,
priority=priority,
default_title_template=default_title_template,
default_description_template=default_description_template,
default_advance_notice_days=default_advance_notice_days,
default_notification_frequency=default_notification_frequency,
days_from_file_open=days_from_file_open,
days_from_event=days_from_event,
created_by_user_id=user_id
)
self.db.add(template)
self.db.commit()
self.db.refresh(template)
logger.info(f"Created deadline template: {name}")
return template
def get_active_templates(
self,
deadline_type: Optional[DeadlineType] = None
) -> List[DeadlineTemplate]:
"""Get all active deadline templates"""
query = self.db.query(DeadlineTemplate).filter(DeadlineTemplate.active == True)
if deadline_type:
query = query.filter(DeadlineTemplate.deadline_type == deadline_type)
return query.order_by(DeadlineTemplate.name).all()