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: %(file)s — confirmation ' '%(conf)s.') % { '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: %s.') % 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: %s. 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.'))