Phase 7: full wizards, auto-generated case tasks, config parameters

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>
This commit is contained in:
Carlos Garcia
2026-05-06 23:49:10 -05:00
parent 6dc2144db7
commit 26f58952b4
7 changed files with 1006 additions and 60 deletions

View File

@@ -1,30 +1,135 @@
from odoo import fields, models
import logging
from datetime import datetime, timedelta
from odoo import api, fields, models, _
_logger = logging.getLogger(__name__)
class FlAnalysisWizard(models.TransientModel):
"""
Trigger AI analysis on a case.
Phase 7 — full implementation.
Phase 1: Stub.
Phase 7 — Full AI analysis trigger wizard.
Checks for a recent analysis (<24 h) before running a new one unless
force_reanalysis is True. Opens the resulting fl.analysis form on success.
"""
_name = 'fl.analysis.wizard'
_description = 'Trigger AI Analysis Wizard'
_description = 'Trigger AI Case Analysis'
case_id = fields.Many2one(
'fl.case', string='Case', required=True
'fl.case', string='Case', required=True,
default=lambda self: self.env.context.get('active_id'),
)
force_reanalysis = fields.Boolean(
string='Force Re-analysis',
help='Run a new analysis even if a recent one exists'
help='Run a new analysis even if one was completed in the last 24 hours'
)
# Computed info fields shown in the form
recent_analysis_id = fields.Many2one(
'fl.analysis', string='Most Recent Analysis',
compute='_compute_recent_analysis',
)
recent_analysis_age_label = fields.Char(
string='Last Analysis', compute='_compute_recent_analysis'
)
has_recent = fields.Boolean(compute='_compute_recent_analysis')
@api.depends('case_id')
def _compute_recent_analysis(self):
for rec in self:
if not rec.case_id:
rec.recent_analysis_id = False
rec.recent_analysis_age_label = ''
rec.has_recent = False
continue
latest = self.env['fl.analysis'].search(
[('case_id', '=', rec.case_id.id), ('state', '=', 'complete')],
order='create_date desc',
limit=1,
)
rec.recent_analysis_id = latest
if latest:
age = datetime.now() - latest.create_date
hours = int(age.total_seconds() // 3600)
minutes = int((age.total_seconds() % 3600) // 60)
if hours == 0:
rec.recent_analysis_age_label = f'{minutes} minute(s) ago'
elif hours < 24:
rec.recent_analysis_age_label = f'{hours} hour(s) ago'
else:
days = age.days
rec.recent_analysis_age_label = f'{days} day(s) ago'
rec.has_recent = age < timedelta(hours=24)
else:
rec.recent_analysis_age_label = 'No analysis yet'
rec.has_recent = False
def action_run_analysis(self):
self.env['fl.ai.engine'].analyze_case(self.case_id.id)
"""
Run the AI analysis pipeline via fl.ai.engine.
If a recent analysis exists and force_reanalysis is False,
just open the existing one.
"""
self.ensure_one()
if self.has_recent and not self.force_reanalysis:
# Surface the existing analysis instead
return {
'type': 'ir.actions.act_window',
'name': _('AI Analysis'),
'res_model': 'fl.analysis',
'res_id': self.recent_analysis_id.id,
'view_mode': 'form',
'target': 'new',
}
# Previous analyses remain in history — new one will appear first
# (fl.analysis is ordered by create_date desc)
# Post start note
self.case_id.message_post(
body='🤖 AI analysis started by %s' % self.env.user.name,
subtype_xmlid='mail.mt_note',
)
try:
analysis = self.env['fl.ai.engine'].analyze_case(self.case_id.id)
except Exception as e:
_logger.error('Analysis wizard: engine error for case %s: %s', self.case_id.id, e)
self.case_id.message_post(
body=f'❌ AI analysis failed: {e}',
subtype_xmlid='mail.mt_note',
)
return {
'type': 'ir.actions.act_window',
'name': _('Case'),
'res_model': 'fl.case',
'res_id': self.case_id.id,
'view_mode': 'form',
'target': 'current',
}
return {
'type': 'ir.actions.act_window',
'name': 'Case',
'res_model': 'fl.case',
'res_id': self.case_id.id,
'name': _('AI Analysis Result'),
'res_model': 'fl.analysis',
'res_id': analysis.id,
'view_mode': 'form',
'target': 'current',
'target': 'new',
}
def action_view_recent(self):
"""Open the most recent analysis form view."""
self.ensure_one()
if not self.recent_analysis_id:
return False
return {
'type': 'ir.actions.act_window',
'name': _('AI Analysis'),
'res_model': 'fl.analysis',
'res_id': self.recent_analysis_id.id,
'view_mode': 'form',
'target': 'new',
}

View File

@@ -1,20 +1,88 @@
from odoo import fields, models
import base64
import logging
from odoo import fields, models, _
_logger = logging.getLogger(__name__)
# Report XML IDs and their binding models
# (xml_id, binding_model, label, needs_sub_record)
# needs_sub_record: which related record to use as the report target
_REPORT_DEFS = {
# fl.case bound
'mandatory_disclosure': (
'activeblue_familylaw.action_report_mandatory_disclosure',
'fl.case', 'Certificate of Mandatory Disclosure (FL-12.932)', None
),
'motion_to_modify': (
'activeblue_familylaw.action_report_motion_to_modify',
'fl.case', 'Motion to Modify Child Support', None
),
'default_motion': (
'activeblue_familylaw.action_report_default_motion',
'fl.case', 'Default Judgment Packet (FL 12.922)', None
),
'parenting_plan': (
'activeblue_familylaw.action_report_parenting_plan',
'fl.case', 'Parenting Plan (FL-12.995(a))', None
),
# fl.party bound — petitioner
'financial_affidavit_short': (
'activeblue_familylaw.action_report_financial_affidavit_short',
'fl.party', 'Financial Affidavit — Short Form (FL-12.902(b))', 'petitioner'
),
'financial_affidavit_long': (
'activeblue_familylaw.action_report_financial_affidavit_long',
'fl.party', 'Financial Affidavit — Long Form (FL-12.902(c))', 'petitioner'
),
'notice_ssn': (
'activeblue_familylaw.action_report_notice_ssn',
'fl.party', 'Notice of Social Security Number (FL-12.930(a))', 'petitioner'
),
# fl.support.calculation bound
'support_worksheet': (
'activeblue_familylaw.action_report_child_support_worksheet',
'fl.support.calculation', 'Child Support Worksheet (FL-12.902(e))', 'support_calc'
),
# fl.fee.waiver bound
'fee_waiver': (
'activeblue_familylaw.action_report_fee_waiver',
'fl.fee.waiver', 'Fee Waiver Application (FL 57.082)', 'fee_waiver'
),
# fl.income.withholding bound
'income_withholding': (
'activeblue_familylaw.action_report_income_withholding',
'fl.income.withholding', 'Income Withholding Order (FL 61.1301)', 'income_withholding'
),
}
class FlGeneratePacketWizard(models.TransientModel):
"""
Generate full filing packet for a case.
Phase 7 — full implementation with batch PDF generation.
Phase 1: Stub.
Phase 7 — Generate a filing packet of selected PDF reports for a case.
Renders each selected report, creates ir.attachment records on the case,
posts a chatter summary, and returns to the case form.
"""
_name = 'fl.generate.packet.wizard'
_description = 'Generate Filing Packet Wizard'
case_id = fields.Many2one(
'fl.case', string='Case', required=True
'fl.case', string='Case', required=True,
default=lambda self: self.env.context.get('active_id'),
)
include_financial_affidavit = fields.Boolean(
string='Financial Affidavit', default=True
# ── Document selection checkboxes ────────────────────────────────────────
include_mandatory_disclosure = fields.Boolean(
string='Certificate of Mandatory Disclosure (FL-12.932)', default=True
)
include_financial_affidavit_short = fields.Boolean(
string='Financial Affidavit — Short Form (FL-12.902(b))', default=False
)
include_financial_affidavit_long = fields.Boolean(
string='Financial Affidavit — Long Form (FL-12.902(c))', default=True
)
include_support_worksheet = fields.Boolean(
string='Child Support Worksheet (FL-12.902(e))', default=True
@@ -25,32 +93,162 @@ class FlGeneratePacketWizard(models.TransientModel):
include_notice_ssn = fields.Boolean(
string='Notice of Social Security Number (FL-12.930(a))', default=True
)
include_mandatory_disclosure = fields.Boolean(
string='Certificate of Mandatory Disclosure (FL-12.932)', default=True
)
include_fee_waiver = fields.Boolean(
string='Fee Waiver Application (if eligible)', default=False
string='Fee Waiver Application (FL 57.082)', default=False
)
language = fields.Selection([
('en', 'English'),
('es', 'Spanish / Español'),
('both', 'Bilingual'),
], string='Document Language', default='en')
include_income_withholding = fields.Boolean(
string='Income Withholding Order (FL 61.1301)', default=False
)
include_default_motion = fields.Boolean(
string='Default Judgment Packet (FL 12.922)', default=False
)
include_parenting_plan = fields.Boolean(
string='Parenting Plan (FL-12.995(a))', default=False
)
# ── Options ──────────────────────────────────────────────────────────────
attach_to_case = fields.Boolean(
string='Attach generated PDFs to case chatter',
default=True
)
# ── Helpers ──────────────────────────────────────────────────────────────
def _get_selected_keys(self):
"""Return list of _REPORT_DEFS keys that the user has selected."""
mapping = {
'mandatory_disclosure': self.include_mandatory_disclosure,
'financial_affidavit_short': self.include_financial_affidavit_short,
'financial_affidavit_long': self.include_financial_affidavit_long,
'support_worksheet': self.include_support_worksheet,
'motion_to_modify': self.include_motion_to_modify,
'notice_ssn': self.include_notice_ssn,
'fee_waiver': self.include_fee_waiver,
'income_withholding': self.include_income_withholding,
'default_motion': self.include_default_motion,
'parenting_plan': self.include_parenting_plan,
}
return [k for k, selected in mapping.items() if selected]
def _resolve_record_id(self, key, case):
"""
Return the (record_id, model) tuple to pass to _render_qweb_pdf.
Returns (None, None) if the required sub-record doesn't exist.
"""
xml_id, model, label, sub = _REPORT_DEFS[key]
if sub is None:
return case.id, model
if sub == 'petitioner':
if not case.petitioner_id:
_logger.warning('Packet: no petitioner_id on case %s', case.id)
return None, None
return case.petitioner_id.id, model
if sub == 'support_calc':
calc = case.support_calc_ids[:1] if case.support_calc_ids else None
if not calc:
_logger.warning('Packet: no support calculation on case %s', case.id)
return None, None
return calc.id, model
if sub == 'fee_waiver':
fwv = case.fee_waiver_id
if not fwv:
_logger.warning('Packet: no fee_waiver_id on case %s', case.id)
return None, None
return fwv.id, model
if sub == 'income_withholding':
iwo = self.env['fl.income.withholding'].search(
[('case_id', '=', case.id)], limit=1
)
if not iwo:
_logger.warning('Packet: no income withholding on case %s', case.id)
return None, None
return iwo.id, model
return None, None
# ── Main action ──────────────────────────────────────────────────────────
def action_generate(self):
"""
Phase 7 — generate all selected documents as PDFs.
Phase 1: Stub — posts a note and returns to case.
Render each selected report PDF, create attachments on the case,
post a chatter summary, and return to the case form.
"""
self.case_id.message_post(
body='📄 Filing packet generation will be available in Phase 4 (Document Templates).',
subtype_xmlid='mail.mt_note',
)
self.ensure_one()
case = self.case_id
selected_keys = self._get_selected_keys()
if not selected_keys:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Nothing selected'),
'message': _('Please select at least one document to generate.'),
'type': 'warning',
'sticky': False,
},
}
generated = []
failed = []
attachment_ids = []
for key in selected_keys:
xml_id, model, label, sub = _REPORT_DEFS[key]
record_id, binding_model = self._resolve_record_id(key, case)
if record_id is None:
failed.append(f'{label} — required record not found on case')
continue
try:
report_action = self.env.ref(xml_id)
pdf_content, _mime = report_action._render_qweb_pdf([record_id])
safe_name = label.replace('/', '-').replace(':', '').strip()
filename = f'{case.name}{safe_name}.pdf'
attachment = self.env['ir.attachment'].create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'fl.case',
'res_id': case.id,
'mimetype': 'application/pdf',
})
attachment_ids.append(attachment.id)
generated.append(label)
_logger.info('Packet: generated %s for case %s', label, case.id)
except Exception as e:
_logger.error(
'Packet: failed to generate %s for case %s: %s', label, case.id, e
)
failed.append(f'{label}{e}')
# Post chatter summary
if self.attach_to_case and (generated or failed):
lines = []
if generated:
lines.append('<b>✅ Documents generated:</b><ul>')
for doc in generated:
lines.append(f'<li>{doc}</li>')
lines.append('</ul>')
if failed:
lines.append('<b>⚠ Failed:</b><ul>')
for doc in failed:
lines.append(f'<li>{doc}</li>')
lines.append('</ul>')
case.message_post(
body=''.join(lines),
attachment_ids=attachment_ids,
subtype_xmlid='mail.mt_note',
)
return {
'type': 'ir.actions.act_window',
'name': 'Case',
'name': _('Case'),
'res_model': 'fl.case',
'res_id': self.case_id.id,
'res_id': case.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -1,15 +1,24 @@
from odoo import api, fields, models
import logging
from datetime import date
from odoo import api, fields, models, _
_logger = logging.getLogger(__name__)
class FlIntakeWizard(models.TransientModel):
"""
Guided case creation wizard.
Phase 7 — full multi-step intake form.
Phase 1: Stub with basic fields.
Phase 7 — Full guided case creation wizard for paralegals/attorneys.
Creates fl.party records (petitioner + optional respondent),
fl.case record with all intake fields, triggers fee waiver check
and AI analysis, then opens the new case form.
"""
_name = 'fl.intake.wizard'
_description = 'Family Law Case Intake Wizard'
# ── Step 1: Case identity ────────────────────────────────────────────────
case_type = fields.Selection([
('modification', 'Child Support Modification'),
('dissolution_children', 'Dissolution of Marriage — With Children'),
@@ -17,38 +26,239 @@ class FlIntakeWizard(models.TransientModel):
('paternity', 'Paternity'),
('alimony_modification', 'Alimony Modification'),
('custody_modification', 'Timesharing / Custody Modification'),
], string='Case Type', required=True)
], string='Case Type', required=True, default='modification')
petitioner_id = fields.Many2one(
'res.partner', string='Petitioner', required=True
court_case_number = fields.Char(
string='Court Case Number',
help='Leave blank if not yet assigned'
)
respondent_id = fields.Many2one(
'res.partner', string='Respondent'
# ── Step 2: Parties ──────────────────────────────────────────────────────
# Petitioner
petitioner_name = fields.Char(string='Petitioner Full Name', required=True)
petitioner_email = fields.Char(string='Petitioner Email')
petitioner_phone = fields.Char(string='Petitioner Phone')
petitioner_address = fields.Char(string='Petitioner Street Address')
petitioner_city = fields.Char(string='Petitioner City')
petitioner_fl_resident_since = fields.Date(
string='FL Resident Since',
help='FL 61.021: Petitioner must be a FL resident for 6 months before filing'
)
# Respondent
respondent_name = fields.Char(string='Respondent Full Name')
respondent_has_counsel = fields.Boolean(
string='Respondent Has Legal Counsel?',
help='Affects attorney referral recommendation'
)
# ── Step 3: Children & Safety ────────────────────────────────────────────
num_children = fields.Integer(
string='Number of Minor Children', default=1,
help='Children under 18 covered by this case'
)
domestic_violence_flag = fields.Boolean(
string='Is there a history of domestic violence?',
help='Your answer affects mediation and safety procedures'
string='History of Domestic Violence?',
help='FL 44.102: Requires separate mediation rooms. '
'Attorney referral is strongly recommended.'
)
has_minor_children = fields.Boolean(
string='Are there minor children involved?'
)
petitioner_fl_resident_since = fields.Date(
string='When did the petitioner become a FL resident?',
help='FL 61.021: Must be 6 months before filing'
income_imputation_concern = fields.Boolean(
string='Income Imputation Concern?',
help='Is one party voluntarily unemployed or underemployed?'
)
# ── Step 4: Financial Information ────────────────────────────────────────
petitioner_monthly_gross = fields.Float(
string='Petitioner Monthly Gross Income', digits=(10, 2)
)
respondent_monthly_gross = fields.Float(
string='Respondent Monthly Gross Income', digits=(10, 2)
)
current_order_amount = fields.Float(
string='Current Support Order Amount ($/month)',
help='0 if no existing order',
digits=(10, 2)
)
household_size = fields.Integer(
string='Household Size (petitioner)', default=3,
help='Used for fee waiver eligibility (FL 57.082)'
)
# ── Step 5: Options ──────────────────────────────────────────────────────
fee_waiver_request = fields.Boolean(
string='Request Fee Waiver? (FL 57.082)',
help='System will check eligibility based on income'
)
run_ai_analysis = fields.Boolean(
string='Run AI Case Analysis?',
default=True,
help='Automatically analyze the case using Ollama AI engine'
)
notes = fields.Text(string='Additional Notes / Case Summary')
# ── Computed helpers ─────────────────────────────────────────────────────
residency_warning = fields.Char(
string='Residency Status',
compute='_compute_residency_warning'
)
@api.depends('petitioner_fl_resident_since')
def _compute_residency_warning(self):
for rec in self:
if rec.petitioner_fl_resident_since:
days = (date.today() - rec.petitioner_fl_resident_since).days
if days < 180:
remaining = 180 - days
rec.residency_warning = (
f'⚠ Only {days} days as FL resident — '
f'{remaining} more days needed (FL 61.021)'
)
else:
rec.residency_warning = f'{days} days — Residency requirement met (FL 61.021)'
else:
rec.residency_warning = ''
# ── Action ───────────────────────────────────────────────────────────────
def action_create_case(self):
"""Create the fl.case record from intake data."""
case = self.env['fl.case'].create({
"""
Create partner → party → case chain from wizard data.
Mirrors the portal intake_submit logic but for authenticated users.
"""
self.ensure_one()
petitioner_name = (self.petitioner_name or '').strip()
respondent_name = (self.respondent_name or '').strip()
# --- Petitioner partner ---
domain = []
if self.petitioner_email:
domain = [('email', '=', self.petitioner_email)]
else:
domain = [('name', '=', petitioner_name)]
petitioner_partner = self.env['res.partner'].search(domain, limit=1)
if not petitioner_partner:
vals = {
'name': petitioner_name,
'is_company': False,
}
if self.petitioner_email:
vals['email'] = self.petitioner_email
if self.petitioner_phone:
vals['phone'] = self.petitioner_phone
if self.petitioner_address:
vals['street'] = self.petitioner_address
if self.petitioner_city:
vals['city'] = self.petitioner_city
try:
vals['state_id'] = self.env.ref('base.state_us_10').id # Florida
vals['country_id'] = self.env.ref('base.us').id
except Exception:
pass
petitioner_partner = self.env['res.partner'].create(vals)
# --- Respondent partner ---
respondent_partner = False
if respondent_name:
respondent_partner = self.env['res.partner'].search(
[('name', '=', respondent_name)], limit=1
)
if not respondent_partner:
respondent_partner = self.env['res.partner'].create({
'name': respondent_name,
'is_company': False,
})
# --- Petitioner fl.party ---
petitioner_party = self.env['fl.party'].search(
[('partner_id', '=', petitioner_partner.id)], limit=1
)
if not petitioner_party:
party_vals = {
'name': petitioner_name,
'partner_id': petitioner_partner.id,
'employment_status': 'employed',
'monthly_gross_income': self.petitioner_monthly_gross,
}
if self.petitioner_email:
party_vals['email'] = self.petitioner_email
petitioner_party = self.env['fl.party'].create(party_vals)
elif self.petitioner_monthly_gross:
petitioner_party.write({'monthly_gross_income': self.petitioner_monthly_gross})
# --- Respondent fl.party ---
respondent_party = False
if respondent_partner:
respondent_party = self.env['fl.party'].search(
[('partner_id', '=', respondent_partner.id)], limit=1
)
if not respondent_party:
respondent_party = self.env['fl.party'].create({
'name': respondent_name,
'partner_id': respondent_partner.id,
'monthly_gross_income': self.respondent_monthly_gross,
})
elif self.respondent_monthly_gross:
respondent_party.write({'monthly_gross_income': self.respondent_monthly_gross})
# --- Residency check ---
filing_date = date.today()
residency_ok = True
if self.petitioner_fl_resident_since:
days = (filing_date - self.petitioner_fl_resident_since).days
residency_ok = days >= 180
# --- Create fl.case ---
case_vals = {
'case_type': self.case_type,
'petitioner_id': self.petitioner_id.id,
'respondent_id': self.respondent_id.id if self.respondent_id else False,
'petitioner_id': petitioner_party.id,
'respondent_id': respondent_party.id if respondent_party else False,
'court_case_number': self.court_case_number or False,
'domestic_violence_flag': self.domestic_violence_flag,
'petitioner_fl_resident_since': self.petitioner_fl_resident_since,
})
'respondent_has_counsel': self.respondent_has_counsel,
'current_order_amount': self.current_order_amount,
'filing_date': filing_date,
'residency_requirement_met': residency_ok,
'household_size': self.household_size,
}
if self.petitioner_fl_resident_since:
case_vals['petitioner_fl_resident_since'] = self.petitioner_fl_resident_since
if self.notes:
case_vals['description'] = self.notes
case = self.env['fl.case'].create(case_vals)
# --- Fee waiver check ---
if self.fee_waiver_request:
fwv = self.env['fl.fee.waiver'].search(
[('case_id', '=', case.id)], limit=1
)
if not fwv:
# Create a fee waiver record manually
self.env['fl.fee.waiver'].create({'case_id': case.id})
# --- AI analysis ---
if self.run_ai_analysis:
try:
self.env['fl.ai.engine'].analyze_case(case.id)
except Exception as e:
_logger.warning(
'Intake wizard: AI analysis failed for case %s: %s', case.id, e
)
case.message_post(
body=f'⚠ AI analysis could not run automatically: {e}',
subtype_xmlid='mail.mt_note',
)
return {
'type': 'ir.actions.act_window',
'name': 'New Case',
'name': _('New Case'),
'res_model': 'fl.case',
'res_id': case.id,
'view_mode': 'form',