diff --git a/activeblue_familylaw/__manifest__.py b/activeblue_familylaw/__manifest__.py index 097d930..8a39a79 100644 --- a/activeblue_familylaw/__manifest__.py +++ b/activeblue_familylaw/__manifest__.py @@ -58,6 +58,7 @@ 'views/fl_discovery_suggest_views.xml', 'views/fl_conflict_check_views.xml', 'views/fl_timesheet_views.xml', + 'views/fl_efiling_views.xml', 'views/menu_views.xml', # Phase 4 — QWeb PDF Reports 'report/report_financial_affidavit_short.xml', diff --git a/activeblue_familylaw/models/__init__.py b/activeblue_familylaw/models/__init__.py index 6b60232..3d91c3c 100644 --- a/activeblue_familylaw/models/__init__.py +++ b/activeblue_familylaw/models/__init__.py @@ -19,3 +19,4 @@ from . import fl_conflict_check from . import fl_paralegal_agent from . import fl_attorney_agent from . import fl_timesheet +from . import fl_efiling diff --git a/activeblue_familylaw/models/fl_case.py b/activeblue_familylaw/models/fl_case.py index f5fdf07..7149442 100644 --- a/activeblue_familylaw/models/fl_case.py +++ b/activeblue_familylaw/models/fl_case.py @@ -400,6 +400,9 @@ class FlCase(models.Model): document_ids = fields.One2many( 'fl.document', 'case_id', string='Case Documents' ) + efiling_submission_ids = fields.One2many( + 'fl.efiling.submission', 'case_id', string='e-Filing Submissions' + ) # ══════════════════════════════════════════════════════════════════════ # PROJECT / TASK INTEGRATION @@ -1222,3 +1225,14 @@ class FlCase(models.Model): 'view_mode': 'form', 'target': 'current', } + + def action_prepare_efiling(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Prepare e-Filing', + 'res_model': 'fl.efiling.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': {'default_case_id': self.id}, + } diff --git a/activeblue_familylaw/models/fl_efiling.py b/activeblue_familylaw/models/fl_efiling.py new file mode 100644 index 0000000..88e6aa5 --- /dev/null +++ b/activeblue_familylaw/models/fl_efiling.py @@ -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: %(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.')) diff --git a/activeblue_familylaw/security/ir.model.access.csv b/activeblue_familylaw/security/ir.model.access.csv index 195438e..6842f9c 100644 --- a/activeblue_familylaw/security/ir.model.access.csv +++ b/activeblue_familylaw/security/ir.model.access.csv @@ -87,6 +87,12 @@ access_fl_timesheet_paralegal,fl.timesheet paralegal,model_fl_timesheet,group_pa # ── account.analytic.line (timesheet wraps it — ensure non-admins can post) ─── access_account_analytic_line_fl_admin,account.analytic.line fl admin,analytic.model_account_analytic_line,group_admin,1,1,1,1 access_account_analytic_line_fl_paralegal,account.analytic.line fl paralegal,analytic.model_account_analytic_line,group_paralegal,1,1,1,0 +# ── fl.efiling.submission ──────────────────────────────────────────────────── +access_fl_efiling_submission_admin,fl.efiling.submission admin,model_fl_efiling_submission,group_admin,1,1,1,1 +access_fl_efiling_submission_paralegal,fl.efiling.submission paralegal,model_fl_efiling_submission,group_paralegal,1,1,1,0 +# ── fl.efiling.wizard ──────────────────────────────────────────────────────── +access_fl_efiling_wizard_admin,fl.efiling.wizard admin,model_fl_efiling_wizard,group_admin,1,1,1,1 +access_fl_efiling_wizard_paralegal,fl.efiling.wizard paralegal,model_fl_efiling_wizard,group_paralegal,1,1,1,1 # ── fl.intake.wizard ───────────────────────────────────────────────────────── access_fl_intake_wizard_admin,fl.intake.wizard admin,model_fl_intake_wizard,group_admin,1,1,1,1 access_fl_intake_wizard_paralegal,fl.intake.wizard paralegal,model_fl_intake_wizard,group_paralegal,1,1,1,1 diff --git a/activeblue_familylaw/views/fl_case_views.xml b/activeblue_familylaw/views/fl_case_views.xml index 268f3a7..e6cedb5 100644 --- a/activeblue_familylaw/views/fl_case_views.xml +++ b/activeblue_familylaw/views/fl_case_views.xml @@ -24,6 +24,9 @@ + @@ -310,7 +313,37 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/activeblue_familylaw/views/fl_efiling_views.xml b/activeblue_familylaw/views/fl_efiling_views.xml new file mode 100644 index 0000000..80f2bd8 --- /dev/null +++ b/activeblue_familylaw/views/fl_efiling_views.xml @@ -0,0 +1,163 @@ + + + + + + + fl.efiling.submission.form + fl.efiling.submission + + + + + + + + + + + + + + ✅ PDF/A: + + + + ⚠️ PDF/A: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + fl.efiling.submission.tree + fl.efiling.submission + + + + + + + + + + + + + + + fl.efiling.submission.search + fl.efiling.submission + + + + + + + + + + + + + + + + + + + e-Filings + fl.efiling.submission + tree,form + + + + + + fl.efiling.wizard.form + fl.efiling.wizard + + + + Select the document and PDF to file. A court-compliant filename + will be generated; the submission record lets you validate PDF/A + and open the FL e-Filing Portal. + + + + + + + + + + + + + + + diff --git a/activeblue_familylaw/views/menu_views.xml b/activeblue_familylaw/views/menu_views.xml index f5c8d1a..2c06f28 100644 --- a/activeblue_familylaw/views/menu_views.xml +++ b/activeblue_familylaw/views/menu_views.xml @@ -101,6 +101,13 @@ action="action_fl_timesheet_list" sequence="80"/> + + diff --git a/activeblue_familylaw/wizard/__init__.py b/activeblue_familylaw/wizard/__init__.py index 548b457..9c1b0fe 100644 --- a/activeblue_familylaw/wizard/__init__.py +++ b/activeblue_familylaw/wizard/__init__.py @@ -2,3 +2,4 @@ from . import fl_intake_wizard from . import fl_analysis_wizard from . import fl_generate_packet_wizard from . import fl_discovery_suggest_wizard +from . import fl_efiling_wizard diff --git a/activeblue_familylaw/wizard/fl_efiling_wizard.py b/activeblue_familylaw/wizard/fl_efiling_wizard.py new file mode 100644 index 0000000..9564b97 --- /dev/null +++ b/activeblue_familylaw/wizard/fl_efiling_wizard.py @@ -0,0 +1,82 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from odoo.addons.activeblue_familylaw.models.fl_efiling import ( + DOC_TYPE_TO_FILING_TYPE, FILING_TYPE_TOKEN, +) + + +class FlEfilingWizard(models.TransientModel): + _name = 'fl.efiling.wizard' + _description = 'Assisted e-Filing Preparation Wizard' + + case_id = fields.Many2one('fl.case', string='Case', required=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 to file — 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) + court_filename_preview = fields.Char( + string='Court Filename', compute='_compute_filename_preview' + ) + + @api.depends('case_id', 'filing_type') + def _compute_filename_preview(self): + Submission = self.env['fl.efiling.submission'] + for rec in self: + case = rec.case_id + last = Submission._sanitize( + Submission._last_name(case.petitioner_id)) or 'Party' + casenum = Submission._sanitize(case.court_case_number or 'NOCASE') + token = FILING_TYPE_TOKEN.get(rec.filing_type, 'Document') + datestr = fields.Date.context_today(rec).strftime('%Y%m%d') + rec.court_filename_preview = f'{last}_{casenum}_{token}_{datestr}.pdf' + + @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] + + def action_create_submission(self): + self.ensure_one() + if not self.attachment_id: + raise UserError(_( + 'Select the PDF to file before creating the submission.')) + submission = self.env['fl.efiling.submission'].create({ + 'case_id': self.case_id.id, + 'document_id': self.document_id.id, + 'attachment_id': self.attachment_id.id, + 'filing_type': self.filing_type, + }) + submission.action_validate_pdfa() + return { + 'type': 'ir.actions.act_window', + 'name': 'e-Filing Submission', + 'res_model': 'fl.efiling.submission', + 'res_id': submission.id, + 'view_mode': 'form', + 'target': 'current', + }
+ Select the document and PDF to file. A court-compliant filename + will be generated; the submission record lets you validate PDF/A + and open the FL e-Filing Portal. +