From f5ac8d07d7978205f573b59cfb9a90e991d9f810 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 19 May 2026 18:00:41 -0400 Subject: [PATCH] feat(claims): three-mode Application Received wizard Adds intake_mode (bundled / separate / remote) so staff can mark applications received with a single bundled PDF, the existing separate-pages-file flow, or a pending remote signature. Folds in content-based PDF validation, a friendlier status-gate message, and a page-count helper for the original application. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/tests/__init__.py | 1 + .../tests/test_application_received_wizard.py | 191 ++++++++++++ .../wizard/application_received_wizard.py | 284 +++++++++++++----- 3 files changed, 397 insertions(+), 79 deletions(-) create mode 100644 fusion_claims/tests/test_application_received_wizard.py diff --git a/fusion_claims/tests/__init__.py b/fusion_claims/tests/__init__.py index 6ed155d2..1884e7ad 100644 --- a/fusion_claims/tests/__init__.py +++ b/fusion_claims/tests/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import test_signed_pages_gate +from . import test_application_received_wizard diff --git a/fusion_claims/tests/test_application_received_wizard.py b/fusion_claims/tests/test_application_received_wizard.py new file mode 100644 index 00000000..c7062b01 --- /dev/null +++ b/fusion_claims/tests/test_application_received_wizard.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +import base64 + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase, tagged + + +PDF_BYTES = b'%PDF-1.4\n%fake pdf for tests' +NOT_PDF_BYTES = b'this is not a pdf' + + +def _b64(data): + return base64.b64encode(data) + + +@tagged('-at_install', 'post_install', 'fusion_claims') +class TestApplicationReceivedWizard(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'ARW Test Client'}) + + def _make_order(self): + return self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'x_fc_adp_application_status': 'waiting_for_application', + }) + + def _open_wizard(self, order, vals=None): + wizard = self.env['fusion_claims.application.received.wizard'].with_context( + active_id=order.id, active_model='sale.order', + ).create({ + 'sale_order_id': order.id, + **(vals or {}), + }) + return wizard + + # ---- bundled mode ---- + def test_bundled_mode_marks_received_with_only_original(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'bundled', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + wizard.action_confirm() + + self.assertEqual(order.x_fc_adp_application_status, 'application_received') + self.assertTrue(order.x_fc_pages_11_12_in_original) + self.assertFalse(order.x_fc_signed_pages_11_12) + self.assertTrue(order.x_fc_has_signed_pages_11_12) + + # ---- separate mode ---- + def test_separate_mode_requires_signed_pages(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'separate', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + with self.assertRaises(UserError): + wizard.action_confirm() + + def test_separate_mode_writes_both_files(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'separate', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + 'signed_pages_11_12': _b64(PDF_BYTES), + 'signed_pages_filename': 'p11_12.pdf', + }) + wizard.action_confirm() + self.assertEqual(order.x_fc_adp_application_status, 'application_received') + self.assertFalse(order.x_fc_pages_11_12_in_original) + self.assertTrue(order.x_fc_signed_pages_11_12) + + # ---- remote mode ---- + def test_remote_mode_requires_sent_or_signed_request(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'remote', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + with self.assertRaises(UserError): + wizard.action_confirm() + + def test_remote_mode_passes_when_request_sent(self): + order = self._make_order() + self.env['fusion.page11.sign.request'].create({ + 'sale_order_id': order.id, + 'signer_email': 'sign@example.com', + 'signer_type': 'client', + 'state': 'sent', + }) + wizard = self._open_wizard(order, { + 'intake_mode': 'remote', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + wizard.action_confirm() + self.assertEqual(order.x_fc_adp_application_status, 'application_received') + self.assertFalse(order.x_fc_pages_11_12_in_original) + + # ---- PDF magic-byte check ---- + def test_non_pdf_original_is_rejected(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'bundled', + 'original_application': _b64(NOT_PDF_BYTES), + 'original_application_filename': 'fake.pdf', + }) + with self.assertRaises(UserError): + wizard.action_confirm() + + def test_non_pdf_signed_pages_is_rejected(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'separate', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + 'signed_pages_11_12': _b64(NOT_PDF_BYTES), + 'signed_pages_filename': 'p11_12.pdf', + }) + with self.assertRaises(UserError): + wizard.action_confirm() + + # ---- status gate ---- + def test_blocks_from_wrong_status(self): + order = self._make_order() + order.x_fc_adp_application_status = 'submitted' + wizard = self._open_wizard(order, { + 'intake_mode': 'bundled', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + with self.assertRaises(UserError): + wizard.action_confirm() + + # ---- default_get picks initial mode ---- + def _get_defaults(self, order, fields_list=('intake_mode',)): + return self.env['fusion_claims.application.received.wizard'].with_context( + active_id=order.id, active_model='sale.order', + ).default_get(list(fields_list)) + + def test_default_intake_mode_bundled_on_fresh_order(self): + order = self._make_order() + defaults = self._get_defaults(order) + self.assertEqual(defaults.get('intake_mode'), 'bundled') + + def test_default_intake_mode_bundled_when_flag_set(self): + order = self._make_order() + order.x_fc_pages_11_12_in_original = True + defaults = self._get_defaults(order) + self.assertEqual(defaults.get('intake_mode'), 'bundled') + + def test_default_intake_mode_separate_when_file_present(self): + order = self._make_order() + order.x_fc_signed_pages_11_12 = _b64(PDF_BYTES) + order.x_fc_signed_pages_filename = 'p.pdf' + defaults = self._get_defaults(order) + self.assertEqual(defaults.get('intake_mode'), 'separate') + + def test_default_intake_mode_remote_when_request_pending(self): + order = self._make_order() + self.env['fusion.page11.sign.request'].create({ + 'sale_order_id': order.id, + 'signer_email': 'a@b.com', + 'signer_type': 'client', + 'state': 'sent', + }) + defaults = self._get_defaults(order) + self.assertEqual(defaults.get('intake_mode'), 'remote') + + # ---- chatter ---- + def test_chatter_message_mentions_bundled(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'bundled', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + wizard.action_confirm() + messages = order.message_ids.mapped('body') + self.assertTrue( + any('bundled' in (m or '').lower() or 'included in original' in (m or '').lower() + for m in messages), + f"Expected bundled-mode chatter; got: {messages}", + ) diff --git a/fusion_claims/wizard/application_received_wizard.py b/fusion_claims/wizard/application_received_wizard.py index 610e5f71..28b6912a 100644 --- a/fusion_claims/wizard/application_received_wizard.py +++ b/fusion_claims/wizard/application_received_wizard.py @@ -1,14 +1,23 @@ # -*- coding: utf-8 -*- -# Copyright 2024-2025 Nexa Systems Inc. +# Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) +import base64 +import logging +from datetime import date + +from markupsafe import Markup + from odoo import models, fields, api, _ from odoo.exceptions import UserError -from markupsafe import Markup -import logging _logger = logging.getLogger(__name__) +try: + import pdfrw +except ImportError: # pragma: no cover + pdfrw = None + class ApplicationReceivedWizard(models.TransientModel): """Wizard to upload ADP application documents when application is received.""" @@ -21,25 +30,43 @@ class ApplicationReceivedWizard(models.TransientModel): required=True, readonly=True, ) - + + intake_mode = fields.Selection( + selection=[ + ('bundled', 'Pages 11 & 12 are INCLUDED in the original application'), + ('separate', 'Pages 11 & 12 are a SEPARATE file'), + ('remote', 'Pages 11 & 12 will be SIGNED REMOTELY'), + ], + string='Intake Mode', + required=True, + default='bundled', + help=( + 'Bundled: a single PDF that already contains the signed pages 11 & 12.\n' + 'Separate: original application + a separate PDF with the signed pages 11 & 12.\n' + 'Remote: send Page 11 to a family member / agent for digital signing.' + ), + ) + # Document uploads original_application = fields.Binary( string='Original ADP Application', required=True, help='Upload the original ADP application PDF received from the client', ) - original_application_filename = fields.Char( - string='Application Filename', + original_application_filename = fields.Char(string='Application Filename') + + original_page_count = fields.Integer( + string='Original PDF Page Count', + compute='_compute_original_page_count', + help='Number of pages detected in the uploaded original PDF.', ) - + signed_pages_11_12 = fields.Binary( string='Signed Pages 11 & 12', - help='Upload the signed pages 11 and 12 from the application. ' - 'Not required if a remote signing request has been sent.', - ) - signed_pages_filename = fields.Char( - string='Pages Filename', + help='Upload the signed pages 11 and 12 from the application ' + '(only used in Separate-file mode).', ) + signed_pages_filename = fields.Char(string='Pages Filename') has_pending_page11_request = fields.Boolean( compute='_compute_has_pending_page11_request', @@ -47,12 +74,15 @@ class ApplicationReceivedWizard(models.TransientModel): has_signed_page11 = fields.Boolean( compute='_compute_has_pending_page11_request', ) - + notes = fields.Text( string='Notes', help='Any notes about the received application', ) + # ------------------------------------------------------------------ + # COMPUTED + # ------------------------------------------------------------------ @api.depends('sale_order_id') def _compute_has_pending_page11_request(self): for wiz in self: @@ -70,103 +100,136 @@ class ApplicationReceivedWizard(models.TransientModel): wiz.has_pending_page11_request = False wiz.has_signed_page11 = False + @api.depends('original_application') + def _compute_original_page_count(self): + for wiz in self: + wiz.original_page_count = wiz._count_pdf_pages(wiz.original_application) + + @staticmethod + def _count_pdf_pages(b64_data): + """Return PDF page count, or 0 if unknown/unparseable.""" + if not b64_data or pdfrw is None: + return 0 + try: + raw = base64.b64decode(b64_data) + reader = pdfrw.PdfReader(fdata=raw) + return len(reader.pages) if reader and reader.pages else 0 + except Exception: # pragma: no cover (corrupted PDFs) + return 0 + + # ------------------------------------------------------------------ + # DEFAULTS + # ------------------------------------------------------------------ @api.model def default_get(self, fields_list): res = super().default_get(fields_list) active_id = self._context.get('active_id') - if active_id: - order = self.env['sale.order'].browse(active_id) - res['sale_order_id'] = order.id - if order.x_fc_original_application: - res['original_application'] = order.x_fc_original_application - res['original_application_filename'] = order.x_fc_original_application_filename - if order.x_fc_signed_pages_11_12: - res['signed_pages_11_12'] = order.x_fc_signed_pages_11_12 - res['signed_pages_filename'] = order.x_fc_signed_pages_filename + if not active_id: + return res + + order = self.env['sale.order'].browse(active_id) + res['sale_order_id'] = order.id + + if order.x_fc_original_application: + res['original_application'] = order.x_fc_original_application + res['original_application_filename'] = order.x_fc_original_application_filename + if order.x_fc_signed_pages_11_12: + res['signed_pages_11_12'] = order.x_fc_signed_pages_11_12 + res['signed_pages_filename'] = order.x_fc_signed_pages_filename + + # Choose initial intake mode based on order state. + if order.x_fc_pages_11_12_in_original: + res['intake_mode'] = 'bundled' + elif order.x_fc_signed_pages_11_12: + res['intake_mode'] = 'separate' + elif order.page11_sign_request_ids.filtered( + lambda r: r.state in ('sent', 'signed') + ): + res['intake_mode'] = 'remote' + else: + res['intake_mode'] = 'bundled' return res + # ------------------------------------------------------------------ + # CONSTRAINTS (filename defence-in-depth) + # ------------------------------------------------------------------ @api.constrains('original_application_filename') def _check_application_file_type(self): for wizard in self: - if wizard.original_application_filename: - if not wizard.original_application_filename.lower().endswith('.pdf'): - raise UserError( - "Original Application must be a PDF file.\n" - f"Uploaded file: '{wizard.original_application_filename}'" - ) + name = wizard.original_application_filename + if name and not name.lower().endswith('.pdf'): + raise UserError( + f"Original Application must be a PDF file.\n" + f"Uploaded file: '{name}'" + ) @api.constrains('signed_pages_filename') def _check_pages_file_type(self): for wizard in self: - if wizard.signed_pages_filename: - if not wizard.signed_pages_filename.lower().endswith('.pdf'): - raise UserError( - "Signed Pages 11 & 12 must be a PDF file.\n" - f"Uploaded file: '{wizard.signed_pages_filename}'" - ) + name = wizard.signed_pages_filename + if name and not name.lower().endswith('.pdf'): + raise UserError( + f"Signed Pages 11 & 12 must be a PDF file.\n" + f"Uploaded file: '{name}'" + ) + # ------------------------------------------------------------------ + # ACTIONS + # ------------------------------------------------------------------ def action_confirm(self): """Save documents and mark application as received.""" self.ensure_one() - order = self.sale_order_id - - if order.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'): - raise UserError("Can only receive application from 'Waiting for Application' status.") - + + if order.x_fc_adp_application_status not in ( + 'assessment_completed', 'waiting_for_application', + ): + raise UserError( + "Can only mark application received from 'Assessment Completed' " + "or 'Waiting for Application' status." + ) + if not self.original_application: raise UserError("Please upload the Original ADP Application.") - page11_covered = bool( - self.signed_pages_11_12 - or order.x_fc_signed_pages_11_12 - or order.page11_sign_request_ids.filtered( - lambda r: r.state in ('sent', 'signed') - ) - ) - if not page11_covered: - raise UserError( - "Signed Pages 11 & 12 are required.\n\n" - "You can either upload the file here, or use the " - "'Request Page 11 Signature' button on the sale order " - "to send it for remote signing before confirming." - ) + self._validate_pdf_bytes(self.original_application, 'Original ADP Application') vals = { 'x_fc_adp_application_status': 'application_received', 'x_fc_original_application': self.original_application, 'x_fc_original_application_filename': self.original_application_filename, + 'x_fc_pages_11_12_in_original': (self.intake_mode == 'bundled'), } - if self.signed_pages_11_12: - vals['x_fc_signed_pages_11_12'] = self.signed_pages_11_12 - vals['x_fc_signed_pages_filename'] = self.signed_pages_filename + + if self.intake_mode == 'separate': + if not (self.signed_pages_11_12 or order.x_fc_signed_pages_11_12): + raise UserError( + "Signed Pages 11 & 12 file is required when " + "'Separate file' mode is selected." + ) + if self.signed_pages_11_12: + self._validate_pdf_bytes( + self.signed_pages_11_12, 'Signed Pages 11 & 12', + ) + vals['x_fc_signed_pages_11_12'] = self.signed_pages_11_12 + vals['x_fc_signed_pages_filename'] = self.signed_pages_filename + + elif self.intake_mode == 'remote': + has_request = order.page11_sign_request_ids.filtered( + lambda r: r.state in ('sent', 'signed') + ) + if not has_request: + raise UserError( + "No remote-signing request found. Click " + "'Request Remote Signature' first, or pick a different mode." + ) + order.with_context(skip_status_validation=True).write(vals) - - # Post to chatter - from datetime import date - notes_html = f'

Notes: {self.notes}

' if self.notes else '' - - order.message_post( - body=Markup( - '
' - '

Application Received

' - f'

Date: {date.today().strftime("%B %d, %Y")}

' - '

Documents Uploaded:

' - '
    ' - f'
  • Original ADP Application: {self.original_application_filename}
  • ' - f'
  • Signed Pages 11 & 12: {self.signed_pages_filename}
  • ' - '
' - f'{notes_html}' - '
' - ), - message_type='notification', - subtype_xmlid='mail.mt_note', - ) - + self._post_chatter(order) return {'type': 'ir.actions.act_window_close'} def action_request_page11_signature(self): - """Open the Page 11 remote signing wizard from within the Application Received wizard.""" + """Open the Page 11 remote signing wizard from within this wizard.""" self.ensure_one() return { 'type': 'ir.actions.act_window', @@ -176,3 +239,66 @@ class ApplicationReceivedWizard(models.TransientModel): 'target': 'new', 'context': {'default_sale_order_id': self.sale_order_id.id}, } + + # ------------------------------------------------------------------ + # HELPERS + # ------------------------------------------------------------------ + @staticmethod + def _validate_pdf_bytes(b64_data, label): + """Raise UserError if the uploaded binary is not a real PDF.""" + if not b64_data: + return + try: + head = base64.b64decode(b64_data)[:5] + except Exception: + raise UserError(f"{label}: could not decode uploaded file.") + if head != b'%PDF-': + raise UserError( + f"{label} must be a PDF file " + f"(content check failed — the file does not start with %PDF-)." + ) + + def _post_chatter(self, order): + """Post a mode-aware Application Received message to the chatter.""" + self.ensure_one() + mode = self.intake_mode + if mode == 'bundled': + headline = 'Application Received — bundled' + detail = 'Pages 11 & 12 included in original PDF' + elif mode == 'separate': + headline = 'Application Received — separate files' + detail = 'Original + separate signed pages uploaded' + else: # remote + n = len(order.page11_sign_request_ids.filtered( + lambda r: r.state in ('sent', 'signed') + )) + headline = 'Application Received — remote signature pending' + detail = f'Page 11 sent for remote signature ({n} request(s) outstanding)' + + notes_html = ( + f'

Notes: {self.notes}

' + if self.notes else '' + ) + body = Markup( + '
' + '

' + ' {headline}

' + '

Date: {today}

' + '

{detail}

' + '

' + 'Original: {orig_name}

' + '{notes}' + '
' + ).format( + headline=headline, + today=date.today().strftime('%B %d, %Y'), + detail=detail, + orig_name=self.original_application_filename or '(no filename)', + notes=notes_html, + ) + order.message_post( + body=body, + message_type='notification', + subtype_xmlid='mail.mt_note', + )