""" 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()