582 lines
21 KiB
Python
582 lines
21 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 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(
|
|
'<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
|
|
# ------------------------------------------------------------------
|
|
|
|
@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
|