Files
famlaw/activeblue_familylaw/models/fl_hearing.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

327 lines
14 KiB
Python

from datetime import timedelta
from odoo import api, fields, models
from dateutil.relativedelta import relativedelta
class FlHearing(models.Model):
"""
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,
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 / Floor')
judge_id = fields.Many2one(
'res.partner', string='Judge',
related='case_id.judge_id', store=True
)
hearing_type = fields.Selection([
('status_conference', 'Status Conference'),
('motion', 'Motion Hearing'),
('temporary_relief', 'Temporary Relief Hearing'),
('mediation', 'Mediation'),
('final', 'Final Hearing'),
('contempt', 'Contempt Hearing'),
('other', 'Other'),
], 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='Pre-Hearing Notes / Preparation')
outcome = fields.Text(string='Outcome / Result')
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',
)