Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

6
fusion_faxes/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import models
from . import wizard

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Faxes',
'version': '19.0.2.0.0',
'category': 'Productivity',
'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.',
'description': """
Fusion Faxes
============
Send faxes directly from Odoo using the RingCentral REST API.
Features:
---------
* Send faxes from Sale Orders and Invoices
* Fax history tracked per contact
* Fax log with status tracking (draft/sending/sent/failed)
* Cover page text support
* Multiple document attachments per fax
* Chatter integration for fax events
* RingCentral JWT authentication (server-to-server)
Copyright 2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': [
'base',
'mail',
'sale',
'sale_management',
'account',
],
'external_dependencies': {
'python': ['ringcentral'],
},
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'data/ir_sequence_data.xml',
'data/ir_cron_data.xml',
'views/fusion_fax_views.xml',
'views/dashboard_views.xml',
'views/res_config_settings_views.xml',
'views/res_partner_views.xml',
'views/sale_order_views.xml',
'views/account_move_views.xml',
'wizard/send_fax_wizard_views.xml',
],
'installable': True,
'auto_install': False,
'application': True,
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="config_ringcentral_enabled" model="ir.config_parameter">
<field name="key">fusion_faxes.ringcentral_enabled</field>
<field name="value">False</field>
</record>
<record id="config_ringcentral_server_url" model="ir.config_parameter">
<field name="key">fusion_faxes.ringcentral_server_url</field>
<field name="value">https://platform.ringcentral.com</field>
</record>
<record id="config_ringcentral_client_id" model="ir.config_parameter">
<field name="key">fusion_faxes.ringcentral_client_id</field>
<field name="value"></field>
</record>
<record id="config_ringcentral_client_secret" model="ir.config_parameter">
<field name="key">fusion_faxes.ringcentral_client_secret</field>
<field name="value"></field>
</record>
<record id="config_ringcentral_jwt_token" model="ir.config_parameter">
<field name="key">fusion_faxes.ringcentral_jwt_token</field>
<field name="value"></field>
</record>
<record id="config_last_inbound_poll" model="ir.config_parameter">
<field name="key">fusion_faxes.last_inbound_poll</field>
<field name="value"></field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_fetch_incoming_faxes" model="ir.cron">
<field name="name">Fusion Faxes: Fetch Incoming Faxes</field>
<field name="model_id" ref="model_fusion_fax"/>
<field name="state">code</field>
<field name="code">model._cron_fetch_incoming_faxes()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_fusion_fax" model="ir.sequence">
<field name="name">Fusion Fax</field>
<field name="code">fusion.fax</field>
<field name="prefix">FAX/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fusion_fax
from . import fusion_fax_document
from . import dashboard
from . import res_config_settings
from . import res_partner
from . import sale_order
from . import account_move

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class AccountMove(models.Model):
_inherit = 'account.move'
x_ff_fax_ids = fields.One2many(
'fusion.fax',
'account_move_id',
string='Faxes',
)
x_ff_fax_count = fields.Integer(
string='Fax Count',
compute='_compute_fax_count',
)
@api.depends('x_ff_fax_ids')
def _compute_fax_count(self):
for move in self:
move.x_ff_fax_count = len(move.x_ff_fax_ids)
def action_send_fax(self):
"""Open the Send Fax wizard pre-filled with this invoice."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Send Fax',
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_model': 'account.move',
'active_id': self.id,
},
}
def action_view_faxes(self):
"""Open fax history for this invoice."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Faxes',
'res_model': 'fusion.fax',
'view_mode': 'list,form',
'domain': [('account_move_id', '=', self.id)],
'context': {'default_account_move_id': self.id},
}

View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models, _
import logging
_logger = logging.getLogger(__name__)
STATUS_DOMAINS = {
'all': [],
'sent': [('state', '=', 'sent')],
'failed': [('state', '=', 'failed')],
'draft': [('state', '=', 'draft')],
'received': [('state', '=', 'received')],
}
class FusionFaxDashboard(models.TransientModel):
_name = 'fusion.fax.dashboard'
_description = 'Fusion Fax Dashboard'
_rec_name = 'name'
name = fields.Char(default='Fax Dashboard', readonly=True)
# KPI stat fields
total_count = fields.Integer(compute='_compute_stats')
sent_count = fields.Integer(compute='_compute_stats')
received_count = fields.Integer(compute='_compute_stats')
failed_count = fields.Integer(compute='_compute_stats')
draft_count = fields.Integer(compute='_compute_stats')
# Recent faxes as a proper relational field for embedded list
recent_fax_ids = fields.Many2many(
'fusion.fax',
compute='_compute_recent_faxes',
)
def _compute_stats(self):
Fax = self.env['fusion.fax'].sudo()
for rec in self:
rec.total_count = Fax.search_count([])
rec.sent_count = Fax.search_count(STATUS_DOMAINS['sent'])
rec.received_count = Fax.search_count(STATUS_DOMAINS['received'])
rec.failed_count = Fax.search_count(STATUS_DOMAINS['failed'])
rec.draft_count = Fax.search_count(STATUS_DOMAINS['draft'])
def _compute_recent_faxes(self):
Fax = self.env['fusion.fax'].sudo()
for rec in self:
rec.recent_fax_ids = Fax.search([], order='create_date desc', limit=20)
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_open_all(self):
return self._open_fax_list('All Faxes', STATUS_DOMAINS['all'])
def action_open_sent(self):
return self._open_fax_list('Sent Faxes', STATUS_DOMAINS['sent'])
def action_open_received(self):
return self._open_fax_list('Received Faxes', STATUS_DOMAINS['received'])
def action_open_failed(self):
return self._open_fax_list('Failed Faxes', STATUS_DOMAINS['failed'])
def action_open_draft(self):
return self._open_fax_list('Draft Faxes', STATUS_DOMAINS['draft'])
def _open_fax_list(self, name, domain):
return {
'type': 'ir.actions.act_window',
'name': name,
'res_model': 'fusion.fax',
'view_mode': 'list,form',
'domain': domain,
}
def action_send_fax(self):
return {
'type': 'ir.actions.act_window',
'name': 'Send Fax',
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
}

View 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

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FusionFaxDocument(models.Model):
_name = 'fusion.fax.document'
_description = 'Fax Document Line'
_order = 'sequence, id'
fax_id = fields.Many2one(
'fusion.fax',
string='Fax',
required=True,
ondelete='cascade',
)
sequence = fields.Integer(
string='Order',
default=10,
)
attachment_id = fields.Many2one(
'ir.attachment',
string='Document',
required=True,
ondelete='cascade',
)
file_name = fields.Char(
related='attachment_id.name',
string='File Name',
)
mimetype = fields.Char(
related='attachment_id.mimetype',
string='Type',
)
def action_preview(self):
"""Open the attachment in Odoo's built-in PDF viewer dialog."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fusion_claims.preview_document',
'params': {
'attachment_id': self.attachment_id.id,
'title': self.file_name or 'Document Preview',
},
}

View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
import logging
_logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
ff_ringcentral_enabled = fields.Boolean(
string='Enable RingCentral Faxing',
config_parameter='fusion_faxes.ringcentral_enabled',
)
ff_ringcentral_server_url = fields.Char(
string='RingCentral Server URL',
config_parameter='fusion_faxes.ringcentral_server_url',
default='https://platform.ringcentral.com',
)
ff_ringcentral_client_id = fields.Char(
string='Client ID',
config_parameter='fusion_faxes.ringcentral_client_id',
groups='fusion_faxes.group_fax_manager',
)
ff_ringcentral_client_secret = fields.Char(
string='Client Secret',
config_parameter='fusion_faxes.ringcentral_client_secret',
groups='fusion_faxes.group_fax_manager',
)
ff_ringcentral_jwt_token = fields.Char(
string='JWT Token',
config_parameter='fusion_faxes.ringcentral_jwt_token',
groups='fusion_faxes.group_fax_manager',
)
def action_test_ringcentral_connection(self):
"""Test connection to RingCentral using stored credentials."""
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
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]):
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Configuration Missing',
'message': 'Please fill in Client ID, Client Secret, and JWT Token before testing.',
'type': 'warning',
'sticky': False,
},
}
try:
from ringcentral import SDK
sdk = SDK(client_id, client_secret, server_url)
platform = sdk.platform()
platform.login(jwt=jwt_token)
# Fetch account info to verify
res = platform.get('/restapi/v1.0/account/~/extension/~')
ext_info = res.json()
ext_name = ext_info.name if hasattr(ext_info, 'name') else str(ext_info.get('name', 'Unknown'))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Connection Successful',
'message': f'Connected to RingCentral as: {ext_name}',
'type': 'success',
'sticky': False,
},
}
except ImportError:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'SDK Not Installed',
'message': 'The ringcentral Python package is not installed. Run: pip install ringcentral',
'type': 'danger',
'sticky': True,
},
}
except Exception as e:
_logger.exception("RingCentral connection test failed")
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Connection Failed',
'message': str(e),
'type': 'danger',
'sticky': True,
},
}
def set_values(self):
"""Protect credential fields from being blanked accidentally."""
protected_keys = [
'fusion_faxes.ringcentral_client_id',
'fusion_faxes.ringcentral_client_secret',
'fusion_faxes.ringcentral_jwt_token',
]
ICP = self.env['ir.config_parameter'].sudo()
for key in protected_keys:
field_map = {
'fusion_faxes.ringcentral_client_id': 'ff_ringcentral_client_id',
'fusion_faxes.ringcentral_client_secret': 'ff_ringcentral_client_secret',
'fusion_faxes.ringcentral_jwt_token': 'ff_ringcentral_jwt_token',
}
field_name = field_map[key]
new_val = getattr(self, field_name, None)
if new_val in (None, False, ''):
existing = ICP.get_param(key, '')
if existing:
ICP.set_param(key, existing)
return super().set_values()

View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
x_ff_fax_number = fields.Char(string='Fax Number')
x_ff_fax_ids = fields.One2many(
'fusion.fax',
'partner_id',
string='Fax History',
)
x_ff_fax_count = fields.Integer(
string='Fax Count',
compute='_compute_fax_count',
)
@api.depends('x_ff_fax_ids')
def _compute_fax_count(self):
for partner in self:
partner.x_ff_fax_count = len(partner.x_ff_fax_ids)
def action_view_faxes(self):
"""Open fax history for this contact."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Fax History',
'res_model': 'fusion.fax',
'view_mode': 'list,form',
'domain': [('partner_id', '=', self.id)],
'context': {'default_partner_id': self.id},
}

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
x_ff_fax_ids = fields.One2many(
'fusion.fax',
'sale_order_id',
string='Faxes',
)
x_ff_fax_count = fields.Integer(
string='Fax Count',
compute='_compute_fax_count',
)
@api.depends('x_ff_fax_ids')
def _compute_fax_count(self):
for order in self:
order.x_ff_fax_count = len(order.x_ff_fax_ids)
def action_send_fax(self):
"""Open the Send Fax wizard pre-filled with this sale order."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Send Fax',
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_model': 'sale.order',
'active_id': self.id,
},
}
def action_view_faxes(self):
"""Open fax history for this sale order."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Faxes',
'res_model': 'fusion.fax',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}

View File

@@ -0,0 +1,8 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_fax_user,fusion.fax.user,model_fusion_fax,group_fax_user,1,1,1,0
access_fusion_fax_manager,fusion.fax.manager,model_fusion_fax,group_fax_manager,1,1,1,1
access_fusion_send_fax_wizard_user,fusion.send.fax.wizard.user,model_fusion_faxes_send_fax_wizard,group_fax_user,1,1,1,1
access_fusion_send_fax_wizard_line_user,fusion.send.fax.wizard.line.user,model_fusion_faxes_send_fax_wizard_line,group_fax_user,1,1,1,1
access_fusion_fax_document_user,fusion.fax.document.user,model_fusion_fax_document,group_fax_user,1,1,1,0
access_fusion_fax_document_manager,fusion.fax.document.manager,model_fusion_fax_document,group_fax_manager,1,1,1,1
access_fusion_fax_dashboard_user,fusion.fax.dashboard.user,model_fusion_fax_dashboard,group_fax_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_fax_user fusion.fax.user model_fusion_fax group_fax_user 1 1 1 0
3 access_fusion_fax_manager fusion.fax.manager model_fusion_fax group_fax_manager 1 1 1 1
4 access_fusion_send_fax_wizard_user fusion.send.fax.wizard.user model_fusion_faxes_send_fax_wizard group_fax_user 1 1 1 1
5 access_fusion_send_fax_wizard_line_user fusion.send.fax.wizard.line.user model_fusion_faxes_send_fax_wizard_line group_fax_user 1 1 1 1
6 access_fusion_fax_document_user fusion.fax.document.user model_fusion_fax_document group_fax_user 1 1 1 0
7 access_fusion_fax_document_manager fusion.fax.document.manager model_fusion_fax_document group_fax_manager 1 1 1 1
8 access_fusion_fax_dashboard_user fusion.fax.dashboard.user model_fusion_fax_dashboard group_fax_user 1 1 1 1

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- User group: can send faxes and view own fax history -->
<record id="group_fax_user" model="res.groups">
<field name="name">Fusion Faxes / User</field>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Manager group: can view all faxes and configure settings -->
<record id="group_fax_manager" model="res.groups">
<field name="name">Fusion Faxes / Manager</field>
<field name="implied_ids" eval="[(4, ref('group_fax_user'))]"/>
</record>
<!-- Record rules -->
<!-- Users see only their own faxes -->
<record id="rule_fax_user_own" model="ir.rule">
<field name="name">Fax: user sees own faxes</field>
<field name="model_id" ref="model_fusion_fax"/>
<field name="domain_force">[('sent_by_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fax_user'))]"/>
</record>
<!-- Managers see all faxes -->
<record id="rule_fax_manager_all" model="ir.rule">
<field name="name">Fax: manager sees all faxes</field>
<field name="model_id" ref="model_fusion_fax"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fax_manager'))]"/>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Send Fax button + smart button to invoice form -->
<record id="view_account_move_form_inherit_fusion_faxes" model="ir.ui.view">
<field name="name">account.move.form.inherit.fusion_faxes</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<!-- Smart button for fax count -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_faxes" type="object"
class="oe_stat_button" icon="fa-fax"
invisible="x_ff_fax_count == 0">
<field name="x_ff_fax_count" widget="statinfo" string="Faxes"/>
</button>
</xpath>
<!-- Send Fax header button -->
<xpath expr="//header" position="inside">
<button name="action_send_fax" string="Send Fax"
type="object" class="btn-secondary"
icon="fa-fax"
groups="fusion_faxes.group_fax_user"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Dashboard Form View -->
<record id="view_fusion_fax_dashboard_form" model="ir.ui.view">
<field name="name">fusion.fax.dashboard.form</field>
<field name="model">fusion.fax.dashboard</field>
<field name="arch" type="xml">
<form string="Fax Dashboard" create="0" delete="0" edit="0">
<sheet>
<field name="name" invisible="1"/>
<!-- Title -->
<div class="mb-4">
<span class="text-muted">Overview of fax activity and quick actions</span>
</div>
<!-- KPI Stat Cards -->
<div class="d-flex flex-nowrap gap-3 mb-4">
<!-- Total Faxes -->
<div class="flex-fill">
<button name="action_open_all" type="object"
class="btn btn-primary w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="total_count"/>
</div>
<div style="font-size: 0.85rem;">Total Faxes</div>
</div>
</button>
</div>
<!-- Sent -->
<div class="flex-fill">
<button name="action_open_sent" type="object"
class="btn btn-success w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="sent_count"/>
</div>
<div style="font-size: 0.85rem;">Sent</div>
</div>
</button>
</div>
<!-- Received -->
<div class="flex-fill">
<button name="action_open_received" type="object"
class="btn btn-info w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="received_count"/>
</div>
<div style="font-size: 0.85rem;">Received</div>
</div>
</button>
</div>
<!-- Failed -->
<div class="flex-fill" invisible="failed_count == 0">
<button name="action_open_failed" type="object"
class="btn btn-danger w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="failed_count"/>
</div>
<div style="font-size: 0.85rem;">Failed</div>
</div>
</button>
</div>
<!-- Draft -->
<div class="flex-fill" invisible="draft_count == 0">
<button name="action_open_draft" type="object"
class="btn btn-warning w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="draft_count"/>
</div>
<div style="font-size: 0.85rem;">Draft</div>
</div>
</button>
</div>
</div>
<!-- Quick Action Buttons -->
<div class="d-flex gap-2 mb-4">
<button name="action_send_fax" type="object"
class="btn btn-primary"
icon="fa-fax">
Send New Fax
</button>
<button name="action_open_all" type="object"
class="btn btn-secondary"
icon="fa-list">
View All Faxes
</button>
<button name="action_open_received" type="object"
class="btn btn-secondary"
icon="fa-download">
View Received
</button>
<button name="action_open_failed" type="object"
class="btn btn-secondary"
icon="fa-exclamation-triangle"
invisible="failed_count == 0">
View Failed
</button>
</div>
<!-- Recent Fax History -->
<separator string="Recent Fax History"/>
<field name="recent_fax_ids" nolabel="1" readonly="1">
<list decoration-danger="state == 'failed'"
decoration-success="state in ('sent', 'received')"
decoration-info="state == 'sending'">
<field name="name"/>
<field name="direction" widget="badge"
decoration-info="direction == 'outbound'"
decoration-success="direction == 'inbound'"/>
<field name="display_date" string="Date"/>
<field name="partner_id"/>
<field name="display_number" string="Fax Number"/>
<field name="page_count"/>
<field name="state" widget="badge"
decoration-success="state in ('sent', 'received')"
decoration-danger="state == 'failed'"
decoration-info="state == 'sending'"
decoration-warning="state == 'draft'"/>
<field name="sent_by_id" optional="show"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<!-- Dashboard Action -->
<record id="action_fusion_fax_dashboard" model="ir.actions.act_window">
<field name="name">Dashboard</field>
<field name="res_model">fusion.fax.dashboard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_fusion_fax_dashboard_form"/>
<field name="target">current</field>
</record>
<!-- Dashboard Menu (first item under Faxes root) -->
<menuitem id="menu_fusion_fax_dashboard"
name="Dashboard"
parent="menu_fusion_faxes_root"
action="action_fusion_fax_dashboard"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,259 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Fax Form View -->
<record id="view_fusion_fax_form" model="ir.ui.view">
<field name="name">fusion.fax.form</field>
<field name="model">fusion.fax</field>
<field name="arch" type="xml">
<form string="Fax">
<header>
<!-- Outbound buttons only -->
<button name="action_send" string="Send Fax"
type="object" class="btn-primary"
icon="fa-fax"
invisible="state != 'draft' or direction == 'inbound'"/>
<button name="action_resend" string="Resend Fax"
type="object" class="btn-primary"
icon="fa-repeat"
invisible="state != 'sent' or direction == 'inbound'"
confirm="This will resend the fax with all attachments. Continue?"/>
<button name="action_retry" string="Retry"
type="object" class="btn-primary"
icon="fa-refresh"
invisible="state != 'failed' or direction == 'inbound'"/>
<button name="action_reset_to_draft" string="Reset to Draft"
type="object"
invisible="state != 'failed' or direction == 'inbound'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,sending,sent,received"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_open_sale_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not sale_order_id">
<field name="sale_order_id" widget="statinfo" string="Sale Order"/>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Direction badge -->
<div class="mb-3">
<field name="direction" widget="badge"
decoration-info="direction == 'outbound'"
decoration-success="direction == 'inbound'"
readonly="1"/>
</div>
<!-- Outbound fields -->
<group invisible="direction == 'inbound'">
<group>
<field name="partner_id"/>
<field name="fax_number"/>
<field name="sent_by_id"/>
</group>
<group>
<field name="sent_date"/>
<field name="page_count"/>
<field name="ringcentral_message_id"/>
</group>
</group>
<!-- Inbound fields -->
<group invisible="direction == 'outbound'">
<group>
<field name="sender_number"/>
<field name="partner_id" string="Matched Contact"/>
<field name="sale_order_id" string="Link to Sale Order"
options="{'no_create': True}"/>
</group>
<group>
<field name="received_date"/>
<field name="page_count"/>
<field name="rc_read_status"/>
<field name="ringcentral_message_id"/>
</group>
</group>
<group invisible="direction == 'inbound'">
<field name="cover_page_text" placeholder="Optional cover page text..."/>
</group>
<!-- Ordered Documents with preview -->
<separator string="Documents"/>
<field name="document_ids" nolabel="1">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="attachment_id"/>
<field name="file_name" readonly="1"/>
<field name="mimetype" readonly="1"/>
<button name="action_preview" type="object"
string="Preview" icon="fa-eye"
class="btn-link"/>
</list>
</field>
<group string="Linked Records"
invisible="direction == 'inbound'">
<group>
<field name="sale_order_id"/>
</group>
<group>
<field name="account_move_id"/>
</group>
</group>
<group string="Error Details" invisible="state != 'failed'">
<field name="error_message" readonly="1" nolabel="1"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Fax List View -->
<record id="view_fusion_fax_list" model="ir.ui.view">
<field name="name">fusion.fax.list</field>
<field name="model">fusion.fax</field>
<field name="arch" type="xml">
<list string="Faxes" decoration-danger="state == 'failed'"
decoration-success="state in ('sent', 'received')"
decoration-info="state == 'sending'">
<field name="name"/>
<field name="direction" widget="badge"
decoration-info="direction == 'outbound'"
decoration-success="direction == 'inbound'"/>
<field name="display_date" string="Date"/>
<field name="partner_id"/>
<field name="display_number" string="Fax Number"/>
<field name="page_count"/>
<field name="state" widget="badge"
decoration-success="state in ('sent', 'received')"
decoration-danger="state == 'failed'"
decoration-info="state == 'sending'"
decoration-warning="state == 'draft'"/>
<field name="sent_by_id" optional="show"/>
<field name="sale_order_id" optional="hide"/>
<field name="account_move_id" optional="hide"/>
</list>
</field>
</record>
<!-- Fax Search View -->
<record id="view_fusion_fax_search" model="ir.ui.view">
<field name="name">fusion.fax.search</field>
<field name="model">fusion.fax</field>
<field name="arch" type="xml">
<search string="Search Faxes">
<field name="name"/>
<field name="partner_id"/>
<field name="fax_number"/>
<field name="sender_number"/>
<field name="sale_order_id"/>
<field name="account_move_id"/>
<separator/>
<filter string="Outbound" name="filter_outbound"
domain="[('direction', '=', 'outbound')]"/>
<filter string="Inbound" name="filter_inbound"
domain="[('direction', '=', 'inbound')]"/>
<separator/>
<filter string="Draft" name="filter_draft"
domain="[('state', '=', 'draft')]"/>
<filter string="Sent" name="filter_sent"
domain="[('state', '=', 'sent')]"/>
<filter string="Received" name="filter_received"
domain="[('state', '=', 'received')]"/>
<filter string="Failed" name="filter_failed"
domain="[('state', '=', 'failed')]"/>
<separator/>
<filter string="My Faxes" name="filter_my_faxes"
domain="[('sent_by_id', '=', uid)]"/>
<separator/>
<filter string="Direction" name="group_direction"
context="{'group_by': 'direction'}"/>
<filter string="Status" name="group_state"
context="{'group_by': 'state'}"/>
<filter string="Recipient" name="group_partner"
context="{'group_by': 'partner_id'}"/>
<filter string="Sent Date" name="group_date"
context="{'group_by': 'sent_date:month'}"/>
<filter string="Sent By" name="group_user"
context="{'group_by': 'sent_by_id'}"/>
</search>
</field>
</record>
<!-- Menu and Actions -->
<record id="action_fusion_fax" model="ir.actions.act_window">
<field name="name">All Faxes</field>
<field name="res_model">fusion.fax</field>
<field name="path">faxes</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_fax_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No faxes yet
</p>
<p>
Send faxes from Sale Orders or Invoices using the "Send Fax" button,
or create one manually from here.
</p>
</field>
</record>
<!-- Received Faxes Action -->
<record id="action_fusion_fax_received" model="ir.actions.act_window">
<field name="name">Received Faxes</field>
<field name="res_model">fusion.fax</field>
<field name="view_mode">list,form</field>
<field name="domain">[('direction', '=', 'inbound')]</field>
<field name="search_view_id" ref="view_fusion_fax_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No received faxes yet
</p>
<p>
Incoming faxes are automatically synced from RingCentral every 5 minutes.
</p>
</field>
</record>
<!-- Sent Faxes Action -->
<record id="action_fusion_fax_sent" model="ir.actions.act_window">
<field name="name">Sent Faxes</field>
<field name="res_model">fusion.fax</field>
<field name="view_mode">list,form</field>
<field name="domain">[('direction', '=', 'outbound')]</field>
<field name="search_view_id" ref="view_fusion_fax_search"/>
</record>
<!-- Top-level Faxes menu -->
<menuitem id="menu_fusion_faxes_root"
name="Faxes"
web_icon="fusion_faxes,static/description/icon.png"
sequence="45"/>
<menuitem id="menu_fusion_fax_list"
name="All Faxes"
parent="menu_fusion_faxes_root"
action="action_fusion_fax"
sequence="10"/>
<menuitem id="menu_fusion_fax_received"
name="Received Faxes"
parent="menu_fusion_faxes_root"
action="action_fusion_fax_received"
sequence="15"/>
<menuitem id="menu_fusion_fax_sent"
name="Sent Faxes"
parent="menu_fusion_faxes_root"
action="action_fusion_fax_sent"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_fusion_faxes" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.fusion_faxes</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Faxes" string="Fusion Faxes" name="fusion_faxes"
groups="fusion_faxes.group_fax_manager">
<h2>RingCentral Configuration</h2>
<div class="row mt-4 o_settings_container">
<!-- Enable toggle -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="ff_ringcentral_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="ff_ringcentral_enabled"/>
<div class="text-muted">
Enable sending faxes via RingCentral API.
</div>
</div>
</div>
<!-- Server URL -->
<div class="col-12 col-lg-6 o_setting_box"
invisible="not ff_ringcentral_enabled">
<div class="o_setting_right_pane">
<span class="o_form_label">Server URL</span>
<div class="text-muted">
RingCentral API server URL. Use https://platform.devtest.ringcentral.com for sandbox.
</div>
<div class="mt-2">
<field name="ff_ringcentral_server_url"
placeholder="https://platform.ringcentral.com"/>
</div>
</div>
</div>
<!-- Client ID -->
<div class="col-12 col-lg-6 o_setting_box"
invisible="not ff_ringcentral_enabled">
<div class="o_setting_right_pane">
<span class="o_form_label">Client ID</span>
<div class="text-muted">
From your RingCentral Developer Console app.
</div>
<div class="mt-2">
<field name="ff_ringcentral_client_id"
placeholder="Enter Client ID..."/>
</div>
</div>
</div>
<!-- Client Secret -->
<div class="col-12 col-lg-6 o_setting_box"
invisible="not ff_ringcentral_enabled">
<div class="o_setting_right_pane">
<span class="o_form_label">Client Secret</span>
<div class="text-muted">
From your RingCentral Developer Console app.
</div>
<div class="mt-2">
<field name="ff_ringcentral_client_secret"
password="True"
placeholder="Enter Client Secret..."/>
</div>
</div>
</div>
<!-- JWT Token -->
<div class="col-12 col-lg-6 o_setting_box"
invisible="not ff_ringcentral_enabled">
<div class="o_setting_right_pane">
<span class="o_form_label">JWT Token</span>
<div class="text-muted">
Generated from RingCentral Developer Console > Credentials > JWT.
</div>
<div class="mt-2">
<field name="ff_ringcentral_jwt_token"
password="True"
placeholder="Enter JWT Token..."/>
</div>
</div>
</div>
<!-- Test Connection button -->
<div class="col-12 col-lg-6 o_setting_box"
invisible="not ff_ringcentral_enabled">
<div class="o_setting_right_pane">
<span class="o_form_label">Test Connection</span>
<div class="text-muted">
Verify your RingCentral credentials are working.
</div>
<div class="mt-2">
<button name="action_test_ringcentral_connection"
string="Test Connection"
type="object"
class="btn-primary"
icon="fa-plug"/>
</div>
</div>
</div>
</div>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add fax number field + fax history tab to contact form -->
<record id="view_partner_form_inherit_fusion_faxes" model="ir.ui.view">
<field name="name">res.partner.form.inherit.fusion_faxes</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<!-- Add fax number after phone field -->
<xpath expr="//field[@name='phone']" position="after">
<field name="x_ff_fax_number" placeholder="Fax number..."/>
</xpath>
<!-- Add smart button for fax count -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_faxes" type="object"
class="oe_stat_button" icon="fa-fax"
invisible="x_ff_fax_count == 0">
<field name="x_ff_fax_count" widget="statinfo" string="Faxes"/>
</button>
</xpath>
<!-- Add Fax History tab -->
<xpath expr="//page[@name='internal_notes']" position="after">
<page string="Fax History" name="fax_history"
invisible="x_ff_fax_count == 0">
<field name="x_ff_fax_ids" readonly="1">
<list>
<field name="name"/>
<field name="sent_date"/>
<field name="fax_number"/>
<field name="page_count"/>
<field name="state" widget="badge"
decoration-success="state == 'sent'"
decoration-danger="state == 'failed'"/>
<field name="sent_by_id"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Send Fax button + smart button to sale order form -->
<record id="view_sale_order_form_inherit_fusion_faxes" model="ir.ui.view">
<field name="name">sale.order.form.inherit.fusion_faxes</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- Smart button for fax count -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_faxes" type="object"
class="oe_stat_button" icon="fa-fax"
invisible="x_ff_fax_count == 0">
<field name="x_ff_fax_count" widget="statinfo" string="Faxes"/>
</button>
</xpath>
<!-- Send Fax header button -->
<xpath expr="//header" position="inside">
<button name="action_send_fax" string="Send Fax"
type="object" class="btn-secondary"
icon="fa-fax"
groups="fusion_faxes.group_fax_user"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import send_fax_wizard
from . import send_fax_wizard_line

View File

@@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class SendFaxWizard(models.TransientModel):
_name = 'fusion_faxes.send.fax.wizard'
_description = 'Send Fax Wizard'
partner_id = fields.Many2one(
'res.partner',
string='Recipient',
required=True,
)
fax_number = fields.Char(
string='Fax Number',
required=True,
)
cover_page_text = fields.Text(
string='Cover Page Text',
)
document_line_ids = fields.One2many(
'fusion_faxes.send.fax.wizard.line',
'wizard_id',
string='Documents',
)
generate_pdf = fields.Boolean(
string='Attach PDF of Source Document',
default=True,
help='Automatically generate and attach a PDF of the sale order or invoice.',
)
# Context links
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
account_move_id = fields.Many2one('account.move', string='Invoice')
@api.onchange('partner_id')
def _onchange_partner_id(self):
if self.partner_id and self.partner_id.x_ff_fax_number:
self.fax_number = self.partner_id.x_ff_fax_number
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
context = self.env.context
# Auto-fill from sale order context
if context.get('active_model') == 'sale.order' and context.get('active_id'):
order = self.env['sale.order'].browse(context['active_id'])
res['sale_order_id'] = order.id
res['partner_id'] = order.partner_id.id
if order.partner_id.x_ff_fax_number:
res['fax_number'] = order.partner_id.x_ff_fax_number
# Auto-fill from invoice context
elif context.get('active_model') == 'account.move' and context.get('active_id'):
move = self.env['account.move'].browse(context['active_id'])
res['account_move_id'] = move.id
res['partner_id'] = move.partner_id.id
if move.partner_id.x_ff_fax_number:
res['fax_number'] = move.partner_id.x_ff_fax_number
# Pre-attach documents when forwarding a fax
forward_ids = context.get('forward_attachment_ids')
if forward_ids:
lines = []
for seq, att_id in enumerate(forward_ids, start=1):
lines.append((0, 0, {
'sequence': seq * 10,
'attachment_id': att_id,
'file_name': self.env['ir.attachment'].browse(att_id).name,
}))
res['document_line_ids'] = lines
res['generate_pdf'] = False
return res
def _generate_source_pdf(self):
"""Generate a PDF of the linked sale order or invoice."""
self.ensure_one()
if self.sale_order_id:
report = self.env.ref('sale.action_report_saleorder')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.sale_order_id.id])
filename = f'{self.sale_order_id.name}.pdf'
return filename, pdf_content
elif self.account_move_id:
report = self.env.ref('account.account_invoices')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.account_move_id.id])
filename = f'{self.account_move_id.name}.pdf'
return filename, pdf_content
return None, None
def action_send(self):
"""Create a fusion.fax record and send it."""
self.ensure_one()
if not self.fax_number:
raise UserError(_('Please enter a fax number.'))
# Collect attachment IDs from ordered wizard lines
ordered_attachment_ids = list(
self.document_line_ids.sorted('sequence').mapped('attachment_id').ids
)
# Generate PDF of source document if requested
if self.generate_pdf:
filename, pdf_content = self._generate_source_pdf()
if pdf_content:
attachment = self.env['ir.attachment'].create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'mimetype': 'application/pdf',
'res_model': 'fusion.fax',
})
# Generated PDF goes first (sequence 1)
ordered_attachment_ids.insert(0, attachment.id)
if not ordered_attachment_ids:
raise UserError(_('Please attach at least one document or enable PDF generation.'))
# Build document lines preserving the wizard ordering
document_lines = []
for seq, att_id in enumerate(ordered_attachment_ids, start=1):
document_lines.append((0, 0, {
'sequence': seq * 10,
'attachment_id': att_id,
}))
# Create the fax record
fax = self.env['fusion.fax'].create({
'partner_id': self.partner_id.id,
'fax_number': self.fax_number,
'cover_page_text': self.cover_page_text,
'document_ids': document_lines,
'sale_order_id': self.sale_order_id.id if self.sale_order_id else False,
'account_move_id': self.account_move_id.id if self.account_move_id else False,
'sent_by_id': self.env.user.id,
})
# Send immediately
fax._send_fax()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Fax Sent'),
'message': _('Fax %s sent successfully to %s.') % (fax.name, self.fax_number),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}

View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class SendFaxWizardLine(models.TransientModel):
_name = 'fusion_faxes.send.fax.wizard.line'
_description = 'Send Fax Wizard Document Line'
_order = 'sequence, id'
wizard_id = fields.Many2one(
'fusion_faxes.send.fax.wizard',
string='Wizard',
required=True,
ondelete='cascade',
)
sequence = fields.Integer(
string='Order',
default=10,
)
file_upload = fields.Binary(
string='Upload File',
)
file_name = fields.Char(
string='File Name',
)
attachment_id = fields.Many2one(
'ir.attachment',
string='Attachment',
readonly=True,
)
@api.onchange('file_upload')
def _onchange_file_upload(self):
"""Create an ir.attachment when a file is uploaded."""
if self.file_upload and self.file_name:
attachment = self.env['ir.attachment'].create({
'name': self.file_name,
'type': 'binary',
'datas': self.file_upload,
'res_model': 'fusion.fax',
})
self.attachment_id = attachment.id

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Send Fax Wizard Form -->
<record id="view_send_fax_wizard_form" model="ir.ui.view">
<field name="name">fusion_faxes.send.fax.wizard.form</field>
<field name="model">fusion_faxes.send.fax.wizard</field>
<field name="arch" type="xml">
<form string="Send Fax">
<group>
<group>
<field name="partner_id"/>
<field name="fax_number"/>
</group>
<group>
<field name="generate_pdf"/>
<field name="sale_order_id" readonly="1"
invisible="not sale_order_id"/>
<field name="account_move_id" readonly="1"
invisible="not account_move_id"/>
</group>
</group>
<group>
<field name="cover_page_text"
placeholder="Optional cover page message..."/>
</group>
<!-- Ordered document list with drag handle and file upload -->
<separator string="Documents (drag to reorder)"/>
<field name="document_line_ids" nolabel="1">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="file_upload" filename="file_name"/>
<field name="file_name"/>
<field name="attachment_id" column_invisible="1"/>
<widget name="preview_button"/>
</list>
</field>
<footer>
<button name="action_send" string="Send Fax"
type="object" class="btn-primary"
icon="fa-fax"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Wizard action (for standalone use from menu) -->
<record id="action_send_fax_wizard" model="ir.actions.act_window">
<field name="name">Send Fax</field>
<field name="res_model">fusion_faxes.send.fax.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<menuitem id="menu_send_fax"
name="Send Fax"
parent="menu_fusion_faxes_root"
action="action_send_fax_wizard"
sequence="5"/>
</odoo>