from odoo import api, fields, models from dateutil.relativedelta import relativedelta class FlDeadline(models.Model): """ Phase 2 — Full implementation with calendar integration, default judgment workflow, and complete FL procedural deadline generation engine. """ _name = 'fl.deadline' _description = 'Case Deadline' _inherit = ['mail.thread'] _order = 'due_date asc' _rec_name = 'name' case_id = fields.Many2one( 'fl.case', required=True, ondelete='cascade', index=True, string='Case' ) name = fields.Char(string='Deadline', required=True) deadline_type = fields.Selection([ ('filing', 'Filing'), ('service', 'Service of Process (Target)'), ('service_max', 'Service Deadline — 120-day Maximum'), ('response', 'Response / Answer'), ('discovery_open', 'Discovery Opens'), ('financial_disclosure', 'Financial Disclosure Exchange'), ('mandatory_disclosure_cert', 'Mandatory Disclosure Certificate'), ('deposition_notice', 'Deposition Notice Deadline'), ('deposition', 'Deposition'), ('discovery_cutoff', 'Discovery Cutoff'), ('parenting_class', 'Parenting Class Completion'), ('mediation', 'Mediation'), ('hearing', 'Hearing'), ('compliance', 'Compliance Deadline'), ('emancipation', 'Emancipation'), ('default_motion', 'Motion for Default — Watch Period'), ('default_motion_file', 'File Motion for Default (FL 12.922)'), ('custom', 'Custom'), ], required=True, string='Type') statute_reference = fields.Char( string='Statute / Rule', help='e.g. FL 1.140 — 20 days to answer after service' ) due_date = fields.Date(string='Due Date', required=True, tracking=True) anchor_date = fields.Date( string='Anchor Date', help='Date this deadline is calculated from (filing date, service date, etc.)' ) offset_days = fields.Integer( string='Offset Days', help='Number of days from anchor date to this deadline' ) completed = fields.Boolean(string='Completed', tracking=True) completed_date = fields.Date(string='Completion Date') waived = fields.Boolean(string='Waived / Not Applicable') notes = fields.Text(string='Notes') # ── Calendar Integration ────────────────────────────────────────────────── calendar_event_id = fields.Many2one( 'calendar.event', string='Calendar Event', ondelete='set null' ) # ── Alert Tracking ──────────────────────────────────────────────────────── alert_7day_sent = fields.Boolean(default=False, string='7-day Alert Sent') alert_3day_sent = fields.Boolean(default=False, string='3-day Alert Sent') alert_1day_sent = fields.Boolean(default=False, string='1-day Alert Sent') overdue_alert_sent = fields.Boolean(default=False, string='Overdue Alert Sent') # ── Computed Status ─────────────────────────────────────────────────────── is_overdue = fields.Boolean( string='Overdue', compute='_compute_is_overdue', store=True ) days_until_due = fields.Integer( string='Days Until Due', compute='_compute_days_until_due' ) # ══════════════════════════════════════════════════════════════════════════ # COMPUTED FIELDS # ══════════════════════════════════════════════════════════════════════════ @api.depends('due_date', 'completed', 'waived') def _compute_is_overdue(self): today = fields.Date.today() for rec in self: rec.is_overdue = ( not rec.completed and not rec.waived and bool(rec.due_date) and rec.due_date < today ) @api.depends('due_date') def _compute_days_until_due(self): today = fields.Date.today() for rec in self: if rec.due_date: rec.days_until_due = (rec.due_date - today).days else: rec.days_until_due = 0 # ══════════════════════════════════════════════════════════════════════════ # CRUD — calendar event lifecycle # ══════════════════════════════════════════════════════════════════════════ @api.model_create_multi def create(self, vals_list): records = super().create(vals_list) for rec in records: rec.with_context(_no_calendar_sync=True)._create_or_update_calendar_event() return records def write(self, vals): result = super().write(vals) if not self.env.context.get('_no_calendar_sync'): sync_fields = {'due_date', 'name', 'completed', 'waived', 'deadline_type'} if sync_fields.intersection(vals.keys()): for rec in self: rec.with_context(_no_calendar_sync=True)._create_or_update_calendar_event() return result # ══════════════════════════════════════════════════════════════════════════ # CALENDAR INTEGRATION # ══════════════════════════════════════════════════════════════════════════ def _create_or_update_calendar_event(self): """Create or update a calendar.event linked to this deadline.""" if not self.due_date or self.waived: # Archive the calendar event if waived or no date if self.calendar_event_id: self.calendar_event_id.write({'active': False}) self.write({'calendar_event_id': False}) return if self.completed: # Archive if completed if self.calendar_event_id: self.calendar_event_id.write({'active': False}) return # Build attendee list partners = self.env['res.partner'] if self.case_id.petitioner_id: partners |= self.case_id.petitioner_id if self.env.user.partner_id: partners |= self.env.user.partner_id # All-day event: start at 08:00, stop at 09:00 start_dt = fields.Datetime.from_string( '{} 08:00:00'.format(self.due_date) ) stop_dt = fields.Datetime.from_string( '{} 09:00:00'.format(self.due_date) ) type_label = dict(self._fields['deadline_type'].selection).get( self.deadline_type, '' ) event_name = '[{}] {} ({})'.format( self.case_id.name, self.name, type_label ) description = ( 'FL Case Deadline\n' 'Case: {}\n' 'Type: {}\n' 'Statute: {}\n' 'Status: Pending' ).format( self.case_id.name, type_label, self.statute_reference or 'N/A', ) if self.calendar_event_id: self.calendar_event_id.write({ 'name': event_name, 'start': start_dt, 'stop': stop_dt, 'description': description, 'active': True, }) else: event = self.env['calendar.event'].sudo().create({ 'name': event_name, 'start': start_dt, 'stop': stop_dt, 'allday': True, 'description': description, 'partner_ids': [(6, 0, partners.ids)], 'show_as': 'free', 'privacy': 'confidential', }) # Use sudo write to avoid recursion on calendar_event_id self.write({'calendar_event_id': event.id}) # ══════════════════════════════════════════════════════════════════════════ # WORKFLOW ACTIONS # ══════════════════════════════════════════════════════════════════════════ def action_mark_complete(self): """Mark deadline as completed and archive the calendar event.""" self.write({ 'completed': True, 'completed_date': fields.Date.today(), }) if self.calendar_event_id: self.calendar_event_id.write({'active': False}) def action_mark_waived(self): """Mark deadline as not applicable for this case.""" self.write({'waived': True}) # ══════════════════════════════════════════════════════════════════════════ # DEADLINE GENERATION ENGINE # ══════════════════════════════════════════════════════════════════════════ @api.model def generate_deadlines_for_case(self, case): """ Auto-generate procedural deadlines anchored to filing_date. Called on case create when filing_date is set, and whenever filing_date changes. """ if not case.filing_date: return existing_types = set(case.deadline_ids.mapped('deadline_type')) # ── Filing-date anchored deadlines ──────────────────────────────────── filing_rules = [ { 'name': 'Serve Respondent — Target Date (FL 1.070)', 'deadline_type': 'service', 'offset_days': 30, 'anchor': case.filing_date, 'statute_reference': 'FL 1.070 — Aim to serve within 30 days of filing', }, { 'name': 'Serve Respondent — MAXIMUM Deadline (FL 1.070)', 'deadline_type': 'service_max', 'offset_days': 120, 'anchor': case.filing_date, 'statute_reference': ( 'FL 1.070 — Case may be dismissed if not served within 120 days' ), }, ] # Parenting class from filing (petitioner side — FL 61.21) if case.has_minor_children: filing_rules.append({ 'name': 'Parenting Class — Petitioner Must Complete (FL 61.21)', 'deadline_type': 'parenting_class', 'offset_days': 45, 'anchor': case.filing_date, 'statute_reference': ( 'FL 61.21 — Both parents must complete parenting class ' 'before final hearing when minor children involved' ), }) for rule in filing_rules: if rule['deadline_type'] in existing_types: continue due = rule['anchor'] + relativedelta(days=rule['offset_days']) self.create({ 'case_id': case.id, 'name': rule['name'], 'deadline_type': rule['deadline_type'], 'due_date': due, 'anchor_date': rule['anchor'], 'offset_days': rule['offset_days'], 'statute_reference': rule.get('statute_reference', ''), }) @api.model def recalculate_service_deadlines(self, case): """ Create or update deadlines anchored to service_date. Called from fl_case.write when service_date is set or changed. All FL procedural deadlines run from the date of service. """ if not case.service_date: return service_rules = [ # FL 1.140: Respondent has 20 days to answer after service { 'name': 'Respondent Answer Deadline (FL 1.140)', 'deadline_type': 'response', 'offset_days': 20, 'statute_reference': 'FL 1.140 — 20 days to serve answer after service', }, # FL 12.285: Mandatory financial disclosure within 45 days of service { 'name': 'Mandatory Financial Disclosure Exchange (FL 12.285)', 'deadline_type': 'financial_disclosure', 'offset_days': 45, 'statute_reference': ( 'FL 12.285 — Exchange mandatory disclosure documents within ' '45 days of service: last 3 years tax returns, paystubs, ' 'bank statements, financial affidavit' ), }, # FL 12.932: Certificate of mandatory disclosure { 'name': 'File Certificate of Mandatory Disclosure (FL 12.932)', 'deadline_type': 'mandatory_disclosure_cert', 'offset_days': 45, 'statute_reference': ( 'FL 12.932 — Certificate confirming disclosure documents exchanged' ), }, # FL 12.280: Discovery opens once case is at issue { 'name': 'Discovery Opens — Case at Issue (FL 12.280)', 'deadline_type': 'discovery_open', 'offset_days': 20, 'statute_reference': ( 'FL 12.280 — Discovery may commence after respondent answers ' '(or after Day 20 if no answer filed)' ), }, ] # Respondent parenting class runs from service date if case.has_minor_children: service_rules.append({ 'name': 'Parenting Class — Respondent Must Complete (FL 61.21)', 'deadline_type': 'parenting_class', 'offset_days': 60, 'statute_reference': ( 'FL 61.21 — Respondent must complete parenting class ' 'before final hearing' ), }) existing = {d.deadline_type: d for d in case.deadline_ids} for rule in service_rules: due = case.service_date + relativedelta(days=rule['offset_days']) dl_type = rule['deadline_type'] if dl_type in existing: # Update existing deadline existing[dl_type].write({ 'due_date': due, 'anchor_date': case.service_date, }) else: self.create({ 'case_id': case.id, 'name': rule['name'], 'deadline_type': dl_type, 'due_date': due, 'anchor_date': case.service_date, 'offset_days': rule['offset_days'], 'statute_reference': rule.get('statute_reference', ''), }) # ══════════════════════════════════════════════════════════════════════════ # CRON: Daily Deadline Alerts # ══════════════════════════════════════════════════════════════════════════ def _cron_deadline_alerts(self): """ Run daily via ir.cron. Posts deadline alerts to case chatter at 7, 3, 1 days before due date and once when a deadline becomes overdue. """ today = fields.Date.today() upcoming = self.search([ ('completed', '=', False), ('waived', '=', False), ('due_date', '>=', today), ]) for dl in upcoming: days = (dl.due_date - today).days if days == 7 and not dl.alert_7day_sent: dl._send_deadline_alert('7 days') dl.alert_7day_sent = True elif days == 3 and not dl.alert_3day_sent: dl._send_deadline_alert('3 days') dl.alert_3day_sent = True elif days == 1 and not dl.alert_1day_sent: dl._send_deadline_alert('1 day') dl.alert_1day_sent = True overdue = self.search([ ('completed', '=', False), ('waived', '=', False), ('due_date', '<', today), ('overdue_alert_sent', '=', False), ]) for dl in overdue: dl._send_deadline_alert('OVERDUE') dl.overdue_alert_sent = True # ══════════════════════════════════════════════════════════════════════════ # CRON: Default Judgment Workflow (FL 12.922) # ══════════════════════════════════════════════════════════════════════════ def _cron_check_default_judgment(self): """ Run daily via ir.cron. FL 12.922 / FL 1.500: If respondent has not filed an answer by Day 20 after service, trigger the default judgment workflow: 1. Create 'File Motion for Default' deadline (5 days from today) 2. Create project task for petitioner 3. Post urgent alert to case chatter Only triggers once per case (checks for existing default_motion_file deadline to avoid duplicates). """ today = fields.Date.today() # Find ALL overdue response deadlines overdue_responses = self.search([ ('deadline_type', '=', 'response'), ('completed', '=', False), ('waived', '=', False), ('due_date', '<', today), ]) for dl in overdue_responses: case = dl.case_id # If respondent has now answered, mark deadline complete if case.respondent_answered: dl.action_mark_complete() continue # Check if we already created the default motion deadline existing_default = self.search([ ('case_id', '=', case.id), ('deadline_type', '=', 'default_motion_file'), ], limit=1) if existing_default: continue # Create the "File Motion for Default" deadline (5 days from today) default_due = today + relativedelta(days=5) self.create({ 'case_id': case.id, 'name': 'File Motion for Default — Respondent Did Not Answer (FL 12.922)', 'deadline_type': 'default_motion_file', 'due_date': default_due, 'anchor_date': today, 'offset_days': 5, 'statute_reference': ( 'FL 12.922 / FL 1.500 — Motion for Default. ' 'Respondent failed to respond within 20 days of service.' ), 'notes': ( 'File Clerk\'s Default (FL-12.922) with Miami-Dade Clerk.\n' 'After clerk enters default, file Motion for Final Judgment.\n' 'Form available at: https://www.flcourts.gov/Resources-Services/' 'Court-Improvement/Family-Courts/Family-Law-Self-Help-Information' ), }) # Create a project task to remind the petitioner if case.project_id: self.env['project.task'].create({ 'name': 'FILE: Motion for Default (FL 12.922) — URGENT', 'project_id': case.project_id.id, 'description': ( 'Respondent did not file an answer by the Day 20 deadline ' '({}). You may now file a Clerk\'s Default.\n\n' 'Steps:\n' '1. Download FL-12.922 from Florida Courts website\n' '2. Complete the form with your case number\n' '3. File at Miami-Dade Clerk\'s office OR via e-Filing portal\n' '4. After clerk enters default, file Motion for Final Judgment\n\n' 'Deadline to file: {}\n' 'Statute: FL 12.922, FL 1.500' ).format(dl.due_date, default_due), 'date_deadline': default_due, }) # Post urgent chatter alert case.message_post( body=( '⚖️ DEFAULT JUDGMENT WINDOW OPEN (FL 12.922)
' 'Respondent did not file an answer by the Day 20 deadline ' '({}).

' 'Action Required: File a Clerk\'s Default (FL-12.922) with the ' 'Miami-Dade Clerk within 5 days (by {}).

' 'Steps:
' '1. Download FL-12.922 from Florida Courts website
' '2. File at Miami-Dade Clerk\'s office or via e-Filing portal
' '3. After default is entered, file Motion for Final Judgment

' 'Statute: FL 12.922 / FL 1.500' ).format(dl.due_date, default_due), subtype_xmlid='mail.mt_note', ) # ══════════════════════════════════════════════════════════════════════════ # ALERT HELPER # ══════════════════════════════════════════════════════════════════════════ def _send_deadline_alert(self, timing): """Post a deadline alert message to the case chatter.""" if timing == 'OVERDUE': icon = '🔴' urgency = 'OVERDUE' elif timing == '1 day': icon = '🚨' urgency = 'Due Tomorrow' else: icon = '⏰' urgency = 'Due in {}'.format(timing) self.case_id.message_post( body=( '{icon} Deadline Alert — {urgency}
' '{name}
' 'Due: {due_date}
' 'Statute: {statute}' ).format( icon=icon, urgency=urgency, name=self.name, due_date=self.due_date, statute=self.statute_reference or 'N/A', ), subtype_xmlid='mail.mt_note', )