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>
534 lines
24 KiB
Python
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',
|
|
)
|