# -*- 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.

' '' ) % (direction_label, self.id, self.name, number, date_str or '-', self.page_count or '-') # Attach the fax documents to the chatter message attachment_ids = self.document_ids.mapped('attachment_id').ids sale_order.message_post( body=body, message_type='notification', attachment_ids=attachment_ids, ) @api.depends('document_ids') def _compute_document_count(self): for rec in self: rec.document_count = len(rec.document_ids) @api.depends('direction', 'sent_date', 'received_date', 'fax_number', 'sender_number') def _compute_display_fields(self): for rec in self: if rec.direction == 'inbound': rec.display_date = rec.received_date rec.display_number = rec.sender_number or rec.fax_number else: rec.display_date = rec.sent_date rec.display_number = rec.fax_number @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get('name', _('New')) == _('New'): vals['name'] = self.env['ir.sequence'].next_by_code('fusion.fax') or _('New') return super().create(vals_list) # ------------------------------------------------------------------ # RingCentral helpers # ------------------------------------------------------------------ def _get_rc_config(self): """Return the active rc.config record or raise.""" try: config = self.env['rc.config']._get_active_config() except Exception: config = False if not config: raise UserError(_( 'RingCentral is not connected. ' 'Go to Settings > Fusion RingCentral and connect via OAuth.' )) return config def _get_rc_sdk(self): """Initialize and authenticate the RingCentral SDK. Returns (sdk, platform) tuple. Tries JWT credentials first (Fusion Faxes settings), then falls back to the rc.config OAuth credentials + SDK JWT if available. """ ICP = self.env['ir.config_parameter'].sudo() enabled = ICP.get_param('fusion_faxes.ringcentral_enabled', 'False') if enabled not in ('True', 'true', '1'): raise UserError(_('RingCentral faxing is not enabled. Go to Settings > Fusion Faxes to enable it.')) client_id = ICP.get_param('fusion_faxes.ringcentral_client_id', '') client_secret = ICP.get_param('fusion_faxes.ringcentral_client_secret', '') server_url = ICP.get_param('fusion_faxes.ringcentral_server_url', 'https://platform.ringcentral.com') jwt_token = ICP.get_param('fusion_faxes.ringcentral_jwt_token', '') if not all([client_id, client_secret, jwt_token]): raise UserError(_( 'RingCentral JWT credentials are not configured. ' 'Go to Settings > Fusion Faxes and enter Client ID, Client Secret, and JWT Token. ' 'JWT is required for outbound fax sending.' )) try: from ringcentral import SDK except ImportError: raise UserError(_( 'The ringcentral Python package is not installed. ' 'Run: pip install ringcentral' )) sdk = SDK(client_id, client_secret, server_url) platform = sdk.platform() platform.login(jwt=jwt_token) return sdk, platform def _get_ordered_attachments(self): """Return attachments in the correct order: document_ids by sequence, or legacy attachment_ids.""" self.ensure_one() if self.document_ids: return self.document_ids.sorted('sequence').mapped('attachment_id') return self.attachment_ids def _send_fax(self): """Send this fax record via RingCentral API. Tries JWT/SDK first (if configured), then falls back to rc.config OAuth with raw multipart POST. """ self.ensure_one() attachments = self._get_ordered_attachments() if not attachments: raise UserError(_('Please attach at least one document to send.')) self.write({'state': 'sending', 'error_message': False}) ICP = self.env['ir.config_parameter'].sudo() jwt_token = ICP.get_param('fusion_faxes.ringcentral_jwt_token', '') if jwt_token: self._send_fax_sdk(attachments) else: self._send_fax_oauth(attachments) def _send_fax_sdk(self, attachments): """Send fax using the RingCentral Python SDK (JWT auth).""" try: sdk, platform = self._get_rc_sdk() builder = sdk.create_multipart_builder() body = { 'to': [{'phoneNumber': self.fax_number}], 'faxResolution': 'High', } if self.cover_page_text: body['coverPageText'] = self.cover_page_text builder.set_body(body) for attachment in attachments: file_content = base64.b64decode(attachment.datas) builder.add((attachment.name, file_content)) request = builder.request('/restapi/v1.0/account/~/extension/~/fax') response = platform.send_request(request) result = response.json() message_id = '' page_count = 0 if isinstance(result, dict): message_id = str(result.get('id', '')) page_count = result.get('pageCount', 0) else: message_id = str(getattr(result, 'id', '')) page_count = getattr(result, 'pageCount', 0) self._finalize_send(message_id, page_count) except UserError: raise except Exception as e: self._handle_send_error(e) def _send_fax_oauth(self, attachments): """Send fax using rc.config OAuth with multipart POST.""" import requests as _requests try: rc_config = self._get_rc_config() headers = rc_config._get_headers() del headers['Content-Type'] body = { 'to': [{'phoneNumber': self.fax_number}], 'faxResolution': 'High', } if self.cover_page_text: body['coverPageText'] = self.cover_page_text files = [ ('json', (None, json.dumps(body), 'application/json')), ] for attachment in attachments: file_content = base64.b64decode(attachment.datas) mime = attachment.mimetype or 'application/pdf' files.append(('attachment', (attachment.name, file_content, mime))) url = f'{rc_config.server_url}/restapi/v1.0/account/~/extension/~/fax' resp = _requests.post( url, headers=headers, files=files, timeout=60, verify=rc_config.ssl_verify, proxies=rc_config._get_proxies(), ) resp.raise_for_status() result = resp.json() message_id = str(result.get('id', '')) page_count = result.get('pageCount', 0) self._finalize_send(message_id, page_count) except UserError: raise except Exception as e: self._handle_send_error(e) def _finalize_send(self, message_id, page_count): self.write({ 'state': 'sent', 'ringcentral_message_id': message_id, 'sent_date': fields.Datetime.now(), 'sent_by_id': self.env.user.id, 'page_count': page_count, }) self._post_fax_chatter_message(success=True) _logger.info("Fax %s sent successfully. RC Message ID: %s", self.name, message_id) def _handle_send_error(self, e): error_msg = str(e) self.write({ 'state': 'failed', 'error_message': error_msg, }) self._post_fax_chatter_message(success=False) _logger.exception("Fax %s failed to send", self.name) raise UserError(_('Fax sending failed: %s') % error_msg) def _post_fax_chatter_message(self, success=True): """Post a chatter message on the linked sale order or invoice.""" self.ensure_one() if success: body = Markup( '

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