Add FL e-Filing Portal integration (assisted submission, Phase 1)
- fl.efiling.submission: generates the 11th Circuit court filename
({LastName}_{CaseNumber}_{DocumentType}_{YYYYMMDD}.pdf), validates PDF/A via
pikepdf (XMP pdfaid + OutputIntents, graceful if pikepdf missing), and tracks
status (draft → validated → pending_manual → submitted → accepted/rejected/failed)
- Assisted flow: "Open e-Filing Portal" deep-links to eportal.flcourts.org
(?caseNumber=… when available; base overridable via ir.config_parameter
fl_efiling.portal_url); confirmation # capture; accepted/rejected mark the
linked fl.document filed and log to chatter
- Phase 2 API stub (action_submit_api) reads creds/endpoint from ir.config_parameter
and refuses to call an unconfirmed endpoint — no guessed URLs, no silent failure
- fl.efiling.wizard: pick document/attachment/filing_type, preview the filename,
create + auto-validate the submission
- Wiring: model + wizard registered, ACL (admin/paralegal), e-filing views, Cases
menu item, fl.case.efiling_submission_ids + Filings tab + Prepare e-Filing button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
280
activeblue_familylaw/models/fl_efiling.py
Normal file
280
activeblue_familylaw/models/fl_efiling.py
Normal file
@@ -0,0 +1,280 @@
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PORTAL_URL = 'https://eportal.flcourts.org'
|
||||
|
||||
# filing_type → CamelCase token used in the 11th Circuit filename convention.
|
||||
FILING_TYPE_TOKEN = {
|
||||
'petition': 'Petition',
|
||||
'supplemental_petition': 'SupplementalPetition',
|
||||
'financial_affidavit': 'FinancialAffidavit',
|
||||
'support_worksheet': 'ChildSupportWorksheet',
|
||||
'motion_to_modify': 'MotionToModify',
|
||||
'motion_to_compel': 'MotionToCompel',
|
||||
'motion_default': 'MotionForDefault',
|
||||
'notice_deposition': 'NoticeOfDeposition',
|
||||
'parenting_plan': 'ParentingPlan',
|
||||
'mandatory_disclosure': 'MandatoryDisclosure',
|
||||
'fee_waiver': 'CivilIndigentStatus',
|
||||
'income_withholding': 'IncomeWithholding',
|
||||
'notice_ssn': 'NoticeOfSSN',
|
||||
'other': 'Document',
|
||||
}
|
||||
|
||||
# Best-effort map from fl.document.document_type → filing_type.
|
||||
DOC_TYPE_TO_FILING_TYPE = {
|
||||
'financial_affidavit_short': 'financial_affidavit',
|
||||
'financial_affidavit_long': 'financial_affidavit',
|
||||
'support_worksheet': 'support_worksheet',
|
||||
'motion_to_modify': 'motion_to_modify',
|
||||
'notice_deposition': 'notice_deposition',
|
||||
'motion_to_compel': 'motion_to_compel',
|
||||
'motion_default': 'motion_default',
|
||||
'income_withholding': 'income_withholding',
|
||||
'parenting_plan': 'parenting_plan',
|
||||
'fee_waiver': 'fee_waiver',
|
||||
'notice_ssn': 'notice_ssn',
|
||||
'mandatory_disclosure': 'mandatory_disclosure',
|
||||
}
|
||||
|
||||
|
||||
class FlEfilingSubmission(models.Model):
|
||||
_name = 'fl.efiling.submission'
|
||||
_description = 'FL e-Filing Portal Submission'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'create_date desc'
|
||||
_rec_name = 'court_filename'
|
||||
|
||||
case_id = fields.Many2one(
|
||||
'fl.case', string='Case', required=True,
|
||||
ondelete='cascade', index=True, tracking=True
|
||||
)
|
||||
document_id = fields.Many2one(
|
||||
'fl.document', string='Case Document',
|
||||
domain="[('case_id', '=', case_id)]"
|
||||
)
|
||||
attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='PDF to File',
|
||||
help='The PDF that will be filed (use the signed PDF where applicable).'
|
||||
)
|
||||
filing_type = fields.Selection([
|
||||
('petition', 'Petition'),
|
||||
('supplemental_petition', 'Supplemental Petition'),
|
||||
('financial_affidavit', 'Financial Affidavit'),
|
||||
('support_worksheet', 'Child Support Worksheet'),
|
||||
('motion_to_modify', 'Motion to Modify'),
|
||||
('motion_to_compel', 'Motion to Compel'),
|
||||
('motion_default', 'Motion for Default'),
|
||||
('notice_deposition', 'Notice of Deposition'),
|
||||
('parenting_plan', 'Parenting Plan'),
|
||||
('mandatory_disclosure', 'Mandatory Disclosure'),
|
||||
('fee_waiver', 'Civil Indigent Status'),
|
||||
('income_withholding', 'Income Withholding'),
|
||||
('notice_ssn', 'Notice of SSN'),
|
||||
('other', 'Other'),
|
||||
], string='Filing Type', default='other', required=True)
|
||||
filing_date = fields.Date(
|
||||
string='Filing Date', default=fields.Date.context_today
|
||||
)
|
||||
court_filename = fields.Char(
|
||||
string='Court Filename', compute='_compute_court_filename', store=True,
|
||||
help='11th Circuit naming convention: '
|
||||
'{LastName}_{CaseNumber}_{DocumentType}_{YYYYMMDD}.pdf'
|
||||
)
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('validated', 'PDF/A Validated'),
|
||||
('pending_manual', 'Pending Manual Filing'),
|
||||
('submitted', 'Submitted'),
|
||||
('accepted', 'Accepted by Clerk'),
|
||||
('rejected', 'Rejected by Clerk'),
|
||||
('failed', 'Failed'),
|
||||
], string='Status', default='draft', required=True, tracking=True)
|
||||
|
||||
pdfa_valid = fields.Boolean(string='PDF/A Valid', readonly=True)
|
||||
pdfa_message = fields.Char(string='PDF/A Check Result', readonly=True)
|
||||
confirmation_number = fields.Char(string='Portal Confirmation #', tracking=True)
|
||||
error_message = fields.Text(string='Error', readonly=True)
|
||||
portal_url = fields.Char(string='Portal Link', compute='_compute_portal_url')
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Computes
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@api.depends('case_id', 'case_id.court_case_number',
|
||||
'case_id.petitioner_id', 'filing_type', 'filing_date')
|
||||
def _compute_court_filename(self):
|
||||
for rec in self:
|
||||
case = rec.case_id
|
||||
last = self._sanitize(self._last_name(case.petitioner_id)) or 'Party'
|
||||
casenum = self._sanitize(case.court_case_number or 'NOCASE')
|
||||
token = FILING_TYPE_TOKEN.get(rec.filing_type, 'Document')
|
||||
datestr = (rec.filing_date or fields.Date.context_today(rec)).strftime('%Y%m%d')
|
||||
rec.court_filename = f'{last}_{casenum}_{token}_{datestr}.pdf'
|
||||
|
||||
@api.depends('case_id', 'case_id.court_case_number')
|
||||
def _compute_portal_url(self):
|
||||
base = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fl_efiling.portal_url', DEFAULT_PORTAL_URL)
|
||||
for rec in self:
|
||||
url = base
|
||||
if rec.case_id.court_case_number:
|
||||
url += '?caseNumber=' + quote(rec.case_id.court_case_number)
|
||||
rec.portal_url = url
|
||||
|
||||
@staticmethod
|
||||
def _last_name(partner):
|
||||
if not partner or not partner.name:
|
||||
return ''
|
||||
return partner.name.strip().split()[-1]
|
||||
|
||||
@staticmethod
|
||||
def _sanitize(value):
|
||||
# Keep alphanumerics and dashes; drop everything else.
|
||||
return re.sub(r'[^A-Za-z0-9-]', '', (value or '').replace(' ', ''))
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Onchange
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@api.onchange('document_id')
|
||||
def _onchange_document_id(self):
|
||||
if not self.document_id:
|
||||
return
|
||||
self.filing_type = DOC_TYPE_TO_FILING_TYPE.get(
|
||||
self.document_id.document_type, 'other')
|
||||
if self.document_id.attachment_ids:
|
||||
self.attachment_id = self.document_id.attachment_ids[:1]
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# PDF/A validation (pikepdf)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def action_validate_pdfa(self):
|
||||
self.ensure_one()
|
||||
if not self.attachment_id:
|
||||
raise UserError(_('Attach the PDF to file before validating.'))
|
||||
valid, message = self._check_pdfa(self.attachment_id)
|
||||
self.write({'pdfa_valid': valid, 'pdfa_message': message})
|
||||
if valid and self.status == 'draft':
|
||||
self.status = 'validated'
|
||||
return True
|
||||
|
||||
def _check_pdfa(self, attachment):
|
||||
"""Pragmatic PDF/A check via pikepdf (XMP pdfaid markers + OutputIntents)."""
|
||||
try:
|
||||
import pikepdf
|
||||
except ImportError:
|
||||
return False, 'pikepdf not installed — cannot validate PDF/A.'
|
||||
|
||||
if not attachment.datas:
|
||||
return False, 'Attachment has no file data.'
|
||||
try:
|
||||
data = base64.b64decode(attachment.datas)
|
||||
pdf = pikepdf.open(io.BytesIO(data))
|
||||
except Exception as exc:
|
||||
return False, f'Not a readable PDF: {exc}'
|
||||
|
||||
has_output_intent = '/OutputIntents' in pdf.Root
|
||||
pdfa_part = None
|
||||
try:
|
||||
with pdf.open_metadata() as meta:
|
||||
pdfa_part = meta.get('pdfaid:part')
|
||||
except Exception:
|
||||
pdfa_part = None
|
||||
|
||||
if pdfa_part:
|
||||
suffix = (' and an OutputIntent' if has_output_intent
|
||||
else ' — WARNING: no OutputIntents present')
|
||||
return True, f'PDF/A-{pdfa_part} markers present{suffix}.'
|
||||
return False, ('No PDF/A identification (pdfaid) found in the PDF metadata. '
|
||||
'Convert the document to PDF/A before filing.')
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Assisted submission (Phase 1)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def action_open_portal(self):
|
||||
self.ensure_one()
|
||||
if self.status in ('draft', 'validated'):
|
||||
self.status = 'pending_manual'
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': self.portal_url,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_record_confirmation(self):
|
||||
"""Mark as submitted once the user has filed and entered a confirmation #."""
|
||||
self.ensure_one()
|
||||
if not (self.confirmation_number or '').strip():
|
||||
raise UserError(_(
|
||||
'Enter the portal confirmation number before marking submitted.'))
|
||||
self.status = 'submitted'
|
||||
self.case_id.message_post(
|
||||
body=_('📤 e-Filing submitted: <b>%(file)s</b> — confirmation '
|
||||
'<b>%(conf)s</b>.') % {
|
||||
'file': self.court_filename,
|
||||
'conf': self.confirmation_number,
|
||||
},
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return True
|
||||
|
||||
def action_mark_accepted(self):
|
||||
self.ensure_one()
|
||||
self.status = 'accepted'
|
||||
if self.document_id:
|
||||
self.document_id.write({
|
||||
'state': 'filed',
|
||||
'filed_date': fields.Date.context_today(self),
|
||||
})
|
||||
self.case_id.message_post(
|
||||
body=_('✅ Clerk accepted filing: <b>%s</b>.') % self.court_filename,
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return True
|
||||
|
||||
def action_mark_rejected(self):
|
||||
self.ensure_one()
|
||||
self.status = 'rejected'
|
||||
self.case_id.message_post(
|
||||
body=_('❌ Clerk rejected filing: <b>%s</b>. See notes.')
|
||||
% self.court_filename,
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# API submission (Phase 2 — not enabled against a confirmed endpoint)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def action_submit_api(self):
|
||||
"""
|
||||
Programmatic submission to the FL e-Filing Portal API. The portal's API
|
||||
base URL must be confirmed from current portal documentation before this
|
||||
is enabled; we never call a guessed endpoint. Credentials live in
|
||||
ir.config_parameter (fl_efiling.username / fl_efiling.password).
|
||||
"""
|
||||
self.ensure_one()
|
||||
endpoint = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fl_efiling.api_endpoint')
|
||||
if not endpoint:
|
||||
raise UserError(_(
|
||||
'Automated API submission is not configured. Confirm the FL '
|
||||
'e-Filing Portal API base URL, then set ir.config_parameter '
|
||||
'fl_efiling.api_endpoint (plus fl_efiling.username / '
|
||||
'fl_efiling.password). Until then use "Open e-Filing Portal" '
|
||||
'for assisted manual submission.'))
|
||||
raise UserError(_(
|
||||
'An API endpoint is configured but Phase 2 programmatic submission '
|
||||
'is not yet enabled in this build.'))
|
||||
Reference in New Issue
Block a user