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:
@@ -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',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user