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>
This commit is contained in:
Carlos Garcia
2026-05-04 23:16:24 -04:00
parent 1d52d85a78
commit 7c865c8c22
7 changed files with 1272 additions and 86 deletions

View File

@@ -1,29 +1,31 @@
from datetime import date, datetime, time
from odoo import api, fields, models
from dateutil.relativedelta import relativedelta
class FlDeadline(models.Model):
"""
Phase 2 — Full implementation with calendar integration and cron alerts.
Phase 1: Core fields and generate_deadlines_for_case stub.
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
'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'),
('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'),
@@ -32,49 +34,56 @@ class FlDeadline(models.Model):
('hearing', 'Hearing'),
('compliance', 'Compliance Deadline'),
('emancipation', 'Emancipation'),
('default_motion', 'Motion for Default'),
('default_motion', 'Motion for Default — Watch Period'),
('default_motion_file', 'File Motion for Default (FL 12.922)'),
('custom', 'Custom'),
], required=True)
], required=True, string='Type')
statute_reference = fields.Char(
string='Statute / Rule',
help='e.g. FL 1.140 — 20 days to answer'
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'
help='Date this deadline is calculated from (filing date, service date, etc.)'
)
offset_days = fields.Integer(
string='Offset Days',
help='Days from anchor date to due date'
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')
waived = fields.Boolean(string='Waived / Not Applicable')
notes = fields.Text(string='Notes')
# ── Calendar Integration (Phase 2) ────────────────────────────────────
# ── Calendar Integration ──────────────────────────────────────────────────
calendar_event_id = fields.Many2one(
'calendar.event', string='Calendar Event'
'calendar.event', string='Calendar Event', ondelete='set null'
)
# ── Alerts ────────────────────────────────────────────────────────────
alert_7day_sent = fields.Boolean(default=False)
alert_3day_sent = fields.Boolean(default=False)
alert_1day_sent = fields.Boolean(default=False)
overdue_alert_sent = fields.Boolean(default=False)
# ── 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
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()
@@ -95,45 +104,167 @@ class FlDeadline(models.Model):
else:
rec.days_until_due = 0
def action_mark_complete(self):
self.completed = True
self.completed_date = fields.Date.today()
# ══════════════════════════════════════════════════════════════════════════
# CRUD — calendar event lifecycle
# ══════════════════════════════════════════════════════════════════════════
# ── Generation Engine ─────────────────────────────────────────────────
@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 from filing_date.
Phase 1: Core deadlines only.
Phase 2: Full calendar events + alert setup.
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
# Avoid duplicates on re-run
existing_types = case.deadline_ids.mapped('deadline_type')
existing_types = set(case.deadline_ids.mapped('deadline_type'))
rules = [
# ── Filing-date anchored deadlines ────────────────────────────────────
filing_rules = [
{
'name': 'Serve Respondent (Target)',
'name': 'Serve Respondent Target Date (FL 1.070)',
'deadline_type': 'service',
'offset_days': 30,
'anchor': case.filing_date,
'statute_reference': 'FL 1.070 — 120-day maximum',
'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:
rules.append({
'name': 'Parenting Class — Petitioner (FL 61.21)',
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',
'statute_reference': (
'FL 61.21 — Both parents must complete parenting class '
'before final hearing when minor children involved'
),
})
for rule in rules:
for rule in filing_rules:
if rule['deadline_type'] in existing_types:
continue
due = rule['anchor'] + relativedelta(days=rule['offset_days'])
@@ -150,47 +281,72 @@ class FlDeadline(models.Model):
@api.model
def recalculate_service_deadlines(self, case):
"""
Recalculate deadlines anchored to service_date when it is set.
Called from fl_case.write when service_date changes.
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_anchored = [
service_rules = [
# FL 1.140: Respondent has 20 days to answer after service
{
'name': 'Respondent Answer Deadline',
'name': 'Respondent Answer Deadline (FL 1.140)',
'deadline_type': 'response',
'offset_days': 20,
'statute_reference': 'FL 1.140 — 20 days to answer',
'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',
'name': 'Mandatory Financial Disclosure Exchange (FL 12.285)',
'deadline_type': 'financial_disclosure',
'offset_days': 45,
'statute_reference': 'FL 12.285 — 45 days',
'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': 'Discovery Opens (Case at Issue)',
'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',
'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_anchored.append({
'name': 'Parenting Class — Respondent (FL 61.21)',
service_rules.append({
'name': 'Parenting Class — Respondent Must Complete (FL 61.21)',
'deadline_type': 'parenting_class',
'offset_days': 60,
'statute_reference': 'FL 61.21',
'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_anchored:
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,
@@ -206,9 +362,18 @@ class FlDeadline(models.Model):
'statute_reference': rule.get('statute_reference', ''),
})
# ══════════════════════════════════════════════════════════════════════════
# CRON: Daily Deadline Alerts
# ══════════════════════════════════════════════════════════════════════════
def _cron_deadline_alerts(self):
"""Run daily — send deadline alerts at 7, 3, 1 days and overdue."""
"""
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),
@@ -236,13 +401,133 @@ class FlDeadline(models.Model):
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):
icon = '🔴' if timing == 'OVERDUE' else ''
"""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=(
f'{icon} <b>Deadline Alert — {timing}</b><br/>'
f'<b>{self.name}</b> is due on <b>{self.due_date}</b>.<br/>'
f'Statute: {self.statute_reference or "N/A"}'
'{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',
)

View File

@@ -1,28 +1,37 @@
from datetime import timedelta
from odoo import api, fields, models
from dateutil.relativedelta import relativedelta
class FlHearing(models.Model):
"""
Phase 2 — Full implementation.
Phase 1: Stub with fields needed by fl_case computed fields.
Phase 2 — Full implementation with calendar integration, workflow buttons,
and pre-hearing checklist for Florida family law hearings.
"""
_name = 'fl.hearing'
_description = 'Case Hearing'
_inherit = ['mail.thread']
_order = 'hearing_date asc'
_rec_name = 'name'
case_id = fields.Many2one(
'fl.case', required=True, ondelete='cascade', index=True
'fl.case', required=True, ondelete='cascade', index=True,
string='Case'
)
name = fields.Char(string='Hearing Description', required=True)
hearing_date = fields.Datetime(
string='Hearing Date / Time', tracking=True
)
duration_hours = fields.Float(
string='Duration (hours)', default=1.0,
help='Expected hearing duration. Default 1 hour.'
)
location = fields.Char(
string='Location',
default='Lawson E. Thomas Courthouse Center, 175 NW 1st Ave, Miami, FL 33128'
)
courtroom = fields.Char(string='Courtroom')
courtroom = fields.Char(string='Courtroom / Floor')
judge_id = fields.Many2one(
'res.partner', string='Judge',
related='case_id.judge_id', store=True
@@ -35,13 +44,283 @@ class FlHearing(models.Model):
('final', 'Final Hearing'),
('contempt', 'Contempt Hearing'),
('other', 'Other'),
], string='Hearing Type', default='final')
], string='Hearing Type', default='final', required=True, tracking=True)
state = fields.Selection([
('scheduled', 'Scheduled'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
('continued', 'Continued'),
], string='Status', default='scheduled', tracking=True)
notes = fields.Text(string='Notes')
notes = fields.Text(string='Pre-Hearing Notes / Preparation')
outcome = fields.Text(string='Outcome / Result')
order_entered = fields.Boolean(string='Order Entered')
order_entered = fields.Boolean(string='Order Entered After Hearing')
continued_date = fields.Datetime(string='New Date (if Continued)')
# ── Calendar Integration ──────────────────────────────────────────────────
calendar_event_id = fields.Many2one(
'calendar.event', string='Calendar Event', ondelete='set null'
)
# ── Pre-Hearing Checklist ─────────────────────────────────────────────────
parenting_class_warning = fields.Char(
string='Parenting Class Status',
compute='_compute_parenting_class_warning',
help='FL 61.21 — Both parents must complete before final hearing'
)
discovery_cutoff_warning = fields.Char(
string='Discovery Cutoff',
compute='_compute_discovery_cutoff_warning',
help='Discovery cutoff is 30 days before hearing date'
)
financial_disclosure_warning = fields.Char(
string='Financial Disclosure Status',
compute='_compute_financial_disclosure_warning',
)
# ══════════════════════════════════════════════════════════════════════════
# COMPUTED: Pre-Hearing Checks
# ══════════════════════════════════════════════════════════════════════════
@api.depends(
'case_id.petitioner_parenting_class_done',
'case_id.respondent_parenting_class_done',
'case_id.parenting_class_required',
)
def _compute_parenting_class_warning(self):
for rec in self:
case = rec.case_id
if not case.parenting_class_required:
rec.parenting_class_warning = '✅ Not required (no minor children)'
elif (case.petitioner_parenting_class_done
and case.respondent_parenting_class_done):
rec.parenting_class_warning = '✅ Both parties completed (FL 61.21)'
else:
missing = []
if not case.petitioner_parenting_class_done:
missing.append('Petitioner')
if not case.respondent_parenting_class_done:
missing.append('Respondent')
rec.parenting_class_warning = (
'⚠️ FL 61.21: Parenting class NOT completed by: {}. '
'Final hearing may not proceed without completion.'
).format(', '.join(missing))
@api.depends('hearing_date')
def _compute_discovery_cutoff_warning(self):
today = fields.Date.today()
for rec in self:
if not rec.hearing_date:
rec.discovery_cutoff_warning = '⚪ Set hearing date to see discovery cutoff'
continue
cutoff = rec.hearing_date.date() - relativedelta(days=30)
if today > cutoff:
rec.discovery_cutoff_warning = (
'🔴 Discovery cutoff PASSED ({}) — 30 days before hearing. '
'No new discovery requests may be served.'
).format(cutoff)
else:
days_left = (cutoff - today).days
rec.discovery_cutoff_warning = (
'✅ Discovery cutoff: {} ({} days remaining)'
).format(cutoff, days_left)
@api.depends('case_id.deadline_ids.completed', 'case_id.deadline_ids.deadline_type')
def _compute_financial_disclosure_warning(self):
for rec in self:
disc_deadline = rec.case_id.deadline_ids.filtered(
lambda d: d.deadline_type == 'financial_disclosure'
)
if not disc_deadline:
rec.financial_disclosure_warning = '⚪ No financial disclosure deadline set'
elif any(d.completed for d in disc_deadline):
rec.financial_disclosure_warning = '✅ Financial disclosure exchanged (FL 12.285)'
elif any(d.is_overdue for d in disc_deadline):
rec.financial_disclosure_warning = (
'🔴 OVERDUE: Financial disclosure not yet exchanged (FL 12.285)'
)
else:
due = min(d.due_date for d in disc_deadline)
rec.financial_disclosure_warning = (
'⏳ Financial disclosure due: {} (FL 12.285)'
).format(due)
# ══════════════════════════════════════════════════════════════════════════
# CRUD — calendar event lifecycle
# ══════════════════════════════════════════════════════════════════════════
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec in records:
if rec.hearing_date:
rec.with_context(_no_hearing_sync=True)._create_or_update_calendar_event()
rec._create_hearing_deadline()
return records
def write(self, vals):
result = super().write(vals)
if not self.env.context.get('_no_hearing_sync'):
sync_fields = {'hearing_date', 'name', 'state', 'duration_hours', 'location', 'courtroom'}
if sync_fields.intersection(vals.keys()):
for rec in self:
rec.with_context(_no_hearing_sync=True)._create_or_update_calendar_event()
return result
# ══════════════════════════════════════════════════════════════════════════
# CALENDAR INTEGRATION
# ══════════════════════════════════════════════════════════════════════════
def _create_or_update_calendar_event(self):
"""Create or sync a calendar.event for this hearing."""
if not self.hearing_date:
return
if self.state in ('cancelled',):
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
stop_dt = self.hearing_date + timedelta(hours=self.duration_hours or 1.0)
type_label = dict(self._fields['hearing_type'].selection).get(
self.hearing_type, 'Hearing'
)
event_name = '[{}] {}{}'.format(
self.case_id.name, type_label, self.name
)
description = (
'Florida Family Law Hearing\n'
'Case: {}\n'
'Type: {}\n'
'Location: {}\n'
'Courtroom: {}\n'
'Judge: {}\n'
'Status: {}'
).format(
self.case_id.name,
type_label,
self.location or 'TBD',
self.courtroom or 'TBD',
self.judge_id.name if self.judge_id else 'TBD',
dict(self._fields['state'].selection).get(self.state, ''),
)
if self.calendar_event_id:
self.calendar_event_id.write({
'name': event_name,
'start': self.hearing_date,
'stop': stop_dt,
'description': description,
'active': self.state != 'cancelled',
'show_as': 'busy',
})
else:
event = self.env['calendar.event'].sudo().create({
'name': event_name,
'start': self.hearing_date,
'stop': stop_dt,
'description': description,
'partner_ids': [(6, 0, partners.ids)],
'show_as': 'busy',
'privacy': 'confidential',
})
self.write({'calendar_event_id': event.id})
def _create_hearing_deadline(self):
"""
Create an fl.deadline 'hearing' record for this hearing
so it appears in the case deadline tracker.
"""
if not self.hearing_date:
return
existing = self.env['fl.deadline'].search([
('case_id', '=', self.case_id.id),
('deadline_type', '=', 'hearing'),
], limit=1)
if not existing:
self.env['fl.deadline'].create({
'case_id': self.case_id.id,
'name': '{} Hearing — {}'.format(
dict(self._fields['hearing_type'].selection).get(
self.hearing_type, 'Hearing'
),
self.name,
),
'deadline_type': 'hearing',
'due_date': self.hearing_date.date(),
'statute_reference': 'Florida Rules of Civil Procedure',
'notes': (
'Location: {}\n'
'Courtroom: {}'
).format(self.location or '', self.courtroom or 'TBD'),
})
# ══════════════════════════════════════════════════════════════════════════
# WORKFLOW BUTTONS
# ══════════════════════════════════════════════════════════════════════════
def action_mark_completed(self):
"""Mark hearing as completed and archive calendar event."""
self.write({'state': 'completed'})
if self.calendar_event_id:
self.calendar_event_id.write({'active': False})
# Update linked hearing deadline
self.env['fl.deadline'].search([
('case_id', '=', self.case_id.id),
('deadline_type', '=', 'hearing'),
]).write({'completed': True, 'completed_date': fields.Date.today()})
self.case_id.message_post(
body=(
'✅ <b>Hearing Completed</b><br/>'
'{}{}<br/>'
'<i>Update the Outcome field with the judge\'s ruling.</i>'
).format(
dict(self._fields['hearing_type'].selection).get(self.hearing_type, ''),
self.name,
),
subtype_xmlid='mail.mt_note',
)
def action_cancel(self):
"""Cancel this hearing and archive calendar event."""
self.write({'state': 'cancelled'})
if self.calendar_event_id:
self.calendar_event_id.write({'active': False})
self.case_id.message_post(
body=(
'❌ <b>Hearing Cancelled</b><br/>'
'{}{} has been cancelled.'
).format(
dict(self._fields['hearing_type'].selection).get(self.hearing_type, ''),
self.name,
),
subtype_xmlid='mail.mt_note',
)
def action_mark_continued(self):
"""
Mark hearing as continued (rescheduled by court).
User must enter the new hearing date separately.
"""
self.write({'state': 'continued'})
self.case_id.message_post(
body=(
'⏸️ <b>Hearing Continued</b><br/>'
'{}{} has been continued (rescheduled by the court).<br/>'
'<b>Action required:</b> Contact the court for the new hearing date '
'and create a new hearing record.'
).format(
dict(self._fields['hearing_type'].selection).get(self.hearing_type, ''),
self.name,
),
subtype_xmlid='mail.mt_note',
)