Initial commit
This commit is contained in:
11
fusion_ringcentral/models/__init__.py
Normal file
11
fusion_ringcentral/models/__init__.py
Normal 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
|
||||
188
fusion_ringcentral/models/fusion_fax.py
Normal file
188
fusion_ringcentral/models/fusion_fax.py
Normal 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,
|
||||
}
|
||||
201
fusion_ringcentral/models/rc_call_dashboard.py
Normal file
201
fusion_ringcentral/models/rc_call_dashboard.py
Normal 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',
|
||||
}
|
||||
488
fusion_ringcentral/models/rc_call_history.py
Normal file
488
fusion_ringcentral/models/rc_call_history.py
Normal 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)
|
||||
609
fusion_ringcentral/models/rc_config.py
Normal file
609
fusion_ringcentral/models/rc_config.py
Normal 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)
|
||||
849
fusion_ringcentral/models/rc_voicemail.py
Normal file
849
fusion_ringcentral/models/rc_voicemail.py
Normal 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))
|
||||
15
fusion_ringcentral/models/res_config_settings.py
Normal file
15
fusion_ringcentral/models/res_config_settings.py
Normal 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.',
|
||||
)
|
||||
48
fusion_ringcentral/models/res_partner.py
Normal file
48
fusion_ringcentral/models/res_partner.py
Normal 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,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user