Files
Odoo-Modules/fusion_faxes/models/fusion_fax.py
gsinghpal acd3fc455e changes
2026-03-09 15:21:22 -04:00

658 lines
23 KiB
Python

# -*- 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(
'<p><strong>Fax Linked</strong></p>'
'<p>%s fax <a href="/odoo/faxes/%s"><b>%s</b></a> has been linked to this order.</p>'
'<ul>'
'<li>Fax Number: %s</li>'
'<li>Date: %s</li>'
'<li>Pages: %s</li>'
'</ul>'
) % (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(
'<p><strong>Fax Sent</strong></p>'
'<p>Fax <b>%s</b> sent successfully to <b>%s</b> (%s).</p>'
'<p>Pages: %s | RingCentral ID: %s</p>'
) % (self.name, self.partner_id.name, self.fax_number,
self.page_count or '-', self.ringcentral_message_id or '-')
else:
body = Markup(
'<p><strong>Fax Failed</strong></p>'
'<p>Fax <b>%s</b> to <b>%s</b> (%s) failed.</p>'
'<p>Error: %s</p>'
) % (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