# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) import base64 import json import logging from datetime import datetime, timedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError from markupsafe import Markup _logger = logging.getLogger(__name__) class FusionFax(models.Model): _name = 'fusion.fax' _description = 'Fax Record' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'create_date desc' _rec_name = 'name' name = fields.Char( string='Reference', required=True, copy=False, readonly=True, default=lambda self: _('New'), ) direction = fields.Selection([ ('outbound', 'Outbound'), ('inbound', 'Inbound'), ], string='Direction', default='outbound', required=True, tracking=True) partner_id = fields.Many2one( 'res.partner', string='Recipient', tracking=True, ) fax_number = fields.Char( string='Fax Number', required=True, tracking=True, ) state = fields.Selection([ ('draft', 'Draft'), ('sending', 'Sending'), ('sent', 'Sent'), ('failed', 'Failed'), ('received', 'Received'), ], string='Status', default='draft', required=True, tracking=True) # Inbound fax fields sender_number = fields.Char( string='Sender Number', readonly=True, ) received_date = fields.Datetime( string='Received Date', readonly=True, ) rc_read_status = fields.Char( string='Read Status', readonly=True, ) # Computed display fields for list views (work regardless of direction) display_date = fields.Datetime( string='Date', compute='_compute_display_fields', store=True, ) display_number = fields.Char( string='Fax Number', compute='_compute_display_fields', store=True, ) cover_page_text = fields.Text(string='Cover Page Text') document_ids = fields.One2many( 'fusion.fax.document', 'fax_id', string='Documents', ) document_count = fields.Integer( compute='_compute_document_count', ) # Keep for backwards compat with existing records attachment_ids = fields.Many2many( 'ir.attachment', 'fusion_fax_attachment_rel', 'fax_id', 'attachment_id', string='Attachments (Legacy)', ) ringcentral_message_id = fields.Char( string='RingCentral Message ID', readonly=True, copy=False, ) sent_date = fields.Datetime( string='Sent Date', readonly=True, copy=False, ) sent_by_id = fields.Many2one( 'res.users', string='Sent By', readonly=True, default=lambda self: self.env.user, ) page_count = fields.Integer( string='Pages', readonly=True, copy=False, ) error_message = fields.Text( string='Error Message', readonly=True, copy=False, ) # Links to source documents sale_order_id = fields.Many2one( 'sale.order', string='Sale Order', ondelete='set null', tracking=True, ) account_move_id = fields.Many2one( 'account.move', string='Invoice', ondelete='set null', tracking=True, ) def write(self, vals): """Post chatter message when a sale order or invoice is linked.""" old_so_ids = {rec.id: rec.sale_order_id.id for rec in self} result = super().write(vals) if 'sale_order_id' in vals: for rec in self: new_so = rec.sale_order_id old_so_id = old_so_ids.get(rec.id) if new_so and new_so.id != old_so_id: rec._post_link_chatter_message(new_so) # Also set the partner from the SO if not already matched if not rec.partner_id and new_so.partner_id: rec.partner_id = new_so.partner_id return result def _post_link_chatter_message(self, sale_order): """Post a message on the sale order when a fax is linked to it.""" self.ensure_one() direction_label = 'Received' if self.direction == 'inbound' else 'Sent' date_str = '' if self.direction == 'inbound' and self.received_date: date_str = self.received_date.strftime('%b %d, %Y %H:%M') elif self.sent_date: date_str = self.sent_date.strftime('%b %d, %Y %H:%M') number = self.sender_number or self.fax_number or '' body = Markup( '
Fax Linked
' '%s fax %s has been linked to this order.
' 'Fax Sent
' 'Fax %s sent successfully to %s (%s).
' 'Pages: %s | RingCentral ID: %s
' ) % (self.name, self.partner_id.name, self.fax_number, self.page_count or '-', self.ringcentral_message_id or '-') else: body = Markup( 'Fax Failed
' 'Fax %s to %s (%s) failed.
' 'Error: %s
' ) % (self.name, self.partner_id.name, self.fax_number, self.error_message or 'Unknown error') if self.sale_order_id: self.sale_order_id.message_post(body=body, message_type='notification') if self.account_move_id: self.account_move_id.message_post(body=body, message_type='notification') # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def action_send(self): """Button action to send this fax.""" self.ensure_one() self._send_fax() def action_retry(self): """Retry a failed fax.""" self.ensure_one() if self.state != 'failed': raise UserError(_('Only failed faxes can be retried.')) self._send_fax() def action_resend(self): """Resend a previously sent fax with all the same attachments.""" self.ensure_one() if self.state != 'sent': raise UserError(_('Only sent faxes can be resent.')) self._send_fax() def action_open_sale_order(self): """Open the linked sale order.""" self.ensure_one() if not self.sale_order_id: return return { 'type': 'ir.actions.act_window', 'name': self.sale_order_id.name, 'res_model': 'sale.order', 'res_id': self.sale_order_id.id, 'view_mode': 'form', } def action_reset_to_draft(self): """Reset a failed fax back to draft.""" self.ensure_one() if self.state not in ('failed',): raise UserError(_('Only failed faxes can be reset to draft.')) self.write({ 'state': 'draft', 'error_message': False, }) # ------------------------------------------------------------------ # Incoming fax polling (uses rc.config OAuth) # ------------------------------------------------------------------ @api.model def _cron_fetch_incoming_faxes(self): """Poll RingCentral for inbound faxes via rc.config OAuth.""" ICP = self.env['ir.config_parameter'].sudo() enabled = ICP.get_param('fusion_faxes.ringcentral_enabled', 'False') if enabled not in ('True', 'true', '1'): return try: rc_config = self.env['rc.config']._get_active_config() except Exception: rc_config = False if not rc_config: _logger.debug("Fusion Faxes: No active RingCentral config, skipping inbound poll.") return last_poll = ICP.get_param('fusion_faxes.last_inbound_poll', '') if not last_poll: date_from = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%dT%H:%M:%S.000Z') else: date_from = last_poll try: self._fetch_faxes_from_rc(rc_config, date_from) except Exception: _logger.exception("Fusion Faxes: Error fetching inbound faxes.") @api.model def _run_historical_fax_import(self): """Background job: import up to 12 months of inbound faxes in monthly chunks.""" rc_config = self.env['rc.config']._get_active_config() if not rc_config: _logger.warning("Fax Historical Import: No connected RC config.") return ICP = self.env['ir.config_parameter'].sudo() now = datetime.utcnow() total_imported = 0 for months_back in range(12, 0, -1): chunk_start = now - timedelta(days=months_back * 30) chunk_key = f'fusion_rc.fax_import_done_{chunk_start.strftime("%Y%m")}' if ICP.get_param(chunk_key, ''): continue date_from = chunk_start.strftime('%Y-%m-%dT%H:%M:%S.000Z') date_to = (now - timedelta(days=(months_back - 1) * 30)).strftime('%Y-%m-%dT%H:%M:%S.000Z') _logger.info("Fax Import: chunk %s to %s ...", date_from[:10], date_to[:10]) try: count = self._fetch_faxes_from_rc(rc_config, date_from, date_to=date_to) total_imported += count ICP.set_param(chunk_key, 'done') except Exception: _logger.exception("Fax Import: chunk failed, will retry next run.") _logger.info("Fax Historical Import complete: %d total imported.", total_imported) @api.model def _fetch_faxes_from_rc(self, rc_config, date_from, date_to=None): """Fetch inbound faxes from RingCentral and create records. Returns import count.""" import time as _time ICP = self.env['ir.config_parameter'].sudo() total_imported = 0 total_skipped = 0 params = { 'messageType': 'Fax', 'direction': 'Inbound', 'dateFrom': date_from, 'perPage': '100', } if date_to: params['dateTo'] = date_to endpoint = '/restapi/v1.0/account/~/extension/~/message-store' page = 1 while True: params['page'] = str(page) data = rc_config._api_get(endpoint, params=params) records = data.get('records', []) if not records: break for msg in records: msg_id = str(msg.get('id', '')) if not msg_id: continue if self.search_count([('ringcentral_message_id', '=', msg_id)]): total_skipped += 1 continue if self._import_inbound_fax(msg, rc_config): total_imported += 1 paging = data.get('paging', {}) if page >= paging.get('totalPages', 1): break page += 1 _time.sleep(2) if not date_to: ICP.set_param( 'fusion_faxes.last_inbound_poll', datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z'), ) if total_imported: _logger.info( "Fusion Faxes: Imported %d inbound faxes, skipped %d duplicates.", total_imported, total_skipped, ) return total_imported def _import_inbound_fax(self, msg, rc_config): """Import a single inbound fax message dict from RingCentral.""" try: import requests as _requests msg_id = str(msg.get('id', '')) from_info = msg.get('from', {}) sender = from_info.get('phoneNumber', '') if isinstance(from_info, dict) else '' creation_time = msg.get('creationTime', '') read_status = msg.get('readStatus', '') page_count = msg.get('faxPageCount', 0) attachments = msg.get('attachments', []) received_dt = False if creation_time: try: clean_time = creation_time.replace('Z', '+00:00') received_dt = datetime.fromisoformat(clean_time).strftime('%Y-%m-%d %H:%M:%S') except (ValueError, AttributeError): pass partner = False if sender: partner = self.env['res.partner'].sudo().search( [('x_ff_fax_number', '=', sender)], limit=1 ) document_lines = [] headers = rc_config._get_headers() for att in attachments: att_uri = att.get('uri', '') att_type = att.get('contentType', '') if not att_uri: continue try: resp = _requests.get( att_uri, headers=headers, timeout=30, verify=rc_config.ssl_verify, proxies=rc_config._get_proxies(), ) resp.raise_for_status() pdf_content = resp.content if not pdf_content: continue file_ext = 'pdf' if 'pdf' in (att_type or '') else 'bin' file_name = f'FAX_IN_{msg_id}.{file_ext}' ir_attachment = self.env['ir.attachment'].sudo().create({ 'name': file_name, 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'mimetype': att_type or 'application/pdf', 'res_model': 'fusion.fax', }) document_lines.append((0, 0, { 'sequence': 10, 'attachment_id': ir_attachment.id, })) except Exception: _logger.exception("Fusion Faxes: Failed to download attachment for message %s", msg_id) self.sudo().create({ 'direction': 'inbound', 'state': 'received', 'fax_number': sender or 'Unknown', 'sender_number': sender, 'partner_id': partner.id if partner else False, 'ringcentral_message_id': msg_id, 'received_date': received_dt, 'page_count': page_count or 0, 'rc_read_status': read_status, 'document_ids': document_lines, }) return True except Exception: _logger.exception("Fusion Faxes: Failed to import inbound fax message.") return False