# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) from odoo import models, fields, api, _ from odoo.exceptions import UserError import base64 import io import os import logging _logger = logging.getLogger(__name__) try: import pdfrw except ImportError: pdfrw = None _logger.warning("pdfrw not installed. Discretionary Benefits PDF filling will not work.") class DiscretionaryBenefitWizard(models.TransientModel): _name = 'fusion_claims.discretionary.benefit.wizard' _description = 'ODSP Discretionary Benefits Form Wizard' sale_order_id = fields.Many2one('sale.order', required=True, readonly=True) # --- Auto-populated from partner --- client_name = fields.Char(string='Client Name', readonly=True) address = fields.Char(string='Address', readonly=True) city = fields.Char(string='City', readonly=True) province = fields.Char(string='Province', default='ON') postal_code = fields.Char(string='Postal Code', readonly=True) phone_number = fields.Char(string='Phone', readonly=True) alt_phone = fields.Char(string='Alternate Phone', readonly=True) email = fields.Char(string='Email', readonly=True) member_id = fields.Char(string='ODSP Member ID', readonly=True) form_date = fields.Date(string='Date', default=fields.Date.today) odsp_office_id = fields.Many2one( 'res.partner', string='ODSP Office', domain="[('x_fc_contact_type', '=', 'odsp_office')]", help='Override the ODSP office for this submission', ) # --- User-editable --- item_type = fields.Selection([ ('medical_equipment', 'Medical Equipment'), ('vision_care', 'Vision Care'), ('dentures', 'Dentures'), ('other', 'Other'), ], string='Item Type', default='medical_equipment', required=True) other_description = fields.Text( string='Description / Request Details', help='Details about the request (visible for all types, required for Other)', ) email_body_notes = fields.Text( string='Email Body Notes', help='Urgency or priority notes that appear at the top of the email body, ' 'right below the title. Use this for time-sensitive requests.', ) @api.model def default_get(self, fields_list): """Pre-populate from sale order and partner.""" res = super().default_get(fields_list) order_id = self.env.context.get('active_id') if not order_id: return res order = self.env['sale.order'].browse(order_id) partner = order.partner_id res['sale_order_id'] = order.id if partner: res['client_name'] = partner.name or '' res['address'] = partner.street or '' res['city'] = partner.city or '' res['province'] = partner.state_id.code if partner.state_id else 'ON' res['postal_code'] = partner.zip or '' res['phone_number'] = partner.phone or '' res['alt_phone'] = '' res['email'] = partner.email or '' res['member_id'] = order.x_fc_odsp_member_id or (partner and partner.x_fc_odsp_member_id) or '' if order.x_fc_odsp_office_id: res['odsp_office_id'] = order.x_fc_odsp_office_id.id return res def _get_template_path(self): """Get the path to the Discretionary Benefits form template PDF.""" module_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.join(module_path, 'static', 'src', 'pdf', 'discretionary_benefits_form_template.pdf') def _build_field_mapping(self): """Build a dictionary mapping PDF field names to values.""" self.ensure_one() mapping = {} mapping['txt_First[0]'] = self.client_name or '' # txt_CITY[1] is physically the Member ID field (despite the name) mapping['txt_CITY[1]'] = self.member_id or '' mapping['txt_add[0]'] = self.address or '' mapping['txt_CITY[0]'] = self.city or '' mapping['txt_prov[0]'] = self.province or 'ON' mapping['txt_postalcodes[0]'] = self.postal_code or '' # txt_email[0] is physically the Phone field (despite the name) mapping['txt_email[0]'] = self.phone_number or '' mapping['txt_bphone[0]'] = self.alt_phone or '' # txt_emp_phone[0] is physically the Email field (despite the name) mapping['txt_emp_phone[0]'] = self.email or '' # txt_clientnumber[0] is physically the Date field (despite the name) date_str = '' if self.form_date: date_str = self.form_date.strftime('%b %d, %Y') mapping['txt_clientnumber[0]'] = date_str # Item type checkboxes (mapped by physical position on the form) mapping['CheckBox15[0]'] = self.item_type == 'medical_equipment' mapping['CheckBox11[0]'] = self.item_type == 'dentures' mapping['CheckBox11[1]'] = self.item_type == 'vision_care' mapping['CheckBox13[0]'] = self.item_type == 'other' # Description / request details mapping['TextField1[0]'] = self.other_description or '' return mapping def _fill_pdf(self): """Fill the Discretionary Benefits PDF using PyPDF2. This PDF is AES-encrypted, so we use PyPDF2 which handles decryption and form filling natively (pdfrw cannot). """ self.ensure_one() from PyPDF2 import PdfReader, PdfWriter from PyPDF2.generic import NameObject, BooleanObject template_path = self._get_template_path() if not os.path.exists(template_path): raise UserError(_("Discretionary Benefits form template not found at %s") % template_path) mapping = self._build_field_mapping() reader = PdfReader(template_path) if reader.is_encrypted: reader.decrypt('') writer = PdfWriter() for page in reader.pages: writer.add_page(page) # Preserve AcroForm from original if '/AcroForm' in reader.trailer['/Root']: writer._root_object[NameObject('/AcroForm')] = reader.trailer['/Root']['/AcroForm'] writer._root_object['/AcroForm'][NameObject('/NeedAppearances')] = BooleanObject(True) # Split text fields and checkbox fields text_fields = {} checkbox_fields = {} for key, value in mapping.items(): if isinstance(value, bool): checkbox_fields[key] = value else: text_fields[key] = str(value) # Fill text fields via PyPDF2 bulk method writer.update_page_form_field_values(writer.pages[0], text_fields) # Fill checkboxes by directly updating each annotation page = writer.pages[0] for annot_ref in page['/Annots']: annot = annot_ref.get_object() if annot.get('/FT') != '/Btn': continue field_name = str(annot.get('/T', '')) if field_name not in checkbox_fields: continue if checkbox_fields[field_name]: annot[NameObject('/V')] = NameObject('/1') annot[NameObject('/AS')] = NameObject('/1') else: annot[NameObject('/V')] = NameObject('/Off') annot[NameObject('/AS')] = NameObject('/Off') output = io.BytesIO() writer.write(output) return output.getvalue() def _sync_odsp_office(self): """Sync ODSP office back to sale order if changed in wizard.""" if self.odsp_office_id and self.odsp_office_id != self.sale_order_id.x_fc_odsp_office_id: self.sale_order_id.x_fc_odsp_office_id = self.odsp_office_id def _generate_and_attach(self): """Generate filled PDF and quotation, attach both to sale order. Returns (disc_attachment, quote_attachment) ir.attachment records. """ order = self.sale_order_id pdf_data = self._fill_pdf() disc_filename = f'Discretionary_Benefits_{order.name}.pdf' encoded_pdf = base64.b64encode(pdf_data) disc_attachment = self.env['ir.attachment'].create({ 'name': disc_filename, 'type': 'binary', 'datas': encoded_pdf, 'res_model': 'sale.order', 'res_id': order.id, 'mimetype': 'application/pdf', }) if order.x_fc_odsp_division == 'ontario_works': order.write({ 'x_fc_ow_discretionary_form': encoded_pdf, 'x_fc_ow_discretionary_form_filename': disc_filename, }) report = self.env.ref('sale.action_report_saleorder') quote_pdf, _ct = report._render_qweb_pdf(report.id, [order.id]) quote_filename = f'{order.name}.pdf' quote_attachment = self.env['ir.attachment'].create({ 'name': quote_filename, 'type': 'binary', 'datas': base64.b64encode(quote_pdf), 'res_model': 'sale.order', 'res_id': order.id, 'mimetype': 'application/pdf', }) return disc_attachment, quote_attachment def action_fill_and_attach(self): """Fill the Discretionary Benefits PDF and attach to sale order via chatter.""" self.ensure_one() self._sync_odsp_office() order = self.sale_order_id disc_att, quote_att = self._generate_and_attach() if order._get_odsp_status() == 'quotation': order._odsp_advance_status('documents_ready', "Documents ready after Discretionary Benefits form attached.") order.message_post( body=_("Discretionary Benefits form filled and attached."), message_type='comment', attachment_ids=[disc_att.id, quote_att.id], ) return {'type': 'ir.actions.act_window_close'} def _advance_status_on_submit(self, order): """Advance status when form is submitted (sent via email/fax).""" current = order._get_odsp_status() if order.x_fc_odsp_division == 'ontario_works' and current in ('quotation', 'documents_ready'): order._odsp_advance_status('submitted_to_ow', "Discretionary Benefits form submitted to Ontario Works.") elif current == 'quotation': order._odsp_advance_status('documents_ready', "Documents ready after Discretionary Benefits form attached.") def action_send_fax(self): """Fill form, generate quotation, and open the fax wizard with both attached. Chatter posting is handled by the fax module when the fax is actually sent. """ self.ensure_one() self._sync_odsp_office() order = self.sale_order_id office = self.odsp_office_id or order.x_fc_odsp_office_id disc_att, quote_att = self._generate_and_attach() self._advance_status_on_submit(order) ctx = { 'default_sale_order_id': order.id, 'default_partner_id': office.id if office else False, 'default_generate_pdf': False, 'forward_attachment_ids': [disc_att.id, quote_att.id], } if office and hasattr(office, 'x_ff_fax_number') and office.x_ff_fax_number: ctx['default_fax_number'] = office.x_ff_fax_number return { 'type': 'ir.actions.act_window', 'name': _('Send Fax - Discretionary Benefits'), 'res_model': 'fusion_faxes.send.fax.wizard', 'view_mode': 'form', 'target': 'new', 'context': ctx, } def action_send_email(self): """Fill form, generate quotation, and email both to ODSP office.""" self.ensure_one() self._sync_odsp_office() order = self.sale_order_id office = self.odsp_office_id or order.x_fc_odsp_office_id if not office or not office.email: raise UserError(_( "No ODSP Office with an email address is set. " "Please select an ODSP Office before sending." )) disc_att, quote_att = self._generate_and_attach() order._send_odsp_submission_email( attachment_ids=[disc_att.id, quote_att.id], email_body_notes=self.email_body_notes, ) self._advance_status_on_submit(order) order.message_post( body=_("Discretionary Benefits form and quotation emailed to %s.") % office.name, message_type='comment', attachment_ids=[disc_att.id, quote_att.id], ) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Email Sent'), 'message': _('Discretionary Benefits form emailed to %s.') % office.email, 'type': 'success', 'sticky': False, 'next': {'type': 'ir.actions.act_window_close'}, }, } def action_send_fax_and_email(self): """Fill form, generate quotation, send email, then open fax wizard. Email is sent first with a notification, then fax wizard opens. """ self.ensure_one() self._sync_odsp_office() order = self.sale_order_id office = self.odsp_office_id or order.x_fc_odsp_office_id if not office or not office.email: raise UserError(_( "No ODSP Office with an email address is set. " "Please select an ODSP Office before sending." )) disc_att, quote_att = self._generate_and_attach() order._send_odsp_submission_email( attachment_ids=[disc_att.id, quote_att.id], email_body_notes=self.email_body_notes, ) self._advance_status_on_submit(order) order.message_post( body=_("Discretionary Benefits form and quotation emailed to %s.") % office.name, message_type='comment', attachment_ids=[disc_att.id, quote_att.id], ) ctx = { 'default_sale_order_id': order.id, 'default_partner_id': office.id, 'default_generate_pdf': False, 'forward_attachment_ids': [disc_att.id, quote_att.id], } if hasattr(office, 'x_ff_fax_number') and office.x_ff_fax_number: ctx['default_fax_number'] = office.x_ff_fax_number fax_action = { 'type': 'ir.actions.act_window', 'name': _('Send Fax - Discretionary Benefits'), 'res_model': 'fusion_faxes.send.fax.wizard', 'view_mode': 'form', 'target': 'new', 'context': ctx, } return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Email Sent'), 'message': _('Email sent to %s. Now proceeding to fax...') % office.email, 'type': 'success', 'sticky': False, 'next': fax_action, }, }