fl_intake_wizard.py:
- Full multi-step intake: parties, income, children, DV flag, fee
waiver, AI analysis option
- Creates res.partner → fl.party → fl.case chain (mirrors portal)
- Triggers fee waiver record creation and Ollama AI analysis
- Residency warning computed field (FL 61.021 — 6-month check)
fl_generate_packet_wizard.py:
- Generates selected documents as PDFs via _render_qweb_pdf
- Handles 4 binding models: fl.case, fl.party, fl.fee.waiver,
fl.support.calculation, fl.income.withholding
- Attaches generated PDFs to case chatter with summary note
- Bound to fl.case form as an action button
fl_analysis_wizard.py:
- Checks for recent analysis (<24h) before running new one
- force_reanalysis flag bypasses the lock
- Shows last analysis age label in form; opens result as dialog
- Bound to fl.case form as an action button
fl_case.py:
- _CASE_TASK_TEMPLATES: standard task lists for 6 case types
- _generate_case_tasks(): creates project.task records from templates
- Called automatically from _create_case_project on case creation
fl_wizard_views.xml:
- Form views for all 3 wizards with inline help text
- Packet wizard bound to fl.case form via binding_model_id
data/case_task_templates.xml:
- ir.config_parameter records for Ollama URL, model, deadline days,
mandatory disclosure days, AI lockout hours — all admin-configurable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1077 lines
51 KiB
Python
1077 lines
51 KiB
Python
from odoo import _, api, fields, models
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
|
|
class FlCase(models.Model):
|
|
_name = 'fl.case'
|
|
_description = 'Florida Family Law Case'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin', 'portal.mixin']
|
|
_order = 'create_date desc'
|
|
_rec_name = 'name'
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# IDENTITY
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
name = fields.Char(
|
|
string='Case Reference',
|
|
required=True,
|
|
copy=False,
|
|
readonly=True,
|
|
default=lambda self: _('New'),
|
|
help='Auto-generated internal reference (FL-YYYY-NNNNN)'
|
|
)
|
|
court_case_number = fields.Char(
|
|
string='Court Case Number',
|
|
tracking=True,
|
|
help='Assigned by Miami-Dade Clerk after filing. '
|
|
'Format: YYYY-DR-XXXXXX-XX (e.g. 2025-DR-012345-01)'
|
|
)
|
|
case_type = fields.Selection([
|
|
('modification', 'Child Support Modification'),
|
|
('dissolution_children', 'Dissolution of Marriage — With Children'),
|
|
('dissolution_no_children', 'Dissolution of Marriage — No Children'),
|
|
('paternity', 'Paternity'),
|
|
('alimony_modification', 'Alimony Modification'),
|
|
('custody_modification', 'Timesharing / Custody Modification'),
|
|
], string='Case Type', required=True, tracking=True)
|
|
|
|
circuit = fields.Char(
|
|
string='Circuit', default='11th', readonly=True,
|
|
help='11th Judicial Circuit — Miami-Dade County'
|
|
)
|
|
county = fields.Char(
|
|
string='County', default='Miami-Dade', readonly=True
|
|
)
|
|
division = fields.Selection([
|
|
('1', 'Division 1'), ('2', 'Division 2'),
|
|
('3', 'Division 3'), ('4', 'Division 4'),
|
|
('5', 'Division 5'), ('6', 'Division 6'),
|
|
('7', 'Division 7'), ('8', 'Division 8'),
|
|
], string='Court Division', tracking=True)
|
|
judge_id = fields.Many2one(
|
|
'res.partner', string='Assigned Judge',
|
|
domain=[('is_company', '=', False)]
|
|
)
|
|
mediator_id = fields.Many2one(
|
|
'res.partner', string='Mediator'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# STAGE / STATUS
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
stage = fields.Selection([
|
|
('intake', 'Intake & Qualification'),
|
|
('preparation', 'Document Preparation'),
|
|
('filed', 'Filed — Awaiting Service'),
|
|
('service_complete', 'Service Complete'),
|
|
('discovery', 'Discovery'),
|
|
('deposition', 'Deposition Stage'),
|
|
('mediation', 'Mediation'),
|
|
('hearing_scheduled', 'Hearing Scheduled'),
|
|
('order_entered', 'Order Entered'),
|
|
('closed', 'Closed'),
|
|
('referred_out', 'Referred to Attorney'),
|
|
], string='Stage', default='intake', tracking=True)
|
|
active = fields.Boolean(default=True)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# PARTIES
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
petitioner_id = fields.Many2one(
|
|
'res.partner', string='Petitioner',
|
|
required=True, tracking=True
|
|
)
|
|
respondent_id = fields.Many2one(
|
|
'res.partner', string='Respondent',
|
|
tracking=True
|
|
)
|
|
petitioner_attorney_id = fields.Many2one(
|
|
'res.partner', string='Petitioner Attorney',
|
|
help='Leave blank if petitioner is pro se'
|
|
)
|
|
respondent_attorney_id = fields.Many2one(
|
|
'res.partner', string='Respondent Attorney',
|
|
tracking=True,
|
|
help='If respondent has counsel, pro se petitioner faces significant disadvantage'
|
|
)
|
|
respondent_has_counsel = fields.Boolean(
|
|
string='Respondent Has Counsel',
|
|
compute='_compute_respondent_has_counsel', store=True
|
|
)
|
|
party_ids = fields.One2many(
|
|
'fl.party', 'case_id', string='Party Details'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# CHILDREN
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
child_ids = fields.One2many(
|
|
'fl.child', 'case_id', string='Children'
|
|
)
|
|
children_count = fields.Integer(
|
|
string='Number of Children',
|
|
compute='_compute_children_count', store=True
|
|
)
|
|
has_minor_children = fields.Boolean(
|
|
string='Has Minor Children',
|
|
compute='_compute_has_minor_children', store=True
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# KEY DATES
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
filing_date = fields.Date(
|
|
string='Filing Date', tracking=True
|
|
)
|
|
service_date = fields.Date(
|
|
string='Service Date', tracking=True
|
|
)
|
|
last_order_date = fields.Date(
|
|
string='Date of Last Order',
|
|
tracking=True,
|
|
help='Required for modification cases — date of the existing order being modified'
|
|
)
|
|
marriage_date = fields.Date(string='Date of Marriage')
|
|
separation_date = fields.Date(string='Date of Separation')
|
|
years_of_marriage = fields.Float(
|
|
string='Years of Marriage',
|
|
compute='_compute_years_of_marriage', store=True
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# RESIDENCY (FL 61.021)
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
petitioner_fl_resident_since = fields.Date(
|
|
string='Petitioner FL Resident Since',
|
|
help='FL 61.021: Must be FL resident for 6 months before filing'
|
|
)
|
|
residency_requirement_met = fields.Boolean(
|
|
string='Residency Requirement Met',
|
|
compute='_compute_residency_met', store=True
|
|
)
|
|
residency_warning = fields.Char(
|
|
string='Residency Warning',
|
|
compute='_compute_residency_met'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# SAFETY FLAGS
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
domestic_violence_flag = fields.Boolean(
|
|
string='Domestic Violence Present',
|
|
tracking=True,
|
|
help='FL 741.30 / FL 61.13(2)(c). '
|
|
'Affects mediation (separate rooms required), timesharing, '
|
|
'and ALL court proceedings. Forces attorney referral.'
|
|
)
|
|
dv_injunction_active = fields.Boolean(
|
|
string='Active Injunction (FL 741.30)'
|
|
)
|
|
dv_safety_note = fields.Html(
|
|
string='Safety Information',
|
|
compute='_compute_dv_safety_note'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# EXISTING ORDER (MODIFICATION CASES)
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
current_order_amount = fields.Float(
|
|
string='Current Monthly Support Order ($)',
|
|
tracking=True,
|
|
help='Amount in the existing court order that is being modified'
|
|
)
|
|
current_order_per_child = fields.Boolean(
|
|
string='Amount is Per-Child (not total)',
|
|
help='Check if the existing order states an amount per child rather than total'
|
|
)
|
|
current_order_total = fields.Float(
|
|
string='Current Order Total ($)',
|
|
compute='_compute_current_order_total', store=True
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# CHILD SUPPORT CALCULATION (FL 61.30)
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
support_calc_id = fields.Many2one(
|
|
'fl.support.calculation',
|
|
string='Active Support Calculation',
|
|
ondelete='cascade'
|
|
)
|
|
calculated_support = fields.Float(
|
|
string='Calculated Support ($)',
|
|
related='support_calc_id.total_support_obligation',
|
|
store=True
|
|
)
|
|
support_difference = fields.Float(
|
|
string='Difference from Current Order ($)',
|
|
compute='_compute_support_difference', store=True
|
|
)
|
|
support_difference_pct = fields.Float(
|
|
string='Difference %',
|
|
compute='_compute_support_difference', store=True
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# MODIFICATION THRESHOLD TEST (FL 61.30(1)(b))
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
threshold_met = fields.Boolean(
|
|
string='Modification Threshold Met',
|
|
compute='_compute_threshold', store=True,
|
|
help='FL 61.30(1)(b): Modification qualifies when change is '
|
|
'both ≥ $50 AND ≥ 15% of the current order'
|
|
)
|
|
threshold_result = fields.Html(
|
|
string='Threshold Analysis',
|
|
compute='_compute_threshold'
|
|
)
|
|
substantial_change_basis = fields.Selection([
|
|
('income_decrease_petitioner', 'Petitioner Income Decreased'),
|
|
('income_decrease_respondent', 'Respondent Income Decreased'),
|
|
('income_increase_petitioner', 'Petitioner Income Increased'),
|
|
('income_increase_respondent', 'Respondent Income Increased'),
|
|
('timesharing_change', 'Timesharing Schedule Changed'),
|
|
('child_needs_change', "Child's Needs Changed"),
|
|
('child_emancipation', 'Child Reaching 18 / Emancipation'),
|
|
('other', 'Other Substantial Change'),
|
|
], string='Basis for Modification (FL 61.14)')
|
|
substantial_change_detail = fields.Text(
|
|
string='Describe the Substantial Change'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# TIMESHARING
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
timesharing_changed = fields.Boolean(
|
|
string='Timesharing Schedule Has Changed'
|
|
)
|
|
petitioner_overnights = fields.Integer(
|
|
string='Petitioner Overnights / Year',
|
|
help='Number of overnights per year the petitioner has with the children'
|
|
)
|
|
respondent_overnights = fields.Integer(
|
|
string='Respondent Overnights / Year',
|
|
compute='_compute_respondent_overnights', store=True
|
|
)
|
|
petitioner_timesharing_pct = fields.Float(
|
|
string='Petitioner Timesharing %',
|
|
compute='_compute_timesharing_pct', store=True
|
|
)
|
|
substantial_timesharing_applies = fields.Boolean(
|
|
string='Substantial Timesharing Adjustment Applies',
|
|
compute='_compute_timesharing_pct', store=True,
|
|
help='FL 61.30(11)(b): Applies if either parent has > 73 overnights/year (20%)'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# FEE WAIVER (FL 57.082)
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
fee_waiver_eligible = fields.Boolean(
|
|
string='Fee Waiver Potentially Eligible',
|
|
compute='_compute_fee_waiver_eligibility', store=True,
|
|
help='Based on petitioner income vs 200% FPL (FL 57.082)'
|
|
)
|
|
fee_waiver_id = fields.Many2one(
|
|
'fl.fee.waiver', string='Fee Waiver Application'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# PARENTING CLASS (FL 61.21)
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
parenting_class_required = fields.Boolean(
|
|
string='Parenting Class Required',
|
|
compute='_compute_parenting_class_required', store=True,
|
|
help='FL 61.21: Mandatory when minor children are involved'
|
|
)
|
|
petitioner_parenting_class_done = fields.Boolean(
|
|
string='Petitioner Parenting Class Complete'
|
|
)
|
|
petitioner_parenting_class_date = fields.Date(
|
|
string='Petitioner Completion Date'
|
|
)
|
|
respondent_parenting_class_done = fields.Boolean(
|
|
string='Respondent Parenting Class Complete'
|
|
)
|
|
respondent_parenting_class_date = fields.Date(
|
|
string='Respondent Completion Date'
|
|
)
|
|
parenting_class_warning = fields.Char(
|
|
string='Parenting Class Status',
|
|
compute='_compute_parenting_class_warning'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# DEADLINES & CALENDAR
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
deadline_ids = fields.One2many(
|
|
'fl.deadline', 'case_id', string='Deadlines'
|
|
)
|
|
next_deadline = fields.Date(
|
|
string='Next Deadline',
|
|
compute='_compute_next_deadline', store=True
|
|
)
|
|
next_deadline_label = fields.Char(
|
|
string='Next Deadline Description',
|
|
compute='_compute_next_deadline'
|
|
)
|
|
overdue_deadline_count = fields.Integer(
|
|
string='Overdue Deadlines',
|
|
compute='_compute_next_deadline'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# HEARINGS
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
hearing_ids = fields.One2many(
|
|
'fl.hearing', 'case_id', string='Hearings'
|
|
)
|
|
next_hearing_date = fields.Datetime(
|
|
string='Next Hearing',
|
|
compute='_compute_next_hearing', store=True
|
|
)
|
|
discovery_cutoff_date = fields.Date(
|
|
string='Discovery Cutoff',
|
|
compute='_compute_discovery_cutoff', store=True,
|
|
help='30 days before next hearing date'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# DEPOSITIONS & DISCOVERY
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
income_disputed = fields.Boolean(
|
|
string='Income Figures Disputed',
|
|
tracking=True,
|
|
help='If True, deposition workflow is activated'
|
|
)
|
|
deposition_ids = fields.One2many(
|
|
'fl.deposition', 'case_id', string='Depositions'
|
|
)
|
|
discovery_ids = fields.One2many(
|
|
'fl.discovery', 'case_id', string='Discovery Items'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# DOCUMENTS
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
document_ids = fields.One2many(
|
|
'fl.document', 'case_id', string='Case Documents'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# PROJECT / TASK INTEGRATION
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
project_id = fields.Many2one(
|
|
'project.project', string='Case Project',
|
|
help='Auto-created project for case task management'
|
|
)
|
|
task_count = fields.Integer(
|
|
string='Tasks', compute='_compute_task_count'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# CASE LAW & AI ANALYSIS
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
caselaw_ids = fields.Many2many(
|
|
'fl.caselaw', string='Applicable Case Law'
|
|
)
|
|
analysis_ids = fields.One2many(
|
|
'fl.analysis', 'case_id', string='AI Analyses'
|
|
)
|
|
latest_analysis_id = fields.Many2one(
|
|
'fl.analysis',
|
|
string='Latest Analysis',
|
|
compute='_compute_latest_analysis', store=True
|
|
)
|
|
attorney_referral_flag = fields.Boolean(
|
|
string='Attorney Referral Recommended',
|
|
compute='_compute_attorney_referral_flag', store=True
|
|
)
|
|
ai_plain_english = fields.Text(
|
|
string='AI Summary (English)',
|
|
related='latest_analysis_id.plain_english_summary'
|
|
)
|
|
ai_plain_english_es = fields.Text(
|
|
string='AI Summary (Spanish)',
|
|
related='latest_analysis_id.plain_english_summary_es'
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# EXPENSES
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
expense_ids = fields.One2many(
|
|
'hr.expense', 'fl_case_id', string='Case Expenses'
|
|
)
|
|
total_expenses = fields.Float(
|
|
string='Total Case Expenses ($)',
|
|
compute='_compute_total_expenses', store=True
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# POST-ORDER
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
new_order_amount = fields.Float(
|
|
string='New Order Amount ($)', tracking=True
|
|
)
|
|
new_order_date = fields.Date(
|
|
string='New Order Date', tracking=True
|
|
)
|
|
income_withholding_id = fields.Many2one(
|
|
'fl.income.withholding',
|
|
string='Income Withholding Order'
|
|
)
|
|
retroactivity_date = fields.Date(
|
|
string='Retroactivity Date',
|
|
compute='_compute_retroactivity_date', store=True,
|
|
help='FL 61.30(17): Modification is retroactive to filing date ONLY. '
|
|
'Cannot seek support modification for periods before filing.'
|
|
)
|
|
respondent_answered = fields.Boolean(
|
|
string='Respondent Filed Answer',
|
|
tracking=True
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# COMPUTED METHODS
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
@api.depends('respondent_attorney_id')
|
|
def _compute_respondent_has_counsel(self):
|
|
for rec in self:
|
|
rec.respondent_has_counsel = bool(rec.respondent_attorney_id)
|
|
|
|
@api.depends('child_ids')
|
|
def _compute_children_count(self):
|
|
for rec in self:
|
|
rec.children_count = len(rec.child_ids)
|
|
|
|
@api.depends('child_ids.emancipated', 'child_ids.date_of_birth')
|
|
def _compute_has_minor_children(self):
|
|
for rec in self:
|
|
rec.has_minor_children = any(
|
|
not c.emancipated for c in rec.child_ids
|
|
)
|
|
|
|
@api.depends('marriage_date', 'separation_date', 'filing_date')
|
|
def _compute_years_of_marriage(self):
|
|
for rec in self:
|
|
end_date = rec.separation_date or rec.filing_date
|
|
if rec.marriage_date and end_date:
|
|
delta = relativedelta(end_date, rec.marriage_date)
|
|
rec.years_of_marriage = round(
|
|
delta.years + delta.months / 12 + delta.days / 365, 2
|
|
)
|
|
else:
|
|
rec.years_of_marriage = 0.0
|
|
|
|
@api.depends('petitioner_fl_resident_since', 'filing_date')
|
|
def _compute_residency_met(self):
|
|
for rec in self:
|
|
if rec.petitioner_fl_resident_since and rec.filing_date:
|
|
required_date = rec.petitioner_fl_resident_since + relativedelta(months=6)
|
|
rec.residency_requirement_met = rec.filing_date >= required_date
|
|
if not rec.residency_requirement_met:
|
|
months_short = relativedelta(required_date, rec.filing_date)
|
|
rec.residency_warning = (
|
|
f'⚠️ FL 61.021: Petitioner must be a FL resident for 6 months '
|
|
f'before filing. Currently {months_short.months} month(s) short.'
|
|
)
|
|
else:
|
|
rec.residency_warning = '✅ Residency requirement met (FL 61.021)'
|
|
elif rec.petitioner_fl_resident_since and not rec.filing_date:
|
|
rec.residency_requirement_met = False
|
|
rec.residency_warning = 'Enter filing date to verify residency requirement'
|
|
else:
|
|
rec.residency_requirement_met = False
|
|
rec.residency_warning = '⚠️ Enter FL residency start date to verify FL 61.021'
|
|
|
|
@api.depends('domestic_violence_flag', 'dv_injunction_active')
|
|
def _compute_dv_safety_note(self):
|
|
for rec in self:
|
|
if rec.domestic_violence_flag:
|
|
injunction_note = (
|
|
'<br/><b>Active injunction on file.</b>'
|
|
if rec.dv_injunction_active else ''
|
|
)
|
|
rec.dv_safety_note = (
|
|
'<div style="background:#ffeeba;border:2px solid #e6ac00;'
|
|
'padding:12px;border-radius:4px;">'
|
|
'<h4 style="color:#856404;margin:0 0 8px 0;">'
|
|
'⚠️ DOMESTIC VIOLENCE SAFETY NOTICE</h4>'
|
|
'<p>Domestic violence has been flagged in this case. '
|
|
'This affects your rights and safety throughout this proceeding.</p>'
|
|
'<ul>'
|
|
'<li><b>Mediation:</b> Separate rooms are REQUIRED (FL 44.102)</li>'
|
|
'<li><b>Representation:</b> Pro se representation is strongly discouraged</li>'
|
|
'<li><b>Contact:</b> Do not communicate with the opposing party directly</li>'
|
|
'</ul>'
|
|
f'{injunction_note}'
|
|
'<p><b>Resources:</b><br/>'
|
|
'Legal Services of Greater Miami: <a href="tel:3055760080">(305) 576-0080</a><br/>'
|
|
'Safespace Miami: <a href="tel:3055365565">(305) 536-5565</a><br/>'
|
|
'National DV Hotline: <a href="tel:18007997233">1-800-799-7233</a></p>'
|
|
'</div>'
|
|
)
|
|
else:
|
|
rec.dv_safety_note = ''
|
|
|
|
@api.depends('current_order_amount', 'current_order_per_child', 'children_count')
|
|
def _compute_current_order_total(self):
|
|
for rec in self:
|
|
if rec.current_order_per_child and rec.children_count:
|
|
rec.current_order_total = (
|
|
rec.current_order_amount * rec.children_count
|
|
)
|
|
else:
|
|
rec.current_order_total = rec.current_order_amount
|
|
|
|
@api.depends('calculated_support', 'current_order_total')
|
|
def _compute_support_difference(self):
|
|
for rec in self:
|
|
current = rec.current_order_total or 0.0
|
|
proposed = rec.calculated_support or 0.0
|
|
rec.support_difference = proposed - current
|
|
if current > 0:
|
|
rec.support_difference_pct = (
|
|
abs(proposed - current) / current
|
|
) * 100
|
|
else:
|
|
rec.support_difference_pct = 0.0
|
|
|
|
@api.depends('support_difference', 'support_difference_pct', 'case_type')
|
|
def _compute_threshold(self):
|
|
"""
|
|
FL 61.30(1)(b): Modification qualifies when BOTH:
|
|
- |difference| >= $50 AND
|
|
- |difference| / current_amount >= 15%
|
|
"""
|
|
for rec in self:
|
|
if rec.case_type != 'modification':
|
|
rec.threshold_met = False
|
|
rec.threshold_result = ''
|
|
continue
|
|
|
|
diff = abs(rec.support_difference)
|
|
pct = rec.support_difference_pct
|
|
current = rec.current_order_total or 0.0
|
|
|
|
dollar_test = diff >= 50.0
|
|
pct_test = pct >= 15.0
|
|
|
|
if current == 0:
|
|
rec.threshold_met = False
|
|
rec.threshold_result = (
|
|
'<span style="color:orange;">⚠️ Enter current order amount '
|
|
'to run threshold test</span>'
|
|
)
|
|
continue
|
|
|
|
color_dollar = '#28a745' if dollar_test else '#dc3545'
|
|
color_pct = '#28a745' if pct_test else '#dc3545'
|
|
|
|
direction = 'increase' if rec.support_difference > 0 else 'decrease'
|
|
|
|
rec.threshold_met = dollar_test and pct_test
|
|
overall_color = '#28a745' if rec.threshold_met else '#dc3545'
|
|
overall_icon = '✅' if rec.threshold_met else '❌'
|
|
overall_label = 'QUALIFIES FOR MODIFICATION' if rec.threshold_met else 'DOES NOT QUALIFY'
|
|
|
|
rec.threshold_result = (
|
|
f'<div style="border:1px solid {overall_color};padding:10px;'
|
|
f'border-radius:4px;">'
|
|
f'<b style="color:{overall_color};">{overall_icon} {overall_label}</b>'
|
|
f'<br/><br/>'
|
|
f'Current order: <b>${current:,.2f}/mo</b><br/>'
|
|
f'Proposed: <b>${rec.calculated_support:,.2f}/mo</b> '
|
|
f'({direction} of <b>${diff:,.2f}</b>)<br/><br/>'
|
|
f'<span style="color:{color_dollar};">'
|
|
f'{"✅" if dollar_test else "❌"} $50 test: '
|
|
f'${diff:,.2f} {"≥" if dollar_test else "<"} $50</span><br/>'
|
|
f'<span style="color:{color_pct};">'
|
|
f'{"✅" if pct_test else "❌"} 15% test: '
|
|
f'{pct:.1f}% {"≥" if pct_test else "<"} 15%</span>'
|
|
f'</div>'
|
|
)
|
|
|
|
@api.depends('petitioner_overnights')
|
|
def _compute_respondent_overnights(self):
|
|
for rec in self:
|
|
if rec.petitioner_overnights:
|
|
rec.respondent_overnights = 365 - rec.petitioner_overnights
|
|
else:
|
|
rec.respondent_overnights = 0
|
|
|
|
@api.depends('petitioner_overnights')
|
|
def _compute_timesharing_pct(self):
|
|
for rec in self:
|
|
if rec.petitioner_overnights:
|
|
pct = rec.petitioner_overnights / 365
|
|
rec.petitioner_timesharing_pct = round(pct * 100, 2)
|
|
# FL 61.30(11)(b): substantial = either parent > 73 overnights (20%)
|
|
pet_substantial = rec.petitioner_overnights > 73
|
|
resp_substantial = (365 - rec.petitioner_overnights) > 73
|
|
rec.substantial_timesharing_applies = pet_substantial or resp_substantial
|
|
else:
|
|
rec.petitioner_timesharing_pct = 0.0
|
|
rec.substantial_timesharing_applies = False
|
|
|
|
@api.depends('party_ids.effective_monthly_income', 'party_ids.role')
|
|
def _compute_fee_waiver_eligibility(self):
|
|
"""
|
|
Quick eligibility check: petitioner income < 200% FPL (FL 57.082).
|
|
Full determination via fl.fee.waiver model.
|
|
2025: 1-person 200% FPL = $30,120/yr = $2,510/mo
|
|
"""
|
|
FPL_200PCT_1PERSON_MONTHLY = 2510.0
|
|
|
|
for rec in self:
|
|
petitioner_party = rec.party_ids.filtered(
|
|
lambda p: p.role == 'petitioner'
|
|
)
|
|
if petitioner_party:
|
|
income = petitioner_party[0].effective_monthly_income or 0.0
|
|
rec.fee_waiver_eligible = income <= FPL_200PCT_1PERSON_MONTHLY
|
|
else:
|
|
rec.fee_waiver_eligible = False
|
|
|
|
@api.depends('has_minor_children')
|
|
def _compute_parenting_class_required(self):
|
|
for rec in self:
|
|
rec.parenting_class_required = rec.has_minor_children
|
|
|
|
@api.depends(
|
|
'parenting_class_required',
|
|
'petitioner_parenting_class_done',
|
|
'respondent_parenting_class_done',
|
|
)
|
|
def _compute_parenting_class_warning(self):
|
|
for rec in self:
|
|
if not rec.parenting_class_required:
|
|
rec.parenting_class_warning = 'Not required'
|
|
continue
|
|
pet_done = rec.petitioner_parenting_class_done
|
|
resp_done = rec.respondent_parenting_class_done
|
|
if pet_done and resp_done:
|
|
rec.parenting_class_warning = '✅ Both parents completed'
|
|
elif pet_done:
|
|
rec.parenting_class_warning = '⚠️ Respondent parenting class pending'
|
|
elif resp_done:
|
|
rec.parenting_class_warning = '⚠️ Petitioner parenting class pending'
|
|
else:
|
|
rec.parenting_class_warning = '❌ Neither parent has completed parenting class'
|
|
|
|
@api.depends('deadline_ids.due_date', 'deadline_ids.completed', 'deadline_ids.waived')
|
|
def _compute_next_deadline(self):
|
|
today = fields.Date.today()
|
|
for rec in self:
|
|
pending = rec.deadline_ids.filtered(
|
|
lambda d: not d.completed and not d.waived
|
|
)
|
|
overdue = pending.filtered(lambda d: d.due_date < today)
|
|
upcoming = pending.filtered(
|
|
lambda d: d.due_date >= today
|
|
).sorted('due_date')
|
|
|
|
rec.overdue_deadline_count = len(overdue)
|
|
if upcoming:
|
|
next_dl = upcoming[0]
|
|
rec.next_deadline = next_dl.due_date
|
|
rec.next_deadline_label = next_dl.name
|
|
elif overdue:
|
|
oldest = overdue.sorted('due_date')[0]
|
|
rec.next_deadline = oldest.due_date
|
|
rec.next_deadline_label = f'OVERDUE: {oldest.name}'
|
|
else:
|
|
rec.next_deadline = False
|
|
rec.next_deadline_label = 'No pending deadlines'
|
|
|
|
@api.depends('hearing_ids.hearing_date')
|
|
def _compute_next_hearing(self):
|
|
now = fields.Datetime.now()
|
|
for rec in self:
|
|
future = rec.hearing_ids.filtered(
|
|
lambda h: h.hearing_date and h.hearing_date > now
|
|
and h.state not in ('cancelled',)
|
|
).sorted('hearing_date')
|
|
rec.next_hearing_date = future[0].hearing_date if future else False
|
|
|
|
@api.depends('next_hearing_date')
|
|
def _compute_discovery_cutoff(self):
|
|
for rec in self:
|
|
if rec.next_hearing_date:
|
|
rec.discovery_cutoff_date = (
|
|
rec.next_hearing_date.date() - relativedelta(days=30)
|
|
)
|
|
else:
|
|
rec.discovery_cutoff_date = False
|
|
|
|
def _compute_task_count(self):
|
|
for rec in self:
|
|
if rec.project_id:
|
|
rec.task_count = self.env['project.task'].search_count([
|
|
('project_id', '=', rec.project_id.id)
|
|
])
|
|
else:
|
|
rec.task_count = 0
|
|
|
|
@api.depends('analysis_ids')
|
|
def _compute_latest_analysis(self):
|
|
for rec in self:
|
|
latest = rec.analysis_ids.sorted('create_date', reverse=True)
|
|
rec.latest_analysis_id = latest[0] if latest else False
|
|
|
|
@api.depends('latest_analysis_id.attorney_referral_flag', 'domestic_violence_flag', 'respondent_has_counsel')
|
|
def _compute_attorney_referral_flag(self):
|
|
for rec in self:
|
|
rec.attorney_referral_flag = (
|
|
rec.domestic_violence_flag
|
|
or rec.respondent_has_counsel
|
|
or (rec.latest_analysis_id and rec.latest_analysis_id.attorney_referral_flag)
|
|
)
|
|
|
|
@api.depends('expense_ids.total_amount')
|
|
def _compute_total_expenses(self):
|
|
for rec in self:
|
|
rec.total_expenses = sum(rec.expense_ids.mapped('total_amount'))
|
|
|
|
@api.depends('filing_date')
|
|
def _compute_retroactivity_date(self):
|
|
"""
|
|
FL 61.30(17): Modification is retroactive to the filing date ONLY.
|
|
"""
|
|
for rec in self:
|
|
rec.retroactivity_date = rec.filing_date
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# CRUD OVERRIDES
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
if vals.get('name', _('New')) == _('New'):
|
|
vals['name'] = self.env['ir.sequence'].next_by_code(
|
|
'fl.case'
|
|
) or _('New')
|
|
records = super().create(vals_list)
|
|
for record in records:
|
|
# 1. Create Odoo project for task management
|
|
record._create_case_project()
|
|
# 2. Generate deadlines if filing date already set
|
|
if record.filing_date:
|
|
self.env['fl.deadline'].generate_deadlines_for_case(record)
|
|
# 3. Check fee waiver eligibility (quick check)
|
|
record._check_fee_waiver_eligibility()
|
|
# 4. Handle DV flag
|
|
if record.domestic_violence_flag:
|
|
record._handle_dv_flag()
|
|
return records
|
|
|
|
def write(self, vals):
|
|
result = super().write(vals)
|
|
# Recalculate service-anchored deadlines when service_date is set/changed
|
|
if 'service_date' in vals:
|
|
for rec in self:
|
|
if rec.service_date:
|
|
self.env['fl.deadline'].recalculate_service_deadlines(rec)
|
|
# Handle DV flag being set
|
|
if vals.get('domestic_violence_flag'):
|
|
for rec in self:
|
|
if rec.domestic_violence_flag:
|
|
rec._handle_dv_flag()
|
|
# Generate income withholding order when new_order_amount is set
|
|
if vals.get('new_order_amount') and vals.get('new_order_date'):
|
|
for rec in self:
|
|
rec._check_generate_income_withholding()
|
|
return result
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# WORKFLOW METHODS
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
# Standard task templates keyed by case_type.
|
|
# Each entry: (name, description, sequence)
|
|
_CASE_TASK_TEMPLATES = {
|
|
'modification': [
|
|
('Gather Financial Documents',
|
|
'Collect last 3 years tax returns, pay stubs, bank statements '
|
|
'(FL 12.285 mandatory disclosure — 45 days from service)',
|
|
10),
|
|
('Complete Financial Affidavit',
|
|
'Complete FL-12.902(b) Short Form or FL-12.902(c) Long Form '
|
|
'financial affidavit (required for support proceedings)',
|
|
20),
|
|
('Calculate New Support Amount',
|
|
'Run FL 61.30 child support calculation using updated income figures. '
|
|
'Verify modification threshold: 15% AND $50 difference (FL 61.30(1)(b))',
|
|
30),
|
|
('File Supplemental Petition',
|
|
'File FL-12.905 Supplemental Petition to Modify Child Support '
|
|
'with the clerk and pay filing fee (or submit fee waiver)',
|
|
40),
|
|
('Serve Respondent',
|
|
'Serve Respondent with Summons + Petition. '
|
|
'Start 20-day answer deadline clock (FL 12.285, Rule 1.070)',
|
|
50),
|
|
('Track Answer Deadline',
|
|
'Monitor 20-day answer deadline. If no response, prepare '
|
|
'FL 12.922 default motion packet after deadline passes.',
|
|
60),
|
|
('Schedule / Attend Mediation',
|
|
'Attend court-ordered mediation (required in most family cases). '
|
|
'Confirm separate rooms if DV flag is set (FL 44.102)',
|
|
70),
|
|
('Attend Final Hearing',
|
|
'Appear at final hearing with all documents. '
|
|
'Bring original + 2 copies of all filed pleadings.',
|
|
80),
|
|
],
|
|
'dissolution_children': [
|
|
('Gather Financial Documents', 'FL 12.285 mandatory disclosure package', 10),
|
|
('Complete Financial Affidavit (Long Form)',
|
|
'FL-12.902(c) required when income > $50,000/year', 20),
|
|
('Calculate Child Support', 'FL 61.30 worksheet (FL-12.902(e))', 30),
|
|
('Draft Parenting Plan', 'FL-12.995(a) Parenting Plan — timesharing schedule', 40),
|
|
('File Petition for Dissolution', 'FL-12.901(b)(1) with all required attachments', 50),
|
|
('Serve Respondent', 'Serve Summons + Petition; track 20-day answer deadline', 60),
|
|
('Attend Parenting Class',
|
|
'FL 61.21 — both parties must complete before final hearing', 70),
|
|
('Schedule Mediation', 'Court-ordered mediation (FL 44.102)', 80),
|
|
('Attend Final Hearing', 'Bring parenting plan, support worksheet, all exhibits', 90),
|
|
],
|
|
'dissolution_no_children': [
|
|
('Gather Financial Documents', 'FL 12.285 mandatory disclosure', 10),
|
|
('Complete Financial Affidavit', 'FL-12.902(b) Short Form', 20),
|
|
('Identify and Value Marital Assets', 'Real property, accounts, retirement funds', 30),
|
|
('File Petition for Dissolution', 'FL-12.901(b)(2) — no minor children', 40),
|
|
('Serve Respondent', 'Serve Summons + Petition; track 20-day answer deadline', 50),
|
|
('Schedule Mediation', 'Property division mediation if contested', 60),
|
|
('Attend Final Hearing', 'Bring financial affidavit, marital settlement agreement', 70),
|
|
],
|
|
'paternity': [
|
|
('Gather Birth Records', 'Obtain certified birth certificate', 10),
|
|
('Complete Financial Affidavit', 'FL-12.902(b) required for support', 20),
|
|
('Calculate Child Support', 'FL 61.30 worksheet', 30),
|
|
('File Petition to Determine Paternity', 'FL-12.983(a)', 40),
|
|
('Serve Respondent', 'Summons + Petition; 20-day deadline', 50),
|
|
('Draft Parenting Plan', 'FL-12.995(a) if timesharing is requested', 60),
|
|
('Attend Parenting Class', 'FL 61.21 — required before final hearing', 70),
|
|
('Attend Final Hearing', 'Bring all documents and parenting plan', 80),
|
|
],
|
|
'alimony_modification': [
|
|
('Gather Financial Documents', 'Tax returns, pay stubs — show substantial change', 10),
|
|
('Complete Financial Affidavit (Long Form)', 'FL-12.902(c) required', 20),
|
|
('Document Change in Circumstances',
|
|
'Document the substantial change in circumstances required for modification', 30),
|
|
('File Supplemental Petition', 'FL-12.905 Supplemental Petition to Modify Alimony', 40),
|
|
('Serve Respondent', 'Summons + Petition; track 20-day answer deadline', 50),
|
|
('Schedule Mediation', 'FL 44.102 mediation', 60),
|
|
('Attend Final Hearing', 'Bring all financial documents and exhibits', 70),
|
|
],
|
|
'custody_modification': [
|
|
('Document Changed Circumstances',
|
|
'Substantial change must affect child welfare — document thoroughly', 10),
|
|
('Gather Supporting Evidence', 'School records, medical records, witness statements', 20),
|
|
('Complete Financial Affidavit', 'FL-12.902(b) if support change is involved', 30),
|
|
('Draft Proposed Parenting Plan', 'FL-12.995(a) — proposed new timesharing', 40),
|
|
('File Supplemental Petition', 'FL-12.905 Supplemental Petition to Modify Custody', 50),
|
|
('Serve Respondent', 'Summons + Petition; track 20-day answer deadline', 60),
|
|
('Attend Parenting Class', 'FL 61.21 if not already completed', 70),
|
|
('Schedule Mediation', 'Court-ordered mediation (FL 44.102)', 80),
|
|
('Attend Final Hearing', 'Bring parenting plan, evidence, exhibits', 90),
|
|
],
|
|
}
|
|
|
|
def _create_case_project(self):
|
|
"""Create a linked Odoo project for case task management."""
|
|
project = self.env['project.project'].create({
|
|
'name': f'Case: {self.name} — {self.petitioner_id.name}',
|
|
'partner_id': self.petitioner_id.id,
|
|
'description': (
|
|
f'Family law case project for {self.petitioner_id.name}. '
|
|
f'Case type: {dict(self._fields["case_type"].selection).get(self.case_type, "")}'
|
|
),
|
|
})
|
|
self.project_id = project
|
|
self._generate_case_tasks()
|
|
|
|
def _generate_case_tasks(self):
|
|
"""
|
|
Create standard project tasks for this case based on its case_type.
|
|
Templates are defined in _CASE_TASK_TEMPLATES above.
|
|
"""
|
|
if not self.project_id:
|
|
return
|
|
templates = self._CASE_TASK_TEMPLATES.get(self.case_type, [])
|
|
task_vals_list = []
|
|
for name, description, sequence in templates:
|
|
task_vals_list.append({
|
|
'name': name,
|
|
'description': description,
|
|
'project_id': self.project_id.id,
|
|
'sequence': sequence,
|
|
})
|
|
if task_vals_list:
|
|
self.env['project.task'].create(task_vals_list)
|
|
|
|
def _check_fee_waiver_eligibility(self):
|
|
"""Post a chatter note if petitioner appears to qualify for fee waiver."""
|
|
if self.fee_waiver_eligible and not self.fee_waiver_id:
|
|
self.message_post(
|
|
body=(
|
|
'💡 <b>Fee Waiver Opportunity:</b> Based on the income information '
|
|
'provided, this petitioner may qualify for a civil indigent fee '
|
|
'waiver (FL 57.082). This would waive filing fees, service fees, '
|
|
'and potentially mediator fees. '
|
|
'Consider creating a Fee Waiver application from the case form.'
|
|
),
|
|
subtype_xmlid='mail.mt_note',
|
|
)
|
|
|
|
def _handle_dv_flag(self):
|
|
"""
|
|
Handle domestic violence flag being set on a case.
|
|
Per spec: Force attorney referral, post safety resources,
|
|
require separate mediation rooms, disable direct contact.
|
|
"""
|
|
self.message_post(
|
|
body=(
|
|
'<div style="background:#f8d7da;border:2px solid #dc3545;'
|
|
'padding:12px;border-radius:4px;">'
|
|
'<h4 style="color:#721c24;margin:0 0 8px 0;">'
|
|
'🚨 SAFETY ALERT — DOMESTIC VIOLENCE FLAGGED</h4>'
|
|
'<ul>'
|
|
'<li>Separate mediation rooms are <b>REQUIRED</b> (FL 44.102)</li>'
|
|
'<li>Pro se representation in DV cases is <b>STRONGLY DISCOURAGED</b></li>'
|
|
'<li>Attorney referral has been flagged on this case</li>'
|
|
'<li>Do NOT allow direct portal contact between parties</li>'
|
|
'</ul>'
|
|
'<b>Resources:</b><br/>'
|
|
'Legal Services of Greater Miami: (305) 576-0080<br/>'
|
|
'Safespace Miami: (305) 536-5565<br/>'
|
|
'National DV Hotline: 1-800-799-7233<br/>'
|
|
'TTY: 1-800-787-3224'
|
|
'</div>'
|
|
),
|
|
subtype_xmlid='mail.mt_note',
|
|
)
|
|
|
|
def _check_generate_income_withholding(self):
|
|
"""
|
|
FL 61.1301: Auto-generate income withholding order when new order amount is set.
|
|
Mandatory unless good cause or written agreement.
|
|
"""
|
|
if self.income_withholding_id:
|
|
return # Already exists
|
|
iwo = self.env['fl.income.withholding'].create({
|
|
'case_id': self.id,
|
|
'obligor_id': self.respondent_id.id,
|
|
'obligee_id': self.petitioner_id.id,
|
|
'monthly_support_amount': self.new_order_amount,
|
|
})
|
|
self.income_withholding_id = iwo
|
|
self.message_post(
|
|
body=(
|
|
f'⚖️ <b>Income Withholding Order created</b> (FL 61.1301).<br/>'
|
|
f'Amount: <b>${self.new_order_amount:,.2f}/mo</b>.<br/>'
|
|
f'Income withholding is MANDATORY under FL 61.1301 unless good cause '
|
|
f'is shown or both parties agree in writing to an alternative payment method.'
|
|
),
|
|
subtype_xmlid='mail.mt_note',
|
|
)
|
|
|
|
def trigger_ai_analysis(self):
|
|
"""
|
|
Trigger AI case analysis via Ollama (fl.ai.engine).
|
|
Phase 5 — full implementation.
|
|
"""
|
|
self.ensure_one()
|
|
engine = self.env['fl.ai.engine']
|
|
self.message_post(
|
|
body='🤖 AI analysis started. This may take up to 3 minutes...',
|
|
subtype_xmlid='mail.mt_note',
|
|
)
|
|
analysis = engine.analyze_case(self.id)
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'AI Analysis Result',
|
|
'res_model': 'fl.analysis',
|
|
'res_id': analysis.id,
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# ACTIONS
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
def action_view_tasks(self):
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Case Tasks',
|
|
'res_model': 'project.task',
|
|
'view_mode': 'tree,form',
|
|
'domain': [('project_id', '=', self.project_id.id)],
|
|
'context': {'default_project_id': self.project_id.id},
|
|
}
|
|
|
|
def action_open_support_calculator(self):
|
|
if not self.support_calc_id:
|
|
calc = self.env['fl.support.calculation'].create({
|
|
'case_id': self.id,
|
|
'calculation_type': 'proposed',
|
|
})
|
|
self.support_calc_id = calc
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'FL 61.30 Support Calculator',
|
|
'res_model': 'fl.support.calculation',
|
|
'res_id': self.support_calc_id.id,
|
|
'view_mode': 'form',
|
|
'target': 'current',
|
|
}
|
|
|
|
def action_create_fee_waiver(self):
|
|
waiver = self.env['fl.fee.waiver'].create({
|
|
'case_id': self.id,
|
|
'party_id': self.petitioner_id.id,
|
|
})
|
|
self.fee_waiver_id = waiver
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Fee Waiver Application',
|
|
'res_model': 'fl.fee.waiver',
|
|
'res_id': waiver.id,
|
|
'view_mode': 'form',
|
|
'target': 'current',
|
|
}
|
|
|
|
def action_run_ai_analysis(self):
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Run AI Analysis',
|
|
'res_model': 'fl.analysis.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {'default_case_id': self.id},
|
|
}
|