# -*- 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 SDK helpers # ------------------------------------------------------------------ def _get_rc_sdk(self): """Initialize and authenticate the RingCentral SDK. Returns (sdk, platform) tuple.""" 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 credentials are not configured. ' 'Go to Settings > Fusion Faxes and enter Client ID, Client Secret, and JWT Token.' )) 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.""" 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}) try: sdk, platform = self._get_rc_sdk() # Use the SDK's multipart builder builder = sdk.create_multipart_builder() # Set the JSON body (metadata) body = { 'to': [{'phoneNumber': self.fax_number}], 'faxResolution': 'High', } if self.cover_page_text: body['coverPageText'] = self.cover_page_text builder.set_body(body) # Add document attachments in sequence order for attachment in attachments: file_content = base64.b64decode(attachment.datas) builder.add((attachment.name, file_content)) # Build the request and send request = builder.request('/restapi/v1.0/account/~/extension/~/fax') response = platform.send_request(request) result = response.json() # Extract response fields message_id = '' page_count = 0 if hasattr(result, 'id'): message_id = str(result.id) elif isinstance(result, dict): message_id = str(result.get('id', '')) if hasattr(result, 'pageCount'): page_count = result.pageCount elif isinstance(result, dict): page_count = result.get('pageCount', 0) 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, }) # Post chatter message on linked documents self._post_fax_chatter_message(success=True) _logger.info("Fax %s sent successfully. RC Message ID: %s", self.name, message_id) except UserError: raise except Exception as 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 # ------------------------------------------------------------------ @api.model def _cron_fetch_incoming_faxes(self): """Poll RingCentral for inbound faxes and create records.""" ICP = self.env['ir.config_parameter'].sudo() enabled = ICP.get_param('fusion_faxes.ringcentral_enabled', 'False') if enabled not in ('True', 'true', '1'): return 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]): _logger.warning("Fusion Faxes: RingCentral credentials not configured, skipping inbound poll.") return try: from ringcentral import SDK except ImportError: _logger.error("Fusion Faxes: ringcentral package not installed.") return # Determine dateFrom: last poll or 1 year ago for first run last_poll = ICP.get_param('fusion_faxes.last_inbound_poll', '') if last_poll: date_from = last_poll else: one_year_ago = datetime.utcnow() - timedelta(days=365) date_from = one_year_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z') try: sdk = SDK(client_id, client_secret, server_url) platform = sdk.platform() platform.login(jwt=jwt_token) total_imported = 0 total_skipped = 0 # Fetch first page endpoint = ( '/restapi/v1.0/account/~/extension/~/message-store' f'?messageType=Fax&direction=Inbound&dateFrom={date_from}' '&perPage=100' ) while endpoint: response = platform.get(endpoint) data = response.json() records = [] if hasattr(data, 'records'): records = data.records elif isinstance(data, dict): records = data.get('records', []) for msg in records: msg_id = str(msg.get('id', '')) if isinstance(msg, dict) else str(getattr(msg, 'id', '')) # Deduplicate existing = self.search_count([('ringcentral_message_id', '=', msg_id)]) if existing: total_skipped += 1 continue imported = self._import_inbound_fax(msg, platform) if imported: total_imported += 1 # Handle pagination endpoint = None navigation = None if isinstance(data, dict): navigation = data.get('navigation', {}) elif hasattr(data, 'navigation'): navigation = data.navigation if navigation: next_page = None if isinstance(navigation, dict): next_page = navigation.get('nextPage', {}) elif hasattr(navigation, 'nextPage'): next_page = navigation.nextPage if next_page: next_uri = next_page.get('uri', '') if isinstance(next_page, dict) else getattr(next_page, 'uri', '') if next_uri: endpoint = next_uri # Update last poll timestamp 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) except Exception: _logger.exception("Fusion Faxes: Error fetching inbound faxes from RingCentral.") def _import_inbound_fax(self, msg, platform): """Import a single inbound fax message from RingCentral.""" try: # Extract fields (handle both dict and SDK JsonObject responses) if isinstance(msg, dict): 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', []) else: msg_id = str(getattr(msg, 'id', '')) # SDK exposes 'from' as 'from_' since 'from' is a Python keyword from_info = getattr(msg, 'from_', None) or getattr(msg, 'from', None) sender = getattr(from_info, 'phoneNumber', '') if from_info else '' creation_time = getattr(msg, 'creationTime', '') read_status = getattr(msg, 'readStatus', '') page_count = getattr(msg, 'faxPageCount', 0) attachments = getattr(msg, 'attachments', []) # Parse received datetime 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): received_dt = False # Try to match sender to a partner partner = False if sender: partner = self.env['res.partner'].sudo().search( [('x_ff_fax_number', '=', sender)], limit=1 ) # Download the PDF attachment document_lines = [] for att in attachments: att_uri = att.get('uri', '') if isinstance(att, dict) else getattr(att, 'uri', '') att_type = att.get('contentType', '') if isinstance(att, dict) else getattr(att, 'contentType', '') if not att_uri: continue try: att_response = platform.get(att_uri) pdf_content = att_response.body() 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) # Create the fax record 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