Initial commit
This commit is contained in:
581
fusion_faxes/models/fusion_fax.py
Normal file
581
fusion_faxes/models/fusion_fax.py
Normal file
@@ -0,0 +1,581 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user