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

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import rc_config
from . import rc_call_history
from . import rc_call_dashboard
from . import res_partner
from . import fusion_fax
from . import rc_voicemail
from . import res_config_settings

View File

@@ -0,0 +1,188 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import re
import logging
from odoo import api, fields, models, _
_logger = logging.getLogger(__name__)
class FusionFaxRC(models.Model):
"""Extend fusion.fax with contact matching, smart buttons,
forward fax, and send-new-fax actions."""
_inherit = 'fusion.fax'
invoice_count = fields.Integer(
string='Invoices', compute='_compute_partner_counts',
)
sale_order_count = fields.Integer(
string='Sales Orders', compute='_compute_partner_counts',
)
@api.depends('partner_id')
def _compute_partner_counts(self):
for rec in self:
if rec.partner_id:
partner = rec.partner_id.commercial_partner_id or rec.partner_id
partner_ids = (partner | partner.child_ids).ids
rec.sale_order_count = self.env['sale.order'].search_count(
[('partner_id', 'in', partner_ids)],
)
rec.invoice_count = self.env['account.move'].search_count(
[('partner_id', 'in', partner_ids),
('move_type', 'in', ('out_invoice', 'out_refund'))],
)
else:
rec.sale_order_count = 0
rec.invoice_count = 0
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('partner_id'):
partner = self._match_fax_partner(
vals.get('fax_number', ''),
vals.get('sender_number', ''),
vals.get('direction', ''),
)
if partner:
vals['partner_id'] = partner.id
return super().create(vals_list)
@api.model
def _match_fax_partner(self, fax_number, sender_number, direction):
"""Match a fax to a contact by fax number, phone, or mobile."""
search_number = sender_number if direction == 'inbound' else fax_number
if not search_number:
return False
cleaned = self._normalize_fax_phone(search_number)
if not cleaned or len(cleaned) < 7:
return False
Partner = self.env['res.partner']
if 'x_ff_fax_number' in Partner._fields:
partners = Partner.search(
[('x_ff_fax_number', '!=', False)],
order='customer_rank desc, write_date desc',
)
for p in partners:
if self._normalize_fax_phone(p.x_ff_fax_number) == cleaned:
return p
phone_fields = [f for f in ('phone', 'mobile') if f in Partner._fields]
if phone_fields:
domain = ['|'] * (len(phone_fields) - 1)
for f in phone_fields:
domain.append((f, '!=', False))
partners = Partner.search(domain, order='customer_rank desc, write_date desc')
for p in partners:
for f in phone_fields:
val = p[f]
if val and self._normalize_fax_phone(val) == cleaned:
return p
return False
@staticmethod
def _normalize_fax_phone(number):
return re.sub(r'\D', '', number or '')[-10:] if number else ''
# ──────────────────────────────────────────────────────────
# Smart button actions
# ──────────────────────────────────────────────────────────
def action_view_contact(self):
self.ensure_one()
if not self.partner_id:
return
return {
'type': 'ir.actions.act_window',
'res_model': 'res.partner',
'res_id': self.partner_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_sale_orders(self):
self.ensure_one()
if not self.partner_id:
return
partner = self.partner_id.commercial_partner_id or self.partner_id
partner_ids = (partner | partner.child_ids).ids
return {
'type': 'ir.actions.act_window',
'name': _('Sales Orders - %s') % self.partner_id.name,
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('partner_id', 'in', partner_ids)],
'target': 'current',
}
def action_view_invoices(self):
self.ensure_one()
if not self.partner_id:
return
partner = self.partner_id.commercial_partner_id or self.partner_id
partner_ids = (partner | partner.child_ids).ids
return {
'type': 'ir.actions.act_window',
'name': _('Invoices - %s') % self.partner_id.name,
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('partner_id', 'in', partner_ids),
('move_type', 'in', ('out_invoice', 'out_refund'))],
'target': 'current',
}
# ──────────────────────────────────────────────────────────
# Forward Fax (received fax -> send to someone else)
# ──────────────────────────────────────────────────────────
def action_forward_fax(self):
"""Open Send Fax wizard with this fax's documents pre-attached."""
self.ensure_one()
attachment_ids = self.document_ids.sorted('sequence').mapped('attachment_id').ids
ctx = {
'default_document_source_fax_id': self.id,
'forward_attachment_ids': attachment_ids,
}
return {
'type': 'ir.actions.act_window',
'name': _('Forward Fax - %s') % self.name,
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': ctx,
}
# ──────────────────────────────────────────────────────────
# Send New Fax (pre-fill contact from current fax)
# ──────────────────────────────────────────────────────────
def action_send_new_fax(self):
"""Open Send Fax wizard pre-filled with this fax's contact info."""
self.ensure_one()
ctx = {}
if self.partner_id:
ctx['default_partner_id'] = self.partner_id.id
fax_num = ''
if self.direction == 'inbound':
fax_num = self.sender_number or self.fax_number
else:
fax_num = self.fax_number
if fax_num:
ctx['default_fax_number'] = fax_num
return {
'type': 'ir.actions.act_window',
'name': _('Send New Fax'),
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': ctx,
}

View File

@@ -0,0 +1,201 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import datetime, timedelta
from odoo import api, fields, models
class RcCallDashboard(models.TransientModel):
_name = 'rc.call.dashboard'
_description = 'RingCentral Call Dashboard'
_rec_name = 'name'
name = fields.Char(default='Call Dashboard', readonly=True)
total_count = fields.Integer(compute='_compute_stats')
inbound_count = fields.Integer(compute='_compute_stats')
outbound_count = fields.Integer(compute='_compute_stats')
answered_count = fields.Integer(compute='_compute_stats')
missed_count = fields.Integer(compute='_compute_stats')
avg_duration = fields.Float(compute='_compute_stats', string='Avg Duration (min)')
success_rate = fields.Float(compute='_compute_stats', string='Success Rate (%)')
today_count = fields.Integer(compute='_compute_time_stats', string='Today')
week_count = fields.Integer(compute='_compute_time_stats', string='This Week')
month_count = fields.Integer(compute='_compute_time_stats', string='This Month')
voicemail_count = fields.Integer(compute='_compute_voicemail_stats')
unread_voicemail_count = fields.Integer(compute='_compute_voicemail_stats')
recent_call_ids = fields.Many2many(
'rc.call.history',
compute='_compute_recent_calls',
)
recent_voicemail_ids = fields.Many2many(
'rc.voicemail', 'rc_dashboard_recent_vm_rel',
compute='_compute_voicemail_lists',
)
older_voicemail_ids = fields.Many2many(
'rc.voicemail', 'rc_dashboard_older_vm_rel',
compute='_compute_voicemail_lists',
)
@api.depends_context('uid')
def _compute_stats(self):
Call = self.env['rc.call.history']
for rec in self:
all_calls = Call.search([])
rec.total_count = len(all_calls)
rec.inbound_count = Call.search_count([('direction', '=', 'inbound')])
rec.outbound_count = Call.search_count([('direction', '=', 'outbound')])
rec.answered_count = Call.search_count([('status', '=', 'answered')])
rec.missed_count = Call.search_count([('status', 'in', ('missed', 'no_answer'))])
durations = all_calls.mapped('duration')
rec.avg_duration = round(sum(durations) / len(durations) / 60, 1) if durations else 0.0
rec.success_rate = round(
(rec.answered_count / rec.total_count * 100) if rec.total_count else 0.0, 1
)
@api.depends_context('uid')
def _compute_time_stats(self):
Call = self.env['rc.call.history']
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=today_start.weekday())
month_start = today_start.replace(day=1)
for rec in self:
rec.today_count = Call.search_count([('start_time', '>=', today_start)])
rec.week_count = Call.search_count([('start_time', '>=', week_start)])
rec.month_count = Call.search_count([('start_time', '>=', month_start)])
@api.depends_context('uid')
def _compute_recent_calls(self):
for rec in self:
rec.recent_call_ids = self.env['rc.call.history'].search([], limit=20)
@api.depends_context('uid')
def _compute_voicemail_lists(self):
VM = self.env['rc.voicemail']
one_week_ago = datetime.utcnow() - timedelta(days=7)
for rec in self:
rec.recent_voicemail_ids = VM.search([
('received_date', '>=', one_week_ago),
], limit=50)
rec.older_voicemail_ids = VM.search([
('received_date', '<', one_week_ago),
], limit=100)
@api.depends_context('uid')
def _compute_voicemail_stats(self):
VM = self.env['rc.voicemail']
for rec in self:
rec.voicemail_count = VM.search_count([])
rec.unread_voicemail_count = VM.search_count(
[('read_status', '=', 'Unread')],
)
# ──────────────────────────────────────────────────────────
# Navigation actions
# ──────────────────────────────────────────────────────────
def action_open_all(self):
return {
'type': 'ir.actions.act_window',
'name': 'Call History',
'res_model': 'rc.call.history',
'view_mode': 'list,form',
}
def action_open_inbound(self):
return {
'type': 'ir.actions.act_window',
'name': 'Inbound Calls',
'res_model': 'rc.call.history',
'view_mode': 'list,form',
'domain': [('direction', '=', 'inbound')],
}
def action_open_outbound(self):
return {
'type': 'ir.actions.act_window',
'name': 'Outbound Calls',
'res_model': 'rc.call.history',
'view_mode': 'list,form',
'domain': [('direction', '=', 'outbound')],
}
def action_open_missed(self):
return {
'type': 'ir.actions.act_window',
'name': 'Missed Calls',
'res_model': 'rc.call.history',
'view_mode': 'list,form',
'domain': [('status', 'in', ('missed', 'no_answer'))],
}
def action_open_graph(self):
return {
'type': 'ir.actions.act_window',
'name': 'Call Analytics',
'res_model': 'rc.call.history',
'view_mode': 'graph,pivot,list',
}
def action_open_voicemails(self):
return {
'type': 'ir.actions.act_window',
'name': 'Voicemails',
'res_model': 'rc.voicemail',
'view_mode': 'list,form',
}
def action_open_unread_voicemails(self):
return {
'type': 'ir.actions.act_window',
'name': 'Voicemails',
'res_model': 'rc.call.dashboard',
'view_mode': 'form',
'view_id': self.env.ref(
'fusion_ringcentral.view_rc_voicemail_dashboard_form',
).id,
'target': 'current',
}
# ──────────────────────────────────────────────────────────
# Quick action buttons (Call / Message / Fax)
# ──────────────────────────────────────────────────────────
def action_quick_call(self):
"""Open the RingCentral dialer via the Embeddable widget."""
return {
'type': 'ir.actions.act_window_close',
'infos': {
'rc_action': 'call',
'rc_phone_number': '',
},
}
def action_quick_sms(self):
"""Open the RingCentral SMS compose via the Embeddable widget."""
return {
'type': 'ir.actions.act_window_close',
'infos': {
'rc_action': 'sms',
'rc_phone_number': '',
},
}
def action_quick_fax(self):
"""Open the Send Fax wizard."""
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,488 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import re
from datetime import datetime, timedelta
from odoo import api, fields, models, _
_logger = logging.getLogger(__name__)
class RcCallHistory(models.Model):
_name = 'rc.call.history'
_description = 'RingCentral Call History'
_inherit = ['mail.thread']
_order = 'start_time desc'
_rec_name = 'name'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
)
rc_session_id = fields.Char(string='RC Session ID', index=True, readonly=True)
direction = fields.Selection([
('inbound', 'Inbound'),
('outbound', 'Outbound'),
], string='Direction', required=True, tracking=True)
from_number = fields.Char(string='From Number')
to_number = fields.Char(string='To Number')
start_time = fields.Datetime(string='Start Time', index=True)
duration = fields.Integer(string='Duration (sec)')
duration_display = fields.Char(string='Duration', compute='_compute_duration_display')
status = fields.Selection([
('answered', 'Answered'),
('no_answer', 'No Answer'),
('busy', 'Busy'),
('missed', 'Missed'),
('voicemail', 'Voicemail'),
('rejected', 'Rejected'),
('unknown', 'Unknown'),
], string='Status', default='unknown', tracking=True)
partner_id = fields.Many2one('res.partner', string='Contact', tracking=True)
user_id = fields.Many2one('res.users', string='Odoo User', default=lambda self: self.env.user)
sale_order_count = fields.Integer(string='Sales Orders', compute='_compute_partner_counts')
invoice_count = fields.Integer(string='Invoices', compute='_compute_partner_counts')
has_recording = fields.Boolean(string='Has Recording', default=False)
recording_url = fields.Char(string='Recording URL')
recording_content_uri = fields.Char(string='Recording Content URI')
has_transcript = fields.Boolean(string='Has Transcript', default=False)
transcript_text = fields.Text(string='Transcript')
sale_order_id = fields.Many2one('sale.order', string='Sale Order', ondelete='set null')
notes = fields.Text(string='Notes')
@api.depends('partner_id')
def _compute_partner_counts(self):
for rec in self:
if rec.partner_id:
partner = rec.partner_id.commercial_partner_id or rec.partner_id
partner_ids = (partner | partner.child_ids).ids
rec.sale_order_count = self.env['sale.order'].search_count(
[('partner_id', 'in', partner_ids)]
)
rec.invoice_count = self.env['account.move'].search_count(
[('partner_id', 'in', partner_ids),
('move_type', 'in', ('out_invoice', 'out_refund'))]
)
else:
rec.sale_order_count = 0
rec.invoice_count = 0
@api.depends('duration')
def _compute_duration_display(self):
for rec in self:
if rec.duration:
minutes, seconds = divmod(rec.duration, 60)
rec.duration_display = f'{minutes}m {seconds}s' if minutes else f'{seconds}s'
else:
rec.duration_display = '0s'
@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('rc.call.history') or _('New')
if not vals.get('partner_id'):
partner = self._match_partner(
vals.get('from_number', ''),
vals.get('to_number', ''),
vals.get('direction', ''),
)
if partner:
vals['partner_id'] = partner.id
return super().create(vals_list)
@api.model
def _match_partner(self, from_number, to_number, direction):
"""Auto-link a call to a partner by matching phone numbers.
For outbound calls, match the to_number (who we called).
For inbound calls, match the from_number (who called us).
Compares last 10 digits to handle +1, (905), etc.
"""
search_number = to_number if direction == 'outbound' else from_number
if not search_number:
return False
cleaned = self._normalize_phone(search_number)
if not cleaned or len(cleaned) < 7:
return False
Partner = self.env['res.partner']
phone_fields = [f for f in ('phone', 'mobile') if f in Partner._fields]
if not phone_fields:
return False
domain = ['|'] * (len(phone_fields) - 1)
for f in phone_fields:
domain.append((f, '!=', False))
partners = Partner.search(domain, order='customer_rank desc, write_date desc')
for p in partners:
for f in phone_fields:
val = p[f]
if val and self._normalize_phone(val) == cleaned:
return p
return False
@staticmethod
def _normalize_phone(number):
"""Strip a phone number to last 10 digits for comparison."""
return re.sub(r'\D', '', number or '')[-10:] if number else ''
# ──────────────────────────────────────────────────────────
# Smart button actions
# ──────────────────────────────────────────────────────────
def action_view_contact(self):
"""Open the linked contact."""
self.ensure_one()
if not self.partner_id:
return
return {
'type': 'ir.actions.act_window',
'res_model': 'res.partner',
'res_id': self.partner_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_sale_orders(self):
"""Open sale orders for the linked contact."""
self.ensure_one()
if not self.partner_id:
return
partner = self.partner_id.commercial_partner_id or self.partner_id
partner_ids = (partner | partner.child_ids).ids
return {
'type': 'ir.actions.act_window',
'name': _('Sales Orders - %s') % self.partner_id.name,
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('partner_id', 'in', partner_ids)],
'target': 'current',
}
def action_view_invoices(self):
"""Open invoices for the linked contact."""
self.ensure_one()
if not self.partner_id:
return
partner = self.partner_id.commercial_partner_id or self.partner_id
partner_ids = (partner | partner.child_ids).ids
return {
'type': 'ir.actions.act_window',
'name': _('Invoices - %s') % self.partner_id.name,
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('partner_id', 'in', partner_ids),
('move_type', 'in', ('out_invoice', 'out_refund'))],
'target': 'current',
}
def _get_contact_number(self):
"""Return the external phone number for this call."""
self.ensure_one()
if self.direction == 'outbound':
return self.to_number or self.from_number
return self.from_number or self.to_number
def action_make_call(self):
"""Trigger a call via the RingCentral Embeddable widget (handled by JS)."""
self.ensure_one()
return {
'type': 'ir.actions.act_window_close',
'infos': {
'rc_action': 'call',
'rc_phone_number': self._get_contact_number(),
},
}
def action_send_sms(self):
"""Open the RingCentral Embeddable SMS compose (handled by JS)."""
self.ensure_one()
return {
'type': 'ir.actions.act_window_close',
'infos': {
'rc_action': 'sms',
'rc_phone_number': self._get_contact_number(),
},
}
def action_send_fax(self):
"""Open the Send Fax wizard pre-filled with this call's contact."""
self.ensure_one()
ctx = {}
if self.partner_id:
ctx['default_partner_id'] = self.partner_id.id
fax_num = getattr(self.partner_id, 'x_ff_fax_number', '')
if fax_num:
ctx['default_fax_number'] = fax_num
return {
'type': 'ir.actions.act_window',
'name': _('Send Fax'),
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': ctx,
}
# ──────────────────────────────────────────────────────────
# Recording playback
# ──────────────────────────────────────────────────────────
def action_play_recording(self):
"""Open the recording proxy URL in a new window."""
self.ensure_one()
if not self.has_recording:
return
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
return {
'type': 'ir.actions.act_url',
'url': f'{base_url}/ringcentral/recording/{self.id}',
'target': 'new',
}
# ──────────────────────────────────────────────────────────
# Cron: sync call history from RingCentral
# ──────────────────────────────────────────────────────────
@api.model
def _cron_sync_call_history(self):
"""Incremental sync: only fetch calls since last successful sync.
Uses `fusion_rc.last_call_sync` to know where to start.
Only queries RingCentral for new records, skips everything
already imported. Runs every 15 min by default.
"""
config = self.env['rc.config']._get_active_config()
if not config:
return
ICP = self.env['ir.config_parameter'].sudo()
last_sync = ICP.get_param('fusion_rc.last_call_sync', '')
if not last_sync:
two_days_ago = datetime.utcnow() - timedelta(days=2)
last_sync = two_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')
total_imported = self._sync_calls_from_date(config, last_sync, now_str)
ICP.set_param('fusion_rc.last_call_sync', now_str)
if total_imported:
_logger.info(
"RC Incremental Sync: %d new calls imported (from %s).",
total_imported, last_sync[:16],
)
@api.model
def _cron_daily_catchup_sync(self):
"""Daily catchup: re-scan the past 3 days to catch anything missed.
Covers calls made from physical phones, mobile app, or any source
outside Odoo. Deduplicates by rc_session_id so no duplicates
are created. Runs once per day.
"""
config = self.env['rc.config']._get_active_config()
if not config:
return
three_days_ago = datetime.utcnow() - timedelta(days=3)
date_from = three_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')
total_imported = self._sync_calls_from_date(config, date_from, now_str)
_logger.info(
"RC Daily Catchup: %d new calls imported (past 3 days).",
total_imported,
)
def _sync_calls_from_date(self, config, date_from, date_to=None):
"""Fetch call records from RingCentral for a date range.
Skips records whose rc_session_id already exists in our DB
(checked via a pre-loaded set, not per-record SQL queries).
Returns the total number of newly imported records.
"""
total_imported = 0
total_skipped = 0
try:
params = {
'dateFrom': date_from,
'type': 'Voice',
'view': 'Detailed',
'perPage': 250,
}
if date_to:
params['dateTo'] = date_to
data = config._api_get(
'/restapi/v1.0/account/~/extension/~/call-log', params=params,
)
imported, skipped = self._process_call_page(data)
total_imported += imported
total_skipped += skipped
while data.get('navigation', {}).get('nextPage', {}).get('uri'):
next_uri = data['navigation']['nextPage']['uri']
data = config._api_get(next_uri)
imported, skipped = self._process_call_page(data)
total_imported += imported
total_skipped += skipped
except Exception:
_logger.exception("Fusion RingCentral: Error syncing call history.")
if total_skipped:
_logger.debug("RC Sync: skipped %d already-imported records.", total_skipped)
return total_imported
def _process_call_page(self, data):
"""Process a single page of call log records.
Uses a bulk session_id lookup (one SQL query per page)
instead of per-record queries.
Returns (imported_count, skipped_count).
"""
records = data.get('records', [])
if not records:
return 0, 0
page_session_ids = [
r.get('sessionId', '') for r in records if r.get('sessionId')
]
if page_session_ids:
existing = set(
r.rc_session_id
for r in self.search([('rc_session_id', 'in', page_session_ids)])
)
else:
existing = set()
imported = 0
skipped = 0
for rec in records:
session_id = rec.get('sessionId', '')
if not session_id:
continue
if session_id in existing:
skipped += 1
continue
self._import_call_record(rec)
existing.add(session_id)
imported += 1
return imported, skipped
def _import_call_record(self, rec):
"""Import a single call log record from the API."""
try:
direction_raw = rec.get('direction', '').lower()
direction = 'inbound' if direction_raw == 'inbound' else 'outbound'
from_info = rec.get('from', {})
to_info = rec.get('to', {})
from_number = from_info.get('phoneNumber', '') or from_info.get('name', '')
to_number = to_info.get('phoneNumber', '') or to_info.get('name', '')
start_time = False
raw_time = rec.get('startTime', '')
if raw_time:
try:
clean_time = raw_time.replace('Z', '+00:00')
start_time = datetime.fromisoformat(clean_time).strftime('%Y-%m-%d %H:%M:%S')
except (ValueError, AttributeError):
pass
result = rec.get('result', 'Unknown')
status_map = {
'Accepted': 'answered',
'Call connected': 'answered',
'Voicemail': 'voicemail',
'Missed': 'missed',
'No Answer': 'no_answer',
'Busy': 'busy',
'Rejected': 'rejected',
'Hang Up': 'answered',
'Reply': 'answered',
}
status = status_map.get(result, 'unknown')
recording = rec.get('recording', {})
has_recording = bool(recording)
recording_content_uri = recording.get('contentUri', '') if recording else ''
self.sudo().create({
'rc_session_id': rec.get('sessionId', ''),
'direction': direction,
'from_number': from_number,
'to_number': to_number,
'start_time': start_time,
'duration': rec.get('duration', 0),
'status': status,
'has_recording': has_recording,
'recording_content_uri': recording_content_uri,
})
except Exception:
_logger.exception("Failed to import call record: %s", rec.get('sessionId', ''))
# ──────────────────────────────────────────────────────────
# Cron: fetch transcripts
# ──────────────────────────────────────────────────────────
@api.model
def _cron_fetch_transcripts(self):
"""Fetch AI transcripts for calls that have recordings but no transcript."""
config = self.env['rc.config']._get_active_config()
if not config:
return
calls = self.search([
('has_recording', '=', True),
('has_transcript', '=', False),
('recording_content_uri', '!=', False),
], limit=20)
for call in calls:
try:
content_uri = call.recording_content_uri
if not content_uri:
continue
media_url = f'{content_uri}?access_token={config.access_token}'
data = config._api_post(
'/ai/speech-to-text/v1/async?webhook=false',
data={
'contentUri': media_url,
'source': 'CallRecording',
},
)
transcript = ''
segments = data.get('utterances', [])
for seg in segments:
speaker = seg.get('speakerName', 'Speaker')
text = seg.get('text', '')
transcript += f'{speaker}: {text}\n'
if transcript:
call.write({
'has_transcript': True,
'transcript_text': transcript.strip(),
})
except Exception:
_logger.debug("Transcript not available yet for call %s", call.name)

View File

@@ -0,0 +1,609 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import json
import logging
import socket
import time
from datetime import datetime, timedelta
import requests
from odoo import api, fields, models, _
from odoo.exceptions import UserError
RC_RATE_LIMIT_DELAY = 10
RC_RATE_LIMIT_RETRY_WAIT = 65
RC_MAX_RETRIES = 5
_logger = logging.getLogger(__name__)
RC_EMBEDDABLE_REDIRECT = (
'https://apps.ringcentral.com/integration/ringcentral-embeddable/latest/redirect.html'
)
class RcConfig(models.Model):
_name = 'rc.config'
_description = 'RingCentral Configuration'
_rec_name = 'name'
name = fields.Char(string='Configuration Name', required=True, default='RingCentral')
client_id = fields.Char(string='Client ID', required=True, groups='fusion_ringcentral.group_rc_manager')
client_secret = fields.Char(string='Client Secret', required=True, groups='fusion_ringcentral.group_rc_manager')
server_url = fields.Char(
string='Server URL',
required=True,
default='https://platform.ringcentral.com',
)
access_token = fields.Char(string='Access Token', groups='base.group_system')
refresh_token = fields.Char(string='Refresh Token', groups='base.group_system')
token_expiry = fields.Datetime(string='Token Expires At', groups='base.group_system')
webhook_subscription_id = fields.Char(string='Webhook Subscription ID', readonly=True)
state = fields.Selection([
('draft', 'Not Connected'),
('connected', 'Connected'),
('error', 'Error'),
], string='Status', default='draft', required=True, tracking=True)
phone_widget_enabled = fields.Boolean(string='Enable Phone Widget', default=True)
proxy_url = fields.Char(string='HTTP Proxy URL', help='Optional proxy for enterprise networks.')
proxy_port = fields.Char(string='Proxy Port')
ssl_verify = fields.Boolean(string='Verify SSL Certificates', default=True)
extension_name = fields.Char(string='Connected As', readonly=True)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
# ──────────────────────────────────────────────────────────
# Connection test
# ──────────────────────────────────────────────────────────
def action_test_connection(self):
"""Test DNS + HTTPS connectivity to the RingCentral server."""
self.ensure_one()
results = []
# DNS check
hostname = self.server_url.replace('https://', '').replace('http://', '').rstrip('/')
try:
socket.getaddrinfo(hostname, 443)
results.append(f'DNS: {hostname} resolved OK')
except socket.gaierror:
return self._notify('Connection Failed', f'DNS resolution failed for {hostname}', 'danger')
# HTTPS check
try:
resp = requests.get(
f'{self.server_url}/restapi/v1.0',
timeout=10,
verify=self.ssl_verify,
proxies=self._get_proxies(),
)
results.append(f'HTTPS: Status {resp.status_code}')
except requests.RequestException as e:
return self._notify('Connection Failed', f'HTTPS error: {e}', 'danger')
return self._notify('Connection Successful', '\n'.join(results), 'success')
# ──────────────────────────────────────────────────────────
# OAuth flow
# ──────────────────────────────────────────────────────────
def action_oauth_connect(self):
"""Redirect user to RingCentral OAuth authorization page."""
self.ensure_one()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
redirect_uri = f'{base_url}/ringcentral/oauth'
state = json.dumps({'config_id': self.id})
auth_url = (
f'{self.server_url}/restapi/oauth/authorize'
f'?response_type=code'
f'&client_id={self.client_id}'
f'&redirect_uri={redirect_uri}'
f'&state={state}'
)
return {
'type': 'ir.actions.act_url',
'url': auth_url,
'target': 'self',
}
def action_disconnect(self):
"""Revoke tokens and disconnect."""
self.ensure_one()
if self.access_token:
try:
requests.post(
f'{self.server_url}/restapi/oauth/revoke',
data={'token': self.access_token},
auth=(self.client_id, self.client_secret),
timeout=10,
verify=self.ssl_verify,
proxies=self._get_proxies(),
)
except Exception:
_logger.exception("Error revoking RingCentral token")
self.write({
'access_token': False,
'refresh_token': False,
'token_expiry': False,
'webhook_subscription_id': False,
'state': 'draft',
'extension_name': False,
})
return self._notify('Disconnected', 'RingCentral has been disconnected.', 'warning')
def exchange_auth_code(self, code, redirect_uri):
"""Exchange authorization code for access/refresh tokens."""
self.ensure_one()
try:
resp = requests.post(
f'{self.server_url}/restapi/oauth/token',
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirect_uri,
},
auth=(self.client_id, self.client_secret),
timeout=15,
verify=self.ssl_verify,
proxies=self._get_proxies(),
)
resp.raise_for_status()
data = resp.json()
expires_in = data.get('expires_in', 3600)
self.write({
'access_token': data.get('access_token'),
'refresh_token': data.get('refresh_token'),
'token_expiry': datetime.utcnow() + timedelta(seconds=expires_in),
'state': 'connected',
})
self._fetch_extension_info()
self._create_webhook_subscription()
return True
except Exception:
_logger.exception("RingCentral OAuth token exchange failed")
self.write({'state': 'error'})
return False
def _refresh_token(self):
"""Refresh the access token using the refresh token."""
self.ensure_one()
if not self.refresh_token:
self.write({'state': 'error'})
return False
try:
resp = requests.post(
f'{self.server_url}/restapi/oauth/token',
data={
'grant_type': 'refresh_token',
'refresh_token': self.refresh_token,
},
auth=(self.client_id, self.client_secret),
timeout=15,
verify=self.ssl_verify,
proxies=self._get_proxies(),
)
resp.raise_for_status()
data = resp.json()
expires_in = data.get('expires_in', 3600)
self.write({
'access_token': data.get('access_token'),
'refresh_token': data.get('refresh_token', self.refresh_token),
'token_expiry': datetime.utcnow() + timedelta(seconds=expires_in),
'state': 'connected',
})
return True
except Exception:
_logger.exception("RingCentral token refresh failed")
self.write({'state': 'error'})
return False
def _ensure_token(self):
"""Ensure we have a valid access token, refreshing if needed."""
self.ensure_one()
if not self.access_token:
raise UserError(_('RingCentral is not connected. Please connect via OAuth first.'))
if self.token_expiry and self.token_expiry < fields.Datetime.now():
if not self._refresh_token():
raise UserError(_('Failed to refresh RingCentral token. Please reconnect.'))
def _get_headers(self):
"""Return authorization headers for API calls."""
self._ensure_token()
return {
'Authorization': f'Bearer {self.access_token}',
'Content-Type': 'application/json',
}
def _api_get(self, endpoint, params=None):
"""Make an authenticated GET request with rate-limit handling."""
return self._api_request('GET', endpoint, params=params)
def _api_post(self, endpoint, data=None):
"""Make an authenticated POST request with rate-limit handling."""
return self._api_request('POST', endpoint, json_data=data)
def _api_request(self, method, endpoint, params=None, json_data=None):
"""Central API request method with automatic rate-limit retry.
RingCentral Heavy group (Call Log): 10 req / 60 sec, 60 sec penalty.
We pace at 10 sec between calls (~6 req/min) and auto-retry on
429 or 401 (token can appear invalid during penalty window).
"""
self._ensure_token()
url = endpoint if endpoint.startswith('http') else f'{self.server_url}{endpoint}'
kwargs = {
'timeout': 30,
'verify': self.ssl_verify,
'proxies': self._get_proxies(),
}
if params:
kwargs['params'] = params
if json_data is not None:
kwargs['json'] = json_data
last_error = None
for attempt in range(1, RC_MAX_RETRIES + 1):
kwargs['headers'] = self._get_headers()
resp = requests.request(method, url, **kwargs)
if resp.status_code == 429:
retry_after = int(resp.headers.get('Retry-After', RC_RATE_LIMIT_RETRY_WAIT))
_logger.warning(
"RC rate limit 429. Waiting %d sec (attempt %d/%d)...",
retry_after, attempt, RC_MAX_RETRIES,
)
time.sleep(retry_after)
self._ensure_token()
continue
if resp.status_code == 401 and attempt < RC_MAX_RETRIES:
_logger.warning(
"RC 401 Unauthorized -- token may be stale from rate-limit penalty. "
"Refreshing token and waiting 60 sec (attempt %d/%d)...",
attempt, RC_MAX_RETRIES,
)
time.sleep(60)
try:
self._refresh_token()
except Exception:
pass
continue
if resp.status_code >= 400:
last_error = resp
_logger.error(
"RC API error %d on %s %s: %s",
resp.status_code, method, url, resp.text[:300],
)
resp.raise_for_status()
time.sleep(RC_RATE_LIMIT_DELAY)
return resp.json()
if last_error is not None:
last_error.raise_for_status()
raise UserError(_('RingCentral API request failed after %d attempts.') % RC_MAX_RETRIES)
# ──────────────────────────────────────────────────────────
# Extension info
# ──────────────────────────────────────────────────────────
def _fetch_extension_info(self):
"""Fetch the connected user's extension info."""
self.ensure_one()
try:
data = self._api_get('/restapi/v1.0/account/~/extension/~')
self.extension_name = data.get('name', 'Unknown')
except Exception:
_logger.exception("Failed to fetch extension info")
# ──────────────────────────────────────────────────────────
# Webhook management
# ──────────────────────────────────────────────────────────
def _create_webhook_subscription(self):
"""Create a webhook subscription for telephony events."""
self.ensure_one()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
webhook_url = f'{base_url}/ringcentral/webhook'
try:
data = self._api_post('/restapi/v1.0/subscription', data={
'eventFilters': [
'/restapi/v1.0/account/~/extension/~/telephony/sessions',
],
'deliveryMode': {
'transportType': 'WebHook',
'address': webhook_url,
},
'expiresIn': 630720000,
})
self.webhook_subscription_id = data.get('id', '')
_logger.info("RingCentral webhook subscription created: %s", self.webhook_subscription_id)
except Exception:
_logger.exception("Failed to create RingCentral webhook subscription")
def _renew_webhook_subscription(self):
"""Renew the webhook subscription."""
self.ensure_one()
if not self.webhook_subscription_id:
self._create_webhook_subscription()
return
try:
self._api_post(
f'/restapi/v1.0/subscription/{self.webhook_subscription_id}/renew'
)
_logger.info("RingCentral webhook renewed: %s", self.webhook_subscription_id)
except Exception:
_logger.warning("Webhook renewal failed, recreating...")
self._create_webhook_subscription()
# ──────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────
def _get_proxies(self):
"""Return proxy dict for requests if configured."""
if self.proxy_url:
proxy = f'{self.proxy_url}:{self.proxy_port}' if self.proxy_port else self.proxy_url
return {'http': proxy, 'https': proxy}
return None
def _notify(self, title, message, level='info'):
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _(title),
'message': message,
'type': level,
'sticky': level == 'danger',
},
}
@api.model
def _get_active_config(self):
"""Return the first connected config, or False."""
return self.search([('state', '=', 'connected')], limit=1)
def action_rematch_contacts(self):
"""Re-run contact matching on all calls without a linked partner."""
self.ensure_one()
CallHistory = self.env['rc.call.history']
unlinked = CallHistory.search([('partner_id', '=', False)])
matched = 0
for call in unlinked:
partner = CallHistory._match_partner(
call.from_number or '', call.to_number or '', call.direction,
)
if partner:
call.write({'partner_id': partner.id})
matched += 1
return self._notify(
'Contact Matching Complete',
f'Matched {matched} calls out of {len(unlinked)} unlinked records.',
'success' if matched else 'info',
)
def action_import_historical_calls(self):
"""Trigger historical call import in the background via cron.
Returns immediately so the UI doesn't freeze. The actual work
happens in _run_historical_import() called by a one-shot cron.
"""
self.ensure_one()
self._ensure_token()
cron = self.env.ref(
'fusion_ringcentral.cron_rc_historical_import', raise_if_not_found=False,
)
if cron:
cron.sudo().write({
'active': True,
'nextcall': fields.Datetime.now(),
})
else:
self.env['ir.cron'].sudo().create({
'name': 'RingCentral: Historical Import',
'cron_name': 'RingCentral: Historical Import',
'model_id': self.env['ir.model']._get('rc.config').id,
'state': 'code',
'code': 'model._run_historical_import()',
'interval_number': 9999,
'interval_type': 'days',
'nextcall': fields.Datetime.now(),
'active': True,
})
return self._notify(
'Import Started',
'Historical call import is running in the background. '
'Check Fusion RingCentral > Call History in a few minutes to see results.',
'info',
)
def action_import_historical_voicemails(self):
"""Trigger historical voicemail import in the background via cron."""
self.ensure_one()
self._ensure_token()
cron = self.env.ref(
'fusion_ringcentral.cron_rc_historical_vm_import',
raise_if_not_found=False,
)
if cron:
cron.sudo().write({
'active': True,
'nextcall': fields.Datetime.now(),
})
else:
self.env['ir.cron'].sudo().create({
'name': 'RingCentral: Historical Voicemail Import',
'cron_name': 'RingCentral: Historical Voicemail Import',
'model_id': self.env['ir.model']._get('rc.voicemail').id,
'state': 'code',
'code': 'model._run_historical_voicemail_import()',
'interval_number': 9999,
'interval_type': 'days',
'nextcall': fields.Datetime.now(),
'active': True,
})
return self._notify(
'Voicemail Import Started',
'Historical voicemail import is running in the background. '
'Check Fusion RingCentral > Voicemails in a few minutes.',
'info',
)
def action_backfill_voicemail_media(self):
"""Re-download audio and transcriptions for voicemails missing them."""
self.ensure_one()
self._ensure_token()
cron = self.env.ref(
'fusion_ringcentral.cron_rc_backfill_vm_media',
raise_if_not_found=False,
)
if cron:
cron.sudo().write({
'active': True,
'nextcall': fields.Datetime.now(),
})
else:
self.env['ir.cron'].sudo().create({
'name': 'RingCentral: Backfill Voicemail Media',
'cron_name': 'RingCentral: Backfill Voicemail Media',
'model_id': self.env['ir.model']._get('rc.voicemail').id,
'state': 'code',
'code': 'model._run_backfill_voicemail_media()',
'interval_number': 9999,
'interval_type': 'days',
'nextcall': fields.Datetime.now(),
'active': True,
})
return self._notify(
'Voicemail Media Backfill Started',
'Downloading audio and transcriptions for voicemails that are missing them. '
'This runs in the background with rate-limit pacing.',
'info',
)
@api.model
def _run_historical_import(self):
"""Background job: import up to 12 months of call history.
Tracks which monthly chunks completed successfully via
ir.config_parameter so it never re-queries months that
already finished. Only fetches chunks that failed or
haven't been attempted yet.
"""
config = self._get_active_config()
if not config:
_logger.warning("RC Historical Import: No connected config found.")
return
try:
config._ensure_token()
except Exception:
_logger.exception("RC Historical Import: Token invalid, aborting.")
return
ICP = self.env['ir.config_parameter'].sudo()
CallHistory = self.env['rc.call.history']
now = datetime.utcnow()
total_imported = 0
total_skipped_chunks = 0
failed_chunks = 0
for months_back in range(12, 0, -1):
chunk_start = now - timedelta(days=months_back * 30)
chunk_end = now - timedelta(days=(months_back - 1) * 30)
chunk_num = 13 - months_back
date_from = chunk_start.strftime('%Y-%m-%dT%H:%M:%S.000Z')
date_to = chunk_end.strftime('%Y-%m-%dT%H:%M:%S.000Z')
chunk_key = f'fusion_rc.import_done_{chunk_start.strftime("%Y%m")}'
already_done = ICP.get_param(chunk_key, '')
if already_done:
_logger.info(
"RC Import [%d/12]: %s to %s -- already imported, skipping.",
chunk_num, date_from[:10], date_to[:10],
)
total_skipped_chunks += 1
continue
_logger.info(
"RC Import [%d/12]: %s to %s ...",
chunk_num, date_from[:10], date_to[:10],
)
try:
chunk_count = CallHistory._sync_calls_from_date(config, date_from, date_to)
total_imported += chunk_count
ICP.set_param(chunk_key, fields.Datetime.now().isoformat())
_logger.info(
"RC Import [%d/12]: %d calls imported.",
chunk_num, chunk_count,
)
except Exception:
failed_chunks += 1
_logger.exception(
"RC Import [%d/12]: Failed, will retry next run.",
chunk_num,
)
ICP.set_param(
'fusion_rc.last_call_sync',
now.strftime('%Y-%m-%dT%H:%M:%S.000Z'),
)
_logger.info(
"RC Historical Import complete: %d imported, %d chunks skipped (already done), %d failed.",
total_imported, total_skipped_chunks, failed_chunks,
)
# ──────────────────────────────────────────────────────────
# Cron: token refresh
# ──────────────────────────────────────────────────────────
@api.model
def _cron_refresh_tokens(self):
"""Refresh tokens for all connected configs nearing expiry."""
soon = fields.Datetime.now() + timedelta(minutes=10)
configs = self.search([
('state', '=', 'connected'),
('token_expiry', '<=', soon),
('refresh_token', '!=', False),
])
for cfg in configs:
try:
cfg._refresh_token()
except Exception:
_logger.exception("Cron: Failed to refresh token for config %s", cfg.name)
# ──────────────────────────────────────────────────────────
# Cron: webhook renewal
# ──────────────────────────────────────────────────────────
@api.model
def _cron_renew_webhooks(self):
"""Renew webhook subscriptions for all connected configs."""
configs = self.search([('state', '=', 'connected')])
for cfg in configs:
try:
cfg._renew_webhook_subscription()
except Exception:
_logger.exception("Cron: Failed to renew webhook for config %s", cfg.name)

View File

@@ -0,0 +1,849 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import io
import re
import time
import logging
from datetime import datetime, timedelta
import requests as py_requests
from odoo import api, fields, models, _
RC_MEDIA_DELAY = 10
RC_MEDIA_RETRY_WAIT = 65
RC_MEDIA_MAX_RETRIES = 5
OPENAI_WHISPER_URL = 'https://api.openai.com/v1/audio/transcriptions'
_logger = logging.getLogger(__name__)
class RcVoicemail(models.Model):
_name = 'rc.voicemail'
_description = 'RingCentral Voicemail'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'received_date desc'
_rec_name = 'name'
name = fields.Char(
string='Reference', required=True, copy=False, readonly=True,
default=lambda self: _('New'),
)
rc_message_id = fields.Char(
string='RC Message ID', readonly=True, copy=False, index=True,
)
caller_number = fields.Char(string='Caller Number', readonly=True)
caller_name = fields.Char(string='Caller Name', readonly=True)
direction = fields.Selection([
('inbound', 'Inbound'),
('outbound', 'Outbound'),
], string='Direction', default='inbound', readonly=True)
received_date = fields.Datetime(string='Received At', readonly=True)
duration = fields.Integer(string='Duration (sec)', readonly=True)
duration_display = fields.Char(
string='Duration', compute='_compute_duration_display',
)
read_status = fields.Selection([
('Read', 'Read'),
('Unread', 'Unread'),
], string='Read Status', readonly=True)
transcription_status = fields.Char(
string='Transcription Status', readonly=True,
)
transcription_text = fields.Text(string='Transcription', readonly=True)
transcription_timeline = fields.Text(
string='Timeline', readonly=True,
help='Timestamped transcription with silence gaps.',
)
transcription_language = fields.Char(
string='Language', readonly=True,
)
has_transcription = fields.Boolean(
compute='_compute_has_transcription', store=True,
)
partner_id = fields.Many2one(
'res.partner', string='Contact', tracking=True,
)
audio_attachment_id = fields.Many2one(
'ir.attachment', string='Audio File', readonly=True,
)
sale_order_count = fields.Integer(compute='_compute_partner_counts')
invoice_count = fields.Integer(compute='_compute_partner_counts')
@api.depends('duration')
def _compute_duration_display(self):
for rec in self:
if rec.duration:
mins, secs = divmod(rec.duration, 60)
rec.duration_display = f'{mins}m {secs}s' if mins else f'{secs}s'
else:
rec.duration_display = ''
@api.depends('transcription_text')
def _compute_has_transcription(self):
for rec in self:
rec.has_transcription = bool(rec.transcription_text)
@api.depends('partner_id')
def _compute_partner_counts(self):
for rec in self:
if rec.partner_id:
partner = rec.partner_id.commercial_partner_id or rec.partner_id
partner_ids = (partner | partner.child_ids).ids
rec.sale_order_count = self.env['sale.order'].search_count(
[('partner_id', 'in', partner_ids)],
)
rec.invoice_count = self.env['account.move'].search_count(
[('partner_id', 'in', partner_ids),
('move_type', 'in', ('out_invoice', 'out_refund'))],
)
else:
rec.sale_order_count = 0
rec.invoice_count = 0
@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('rc.voicemail')
or _('New')
)
if not vals.get('partner_id') and vals.get('caller_number'):
partner = self._match_voicemail_partner(vals['caller_number'])
if partner:
vals['partner_id'] = partner.id
return super().create(vals_list)
# ──────────────────────────────────────────────────────────
# Contact matching
# ──────────────────────────────────────────────────────────
@api.model
def _match_voicemail_partner(self, number):
if not number:
return False
cleaned = self._normalize_phone(number)
if not cleaned or len(cleaned) < 7:
return False
Partner = self.env['res.partner']
phone_fields = [f for f in ('phone', 'mobile') if f in Partner._fields]
if not phone_fields:
return False
domain = ['|'] * (len(phone_fields) - 1)
for f in phone_fields:
domain.append((f, '!=', False))
partners = Partner.search(domain, order='customer_rank desc, write_date desc')
for p in partners:
for f in phone_fields:
val = p[f]
if val and self._normalize_phone(val) == cleaned:
return p
return False
@staticmethod
def _normalize_phone(number):
return re.sub(r'\D', '', number or '')[-10:] if number else ''
# ──────────────────────────────────────────────────────────
# Smart button actions
# ──────────────────────────────────────────────────────────
def action_view_contact(self):
self.ensure_one()
if not self.partner_id:
return
return {
'type': 'ir.actions.act_window',
'res_model': 'res.partner',
'res_id': self.partner_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_sale_orders(self):
self.ensure_one()
if not self.partner_id:
return
partner = self.partner_id.commercial_partner_id or self.partner_id
partner_ids = (partner | partner.child_ids).ids
return {
'type': 'ir.actions.act_window',
'name': _('Sales Orders - %s') % self.partner_id.name,
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('partner_id', 'in', partner_ids)],
'target': 'current',
}
def action_view_invoices(self):
self.ensure_one()
if not self.partner_id:
return
partner = self.partner_id.commercial_partner_id or self.partner_id
partner_ids = (partner | partner.child_ids).ids
return {
'type': 'ir.actions.act_window',
'name': _('Invoices - %s') % self.partner_id.name,
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('partner_id', 'in', partner_ids),
('move_type', 'in', ('out_invoice', 'out_refund'))],
'target': 'current',
}
# ──────────────────────────────────────────────────────────
# Call / Fax buttons (stay on same page via act_window_close)
# ──────────────────────────────────────────────────────────
def action_make_call(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window_close',
'infos': {
'rc_action': 'call',
'rc_phone_number': self.caller_number or '',
},
}
def action_send_sms(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window_close',
'infos': {
'rc_action': 'sms',
'rc_phone_number': self.caller_number or '',
},
}
def action_send_fax(self):
self.ensure_one()
ctx = {}
if self.partner_id:
ctx['default_partner_id'] = self.partner_id.id
fax_num = getattr(self.partner_id, 'x_ff_fax_number', '')
if fax_num:
ctx['default_fax_number'] = fax_num
return {
'type': 'ir.actions.act_window',
'name': _('Send Fax'),
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': ctx,
}
# ──────────────────────────────────────────────────────────
# Cron: incremental voicemail sync
# ──────────────────────────────────────────────────────────
@api.model
def _cron_sync_voicemails(self):
"""Incremental sync: fetch voicemails since last sync."""
config = self.env['rc.config']._get_active_config()
if not config:
return
ICP = self.env['ir.config_parameter'].sudo()
last_sync = ICP.get_param('fusion_rc.last_voicemail_sync', '')
if not last_sync:
two_days_ago = datetime.utcnow() - timedelta(days=2)
last_sync = two_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')
total = self._sync_voicemails_from_date(config, last_sync, now_str)
ICP.set_param('fusion_rc.last_voicemail_sync', now_str)
if total:
_logger.info("RC Voicemail Sync: %d new voicemails imported.", total)
@api.model
def _cron_daily_voicemail_catchup(self):
"""Re-scan past 3 days for any missed voicemails."""
config = self.env['rc.config']._get_active_config()
if not config:
return
three_days_ago = datetime.utcnow() - timedelta(days=3)
date_from = three_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')
total = self._sync_voicemails_from_date(config, date_from, now_str)
_logger.info("RC Voicemail Catchup: %d new voicemails (past 3 days).", total)
# ──────────────────────────────────────────────────────────
# Backfill: re-download audio/transcripts for existing records
# ──────────────────────────────────────────────────────────
@api.model
def _run_backfill_voicemail_media(self):
"""Re-fetch audio and transcription for records missing them.
Called via cron after the initial import that missed audio due
to rate limiting. Queries the RC API for each voicemail's
message data and downloads any missing media.
"""
config = self.env['rc.config']._get_active_config()
if not config:
_logger.warning("RC VM Backfill: No connected config.")
return
try:
config._ensure_token()
except Exception:
_logger.exception("RC VM Backfill: Token invalid.")
return
missing = self.search([
('audio_attachment_id', '=', False),
('rc_message_id', '!=', False),
], order='received_date asc')
_logger.info("RC VM Backfill: %d voicemails need audio.", len(missing))
success = 0
for vm in missing:
try:
data = config._api_get(
f'/restapi/v1.0/account/~/extension/~/message-store/{vm.rc_message_id}',
)
attachments = data.get('attachments', [])
audio_uri = ''
audio_content_type = 'audio/x-wav'
transcript_uri = ''
for att in attachments:
att_type = att.get('type', '')
if att_type == 'AudioRecording':
audio_uri = att.get('uri', '')
audio_content_type = att.get('contentType', 'audio/x-wav')
elif att_type == 'AudioTranscription':
transcript_uri = att.get('uri', '')
if not vm.transcription_text and transcript_uri:
text = self._download_transcription(transcript_uri, config)
if text:
vm.sudo().write({'transcription_text': text})
if audio_uri:
self._download_and_attach_audio(
vm, audio_uri, audio_content_type, config,
)
success += 1
if not vm.transcription_text and vm.audio_attachment_id:
self._try_openai_transcribe(vm)
except Exception:
_logger.exception(
"RC VM Backfill: Failed for %s (msg %s).",
vm.name, vm.rc_message_id,
)
_logger.info(
"RC VM Backfill complete: %d/%d audio files downloaded.",
success, len(missing),
)
# ──────────────────────────────────────────────────────────
# Historical import (all available voicemails, 12 months)
# ──────────────────────────────────────────────────────────
@api.model
def _run_historical_voicemail_import(self):
"""Background job: import up to 12 months of voicemails in monthly chunks.
Tracks which monthly chunks completed successfully via
ir.config_parameter so it never re-queries months already done.
Rate-limit safe: _api_get already paces at ~6 req/min with
auto-retry on 429.
"""
config = self.env['rc.config']._get_active_config()
if not config:
_logger.warning("RC VM Historical Import: No connected config.")
return
try:
config._ensure_token()
except Exception:
_logger.exception("RC VM Historical Import: Token invalid.")
return
ICP = self.env['ir.config_parameter'].sudo()
now = datetime.utcnow()
total_imported = 0
total_skipped = 0
failed_chunks = 0
for months_back in range(12, 0, -1):
chunk_start = now - timedelta(days=months_back * 30)
chunk_end = now - timedelta(days=(months_back - 1) * 30)
chunk_num = 13 - months_back
date_from = chunk_start.strftime('%Y-%m-%dT%H:%M:%S.000Z')
date_to = chunk_end.strftime('%Y-%m-%dT%H:%M:%S.000Z')
chunk_key = (
f'fusion_rc.vm_import_done_{chunk_start.strftime("%Y%m")}'
)
if ICP.get_param(chunk_key, ''):
_logger.info(
"RC VM Import [%d/12]: %s to %s -- already done, skipping.",
chunk_num, date_from[:10], date_to[:10],
)
total_skipped += 1
continue
_logger.info(
"RC VM Import [%d/12]: %s to %s ...",
chunk_num, date_from[:10], date_to[:10],
)
try:
chunk_count = self._sync_voicemails_from_date(
config, date_from, date_to,
)
total_imported += chunk_count
ICP.set_param(chunk_key, fields.Datetime.now().isoformat())
_logger.info(
"RC VM Import [%d/12]: %d voicemails imported.",
chunk_num, chunk_count,
)
except Exception:
failed_chunks += 1
_logger.exception(
"RC VM Import [%d/12]: Failed, will retry next run.",
chunk_num,
)
_logger.info(
"RC VM Historical Import complete: %d imported, %d skipped, %d failed.",
total_imported, total_skipped, failed_chunks,
)
# ──────────────────────────────────────────────────────────
# Core sync logic
# ──────────────────────────────────────────────────────────
def _sync_voicemails_from_date(self, config, date_from, date_to=None):
"""Fetch voicemails from RC Message Store for a date range."""
total_imported = 0
try:
params = {
'messageType': 'VoiceMail',
'dateFrom': date_from,
'perPage': 100,
}
if date_to:
params['dateTo'] = date_to
data = config._api_get(
'/restapi/v1.0/account/~/extension/~/message-store',
params=params,
)
total_imported += self._process_voicemail_page(data, config)
nav = data.get('navigation', {})
while nav.get('nextPage', {}).get('uri'):
next_uri = nav['nextPage']['uri']
data = config._api_get(next_uri)
total_imported += self._process_voicemail_page(data, config)
nav = data.get('navigation', {})
except Exception:
_logger.exception("RC Voicemail: Error syncing voicemails.")
return total_imported
def _process_voicemail_page(self, data, config):
"""Process one page of voicemail records with bulk dedup."""
records = data.get('records', [])
if not records:
return 0
page_ids = [str(r.get('id', '')) for r in records if r.get('id')]
if page_ids:
existing = set(
r.rc_message_id
for r in self.search([('rc_message_id', 'in', page_ids)])
)
else:
existing = set()
imported = 0
for msg in records:
msg_id = str(msg.get('id', ''))
if not msg_id or msg_id in existing:
continue
if self._import_voicemail(msg, config):
existing.add(msg_id)
imported += 1
return imported
def _import_voicemail(self, msg, config):
"""Import a single voicemail message and download its audio."""
try:
msg_id = str(msg.get('id', ''))
from_info = msg.get('from', {})
caller_number = from_info.get('phoneNumber', '')
caller_name = from_info.get('name', '')
creation_time = msg.get('creationTime', '')
received_dt = False
if creation_time:
try:
clean = creation_time.replace('Z', '+00:00')
received_dt = datetime.fromisoformat(clean).strftime(
'%Y-%m-%d %H:%M:%S'
)
except (ValueError, AttributeError):
pass
direction = (msg.get('direction', '') or '').lower()
if direction not in ('inbound', 'outbound'):
direction = 'inbound'
attachments = msg.get('attachments', [])
vm_duration = 0
audio_uri = ''
audio_content_type = 'audio/x-wav'
transcript_uri = ''
for att in attachments:
att_type = att.get('type', '')
if att_type == 'AudioRecording':
vm_duration = att.get('vmDuration', 0)
audio_uri = att.get('uri', '')
audio_content_type = att.get('contentType', 'audio/x-wav')
elif att_type == 'AudioTranscription':
transcript_uri = att.get('uri', '')
transcription_text = ''
if transcript_uri:
transcription_text = self._download_transcription(
transcript_uri, config,
)
partner = self._match_voicemail_partner(caller_number)
vm = self.sudo().create({
'rc_message_id': msg_id,
'caller_number': caller_number,
'caller_name': caller_name,
'direction': direction,
'received_date': received_dt,
'duration': vm_duration,
'read_status': msg.get('readStatus', 'Unread'),
'transcription_status': 'Completed' if transcription_text else '',
'transcription_text': transcription_text,
'partner_id': partner.id if partner else False,
})
if audio_uri:
self._download_and_attach_audio(vm, audio_uri, audio_content_type, config)
if not vm.transcription_text and vm.audio_attachment_id:
self._try_openai_transcribe(vm)
return True
except Exception:
_logger.exception(
"RC Voicemail: Failed to import message %s",
msg.get('id', ''),
)
return False
def _rc_media_get(self, uri, config):
"""Download binary content from RC with rate-limit retry."""
config._ensure_token()
for attempt in range(1, RC_MEDIA_MAX_RETRIES + 1):
resp = py_requests.get(
uri,
headers={'Authorization': f'Bearer {config.access_token}'},
timeout=30,
verify=config.ssl_verify,
proxies=config._get_proxies(),
)
if resp.status_code == 429:
wait = int(resp.headers.get('Retry-After', RC_MEDIA_RETRY_WAIT))
_logger.warning(
"RC media 429. Waiting %ds (attempt %d/%d)...",
wait, attempt, RC_MEDIA_MAX_RETRIES,
)
time.sleep(wait)
config._ensure_token()
continue
if resp.status_code == 401 and attempt < RC_MEDIA_MAX_RETRIES:
_logger.warning(
"RC media 401. Refreshing token, waiting 60s (attempt %d/%d)...",
attempt, RC_MEDIA_MAX_RETRIES,
)
time.sleep(60)
try:
config._refresh_token()
except Exception:
pass
continue
resp.raise_for_status()
time.sleep(RC_MEDIA_DELAY)
return resp
resp.raise_for_status()
def _download_transcription(self, transcript_uri, config):
"""Download voicemail transcription text from RC."""
try:
resp = self._rc_media_get(transcript_uri, config)
return (resp.text or '').strip()
except Exception:
_logger.warning("RC Voicemail: Could not fetch transcription.")
return ''
def _download_and_attach_audio(self, vm, audio_uri, content_type, config):
"""Download the voicemail audio and post it to the record's chatter."""
try:
resp = self._rc_media_get(audio_uri, config)
audio_data = resp.content
if not audio_data:
return
ext = 'wav' if 'wav' in content_type else 'mp3'
filename = f'{vm.name}.{ext}'
attachment = self.env['ir.attachment'].sudo().create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(audio_data),
'mimetype': content_type,
'res_model': 'rc.voicemail',
'res_id': vm.id,
})
vm.sudo().write({'audio_attachment_id': attachment.id})
vm.sudo().message_post(
body=_('Voicemail audio (%ss)') % vm.duration,
attachment_ids=[attachment.id],
message_type='notification',
)
except Exception:
_logger.exception(
"RC Voicemail: Failed to download audio for %s", vm.name,
)
# ──────────────────────────────────────────────────────────
# OpenAI Whisper transcription
# ──────────────────────────────────────────────────────────
def _get_openai_key(self):
return (
self.env['ir.config_parameter']
.sudo()
.get_param('fusion_rc.openai_api_key', '')
)
def _try_openai_transcribe(self, vm):
"""Transcribe a voicemail if OpenAI key is configured. Non-fatal."""
api_key = self._get_openai_key()
if not api_key:
return
try:
result = self._transcribe_audio_openai(vm.audio_attachment_id, api_key)
if result:
vm.sudo().write(result)
except Exception:
_logger.warning(
"RC Voicemail: OpenAI transcription failed for %s", vm.name,
)
def _transcribe_audio_openai(self, attachment, api_key):
"""Send audio to OpenAI Whisper verbose_json and return structured data.
Returns a dict with transcription_text (English), language tag,
and a timeline string showing silence gaps between segments.
"""
audio_data = base64.b64decode(attachment.datas)
if not audio_data:
return {}
ext = 'wav'
if attachment.mimetype and 'mp3' in attachment.mimetype:
ext = 'mp3'
resp = py_requests.post(
OPENAI_WHISPER_URL,
headers={'Authorization': f'Bearer {api_key}'},
files={
'file': (
f'voicemail.{ext}',
io.BytesIO(audio_data),
attachment.mimetype or 'audio/wav',
),
},
data={
'model': 'whisper-1',
'response_format': 'verbose_json',
'timestamp_granularities[]': 'segment',
},
timeout=120,
)
resp.raise_for_status()
data = resp.json()
language = (data.get('language') or '').strip()
full_text = (data.get('text') or '').strip()
segments = data.get('segments', [])
english_text = full_text
if language and language != 'english':
english_text = self._translate_to_english(full_text, api_key)
timeline = self._build_timeline(segments)
return {
'transcription_text': english_text,
'transcription_language': language.title() if language else '',
'transcription_timeline': timeline,
'transcription_status': 'Completed',
}
def _translate_to_english(self, text, api_key):
"""Translate text to English using OpenAI chat completions."""
try:
resp = py_requests.post(
'https://api.openai.com/v1/chat/completions',
headers={
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
},
json={
'model': 'gpt-4o-mini',
'messages': [
{
'role': 'system',
'content': 'Translate the following text to English. '
'Return only the translation, nothing else.',
},
{'role': 'user', 'content': text},
],
'temperature': 0.1,
},
timeout=60,
)
resp.raise_for_status()
return (
resp.json()
.get('choices', [{}])[0]
.get('message', {})
.get('content', text)
.strip()
)
except Exception:
_logger.warning("RC Voicemail: Translation failed, using original.")
return text
@staticmethod
def _build_timeline(segments):
"""Build a timeline string from Whisper segments showing silence gaps.
Example output:
.....3s..... Do you have apartments? .....6s..... Please call back.
"""
if not segments:
return ''
parts = []
prev_end = 0.0
for seg in segments:
seg_start = seg.get('start', 0.0)
seg_end = seg.get('end', 0.0)
seg_text = (seg.get('text') or '').strip()
gap = seg_start - prev_end
if gap >= 1.0:
parts.append(f'.....{int(gap)}s.....')
if seg_text:
parts.append(seg_text)
prev_end = seg_end
return ' '.join(parts)
def action_transcribe(self):
"""Manual button: transcribe this voicemail with OpenAI Whisper."""
self.ensure_one()
api_key = self._get_openai_key()
if not api_key:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('No API Key'),
'message': _('Set your OpenAI API key in Settings > Fusion RingCentral.'),
'type': 'warning',
'sticky': False,
},
}
if not self.audio_attachment_id:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('No Audio'),
'message': _('This voicemail has no audio file to transcribe.'),
'type': 'warning',
'sticky': False,
},
}
try:
result = self._transcribe_audio_openai(self.audio_attachment_id, api_key)
if result:
self.sudo().write(result)
except Exception:
_logger.exception("RC Voicemail: Manual transcription failed for %s", self.name)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Transcription Failed'),
'message': _('Could not transcribe. Check logs for details.'),
'type': 'danger',
'sticky': False,
},
}
@api.model
def _cron_transcribe_voicemails(self):
"""Batch-transcribe voicemails that have audio but no transcript."""
api_key = self._get_openai_key()
if not api_key:
return
pending = self.search([
('audio_attachment_id', '!=', False),
('transcription_text', '=', False),
], limit=50, order='received_date desc')
if not pending:
return
_logger.info("RC VM Transcribe: %d voicemails to process.", len(pending))
success = 0
for vm in pending:
try:
result = self._transcribe_audio_openai(vm.audio_attachment_id, api_key)
if result:
vm.sudo().write(result)
success += 1
except Exception:
_logger.warning(
"RC VM Transcribe: Failed for %s, skipping.", vm.name,
)
time.sleep(1)
_logger.info("RC VM Transcribe: %d/%d completed.", success, len(pending))

View File

@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
rc_openai_api_key = fields.Char(
string='OpenAI API Key',
config_parameter='fusion_rc.openai_api_key',
help='Used for automatic voicemail transcription via Whisper.',
)

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 ResPartner(models.Model):
_inherit = 'res.partner'
rc_call_ids = fields.One2many(
'rc.call.history',
'partner_id',
string='RingCentral Calls',
)
rc_call_count = fields.Integer(
string='Call Count',
compute='_compute_rc_call_count',
)
@api.depends('rc_call_ids')
def _compute_rc_call_count(self):
for partner in self:
partner.rc_call_count = len(partner.rc_call_ids)
def action_view_rc_calls(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Calls',
'res_model': 'rc.call.history',
'view_mode': 'list,form',
'domain': [('partner_id', '=', self.id)],
}
def action_rc_call(self):
"""Placeholder for click-to-call -- actual dialing is handled by the JS widget."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Click-to-Dial',
'message': f'Use the RingCentral phone widget to call {self.phone or self.mobile or "this contact"}.',
'type': 'info',
'sticky': False,
},
}