Files
famlaw/activeblue_familylaw/models/fl_deadline.py
Carlos Garcia 7c865c8c22 Phase 2: Deadlines + Calendar integration
Full fl_deadline.py:
- Calendar event creation/update on every deadline (all-day events)
- _cron_check_default_judgment: FL 12.922 — if respondent misses Day 20
  answer deadline, auto-creates Motion for Default deadline (5 days),
  project task, and urgent chatter alert
- _cron_deadline_alerts: 7/3/1-day and overdue chatter alerts
- Complete service-anchored deadline set: response (Day 20), financial
  disclosure + mandatory disclosure cert (Day 45), discovery opens (Day 20),
  respondent parenting class (Day 60), 120-day service max
- context-flag pattern (_no_calendar_sync) to prevent recursive write loops

Full fl_hearing.py:
- Calendar event sync (show_as=busy, confidential)
- Pre-hearing checklist computed fields: parenting class (FL 61.21),
  discovery cutoff (hearing -30 days), financial disclosure status
- Workflow buttons: Mark Completed, Mark Continued, Cancel
- _create_hearing_deadline: creates fl.deadline record for each hearing

New data/fl_deadline_rules.xml:
- ir.cron: Daily Deadline Alerts (fl_deadline._cron_deadline_alerts)
- ir.cron: Default Judgment Check (fl_deadline._cron_check_default_judgment)
- ir.cron: Emancipation Alerts (fl_child._cron_emancipation_alerts)

New data/mail_templates.xml:
- Deadline Alert Upcoming (EN)
- Deadline Alert OVERDUE (EN)
- Portal Welcome (EN + ES bilingual)
- Default Judgment Window Alert (EN)

Enhanced views: deadline + hearing now have calendar view, search view
with filters (overdue, due this week, by type), and group-by options.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 23:16:24 -04:00

534 lines
24 KiB
Python

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=(
'<b>⚖️ DEFAULT JUDGMENT WINDOW OPEN (FL 12.922)</b><br/>'
'Respondent did not file an answer by the Day 20 deadline '
'(<b>{}</b>).<br/><br/>'
'<b>Action Required:</b> File a Clerk\'s Default (FL-12.922) with the '
'Miami-Dade Clerk within <b>5 days (by {})</b>.<br/><br/>'
'<b>Steps:</b><br/>'
'1. Download FL-12.922 from Florida Courts website<br/>'
'2. File at Miami-Dade Clerk\'s office or via e-Filing portal<br/>'
'3. After default is entered, file Motion for Final Judgment<br/><br/>'
'<i>Statute: FL 12.922 / FL 1.500</i>'
).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} <b>Deadline Alert — {urgency}</b><br/>'
'<b>{name}</b><br/>'
'Due: <b>{due_date}</b><br/>'
'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',
)