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,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import controllers
from . import models

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Connect',
'version': '19.0.1.0.0',
'category': 'Productivity/Phone',
'summary': 'Embedded RingCentral phone, click-to-dial, call history, recordings, transcripts, and analytics.',
'description': """
Fusion RingCentral
==================
Comprehensive RingCentral integration for Odoo 19.
Features:
---------
* Embedded RingCentral softphone widget
* Click-to-dial from any phone field
* System tray presence indicator
* OAuth 2.0 authentication with automatic token refresh
* Call history with automatic contact linking
* Call recordings with authenticated proxy playback
* AI-powered call transcripts via RingSense
* Webhook subscriptions for real-time call events
* KPI dashboard with graphs and pivot tables
* Background call history sync via cron
Copyright 2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': [
'base',
'mail',
'contacts',
'account',
'sale_management',
'fusion_faxes',
],
'external_dependencies': {
'python': ['requests'],
},
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/ir_sequence_data.xml',
'data/ir_cron_data.xml',
'views/rc_config_views.xml',
'views/rc_call_history_views.xml',
'views/rc_call_dashboard_views.xml',
'views/rc_fax_menus.xml',
'views/fusion_fax_views.xml',
'views/rc_voicemail_views.xml',
'views/res_config_settings_views.xml',
'views/res_partner_views.xml',
],
'assets': {
'web.assets_backend': [
'fusion_ringcentral/static/src/xml/rc_systray.xml',
'fusion_ringcentral/static/src/js/rc_phone_widget.js',
'fusion_ringcentral/static/src/js/rc_click_to_dial.js',
'fusion_ringcentral/static/src/js/rc_systray.js',
],
},
'installable': True,
'auto_install': False,
'application': True,
}

View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import oauth
from . import webhook
from . import widget
from . import recording

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import json
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class RcOAuthController(http.Controller):
@http.route('/ringcentral/oauth', type='http', auth='user', website=False)
def oauth_callback(self, code=None, state=None, error=None, **kw):
"""Handle the OAuth redirect from RingCentral after user authorizes."""
if error:
_logger.error("RingCentral OAuth error: %s", error)
return request.redirect('/odoo?rc_error=oauth_denied')
if not code:
return request.redirect('/odoo?rc_error=no_code')
config_id = None
if state:
try:
state_data = json.loads(state)
config_id = state_data.get('config_id')
except (json.JSONDecodeError, TypeError):
pass
if not config_id:
configs = request.env['rc.config'].search([], limit=1)
if not configs:
return request.redirect('/odoo?rc_error=no_config')
config_id = configs.id
config = request.env['rc.config'].browse(config_id)
if not config.exists():
return request.redirect('/odoo?rc_error=invalid_config')
base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
redirect_uri = f'{base_url}/ringcentral/oauth'
success = config.exchange_auth_code(code, redirect_uri)
if success:
return request.redirect(f'/odoo/rc-config/{config_id}')
else:
return request.redirect(f'/odoo/rc-config/{config_id}?rc_error=token_exchange_failed')

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import requests as py_requests
from odoo import http
from odoo.http import request, Response
_logger = logging.getLogger(__name__)
class RcRecordingController(http.Controller):
@http.route('/ringcentral/recording/<int:call_id>', type='http', auth='user')
def stream_recording(self, call_id, **kw):
"""Proxy a call recording from RingCentral with authentication."""
call = request.env['rc.call.history'].browse(call_id)
if not call.exists() or not call.recording_content_uri:
return Response('Recording not found', status=404)
config = request.env['rc.config']._get_active_config()
if not config:
return Response('RingCentral not configured', status=503)
try:
config._ensure_token()
resp = py_requests.get(
call.recording_content_uri,
headers={'Authorization': f'Bearer {config.access_token}'},
stream=True,
timeout=30,
verify=config.ssl_verify,
proxies=config._get_proxies(),
)
resp.raise_for_status()
content_type = resp.headers.get('Content-Type', 'audio/mpeg')
return Response(
resp.iter_content(chunk_size=4096),
content_type=content_type,
headers={
'Content-Disposition': f'inline; filename="recording_{call_id}.mp3"',
},
)
except Exception:
_logger.exception("Error streaming recording for call %s", call_id)
return Response('Error streaming recording', status=500)

View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import json
import logging
from odoo import fields, http
from odoo.http import request, Response
_logger = logging.getLogger(__name__)
class RcWebhookController(http.Controller):
@http.route('/ringcentral/webhook', type='json', auth='none', csrf=False, methods=['POST'])
def webhook_handler(self, **kw):
"""Receive webhook events from RingCentral."""
headers = request.httprequest.headers
validation_token = headers.get('Validation-Token')
if validation_token:
return Response(
status=200,
headers={'Validation-Token': validation_token},
)
try:
body = json.loads(request.httprequest.get_data(as_text=True))
except (json.JSONDecodeError, TypeError):
_logger.warning("RingCentral webhook: invalid JSON body")
return {'status': 'error', 'message': 'Invalid JSON'}
event = body.get('event', '')
_logger.info("RingCentral webhook event: %s", event)
if 'telephony/sessions' in event:
self._handle_telephony_event(body)
return {'status': 'ok'}
def _handle_telephony_event(self, body):
"""Process a telephony session event."""
try:
event_body = body.get('body', {})
parties = event_body.get('parties', [])
for party in parties:
status_code = party.get('status', {}).get('code', '')
if status_code != 'Disconnected':
continue
direction_raw = party.get('direction', '').lower()
direction = 'inbound' if direction_raw == 'inbound' else 'outbound'
from_info = party.get('from', {})
to_info = party.get('to', {})
from_number = from_info.get('phoneNumber', '')
to_number = to_info.get('phoneNumber', '')
session_id = event_body.get('sessionId', '')
if not session_id:
continue
env = request.env(su=True)
existing = env['rc.call.history'].search_count([
('rc_session_id', '=', str(session_id)),
])
if existing:
continue
duration = party.get('duration', 0)
result = party.get('status', {}).get('reason', 'Unknown')
status_map = {
'Answered': 'answered',
'CallConnected': 'answered',
'HangUp': 'answered',
'Voicemail': 'voicemail',
'Missed': 'missed',
'NoAnswer': 'no_answer',
'Busy': 'busy',
'Rejected': 'rejected',
}
status = status_map.get(result, 'answered' if duration > 0 else 'missed')
env['rc.call.history'].create({
'rc_session_id': str(session_id),
'direction': direction,
'from_number': from_number,
'to_number': to_number,
'start_time': fields.Datetime.now(),
'duration': duration,
'status': status,
})
except Exception:
_logger.exception("Error processing telephony webhook event")

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import http
from odoo.http import request
class RcWidgetController(http.Controller):
@http.route('/ringcentral/widget-config', type='json', auth='user')
def widget_config(self, **kw):
"""Return RingCentral widget configuration for the JS frontend."""
config = request.env['rc.config']._get_active_config()
if not config:
return {'enabled': False}
return {
'enabled': config.phone_widget_enabled,
'client_id': config.client_id,
'app_server': config.server_url,
'connected': config.state == 'connected',
}

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Sync call history every 15 minutes -->
<record id="ir_cron_sync_call_history" model="ir.cron">
<field name="name">Fusion RingCentral: Sync Call History</field>
<field name="model_id" ref="model_rc_call_history"/>
<field name="state">code</field>
<field name="code">model._cron_sync_call_history()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Refresh OAuth tokens every 30 minutes -->
<record id="ir_cron_refresh_tokens" model="ir.cron">
<field name="name">Fusion RingCentral: Refresh OAuth Tokens</field>
<field name="model_id" ref="model_rc_config"/>
<field name="state">code</field>
<field name="code">model._cron_refresh_tokens()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Renew webhook subscriptions daily -->
<record id="ir_cron_renew_webhooks" model="ir.cron">
<field name="name">Fusion RingCentral: Renew Webhook Subscriptions</field>
<field name="model_id" ref="model_rc_config"/>
<field name="state">code</field>
<field name="code">model._cron_renew_webhooks()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
<!-- Daily catchup: re-scan past 3 days for calls from phones/mobile -->
<record id="ir_cron_daily_catchup_sync" model="ir.cron">
<field name="name">Fusion RingCentral: Daily Call Catchup</field>
<field name="model_id" ref="model_rc_call_history"/>
<field name="state">code</field>
<field name="code">model._cron_daily_catchup_sync()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
<!-- Historical import (triggered manually from UI button) -->
<record id="cron_rc_historical_import" model="ir.cron">
<field name="name">Fusion RingCentral: Historical Import</field>
<field name="cron_name">Fusion RingCentral: Historical Import</field>
<field name="model_id" ref="model_rc_config"/>
<field name="state">code</field>
<field name="code">model._run_historical_import()</field>
<field name="interval_number">9999</field>
<field name="interval_type">days</field>
<field name="active">False</field>
</record>
<!-- Fetch transcripts every hour -->
<record id="ir_cron_fetch_transcripts" model="ir.cron">
<field name="name">Fusion RingCentral: Fetch Call Transcripts</field>
<field name="model_id" ref="model_rc_call_history"/>
<field name="state">code</field>
<field name="code">model._cron_fetch_transcripts()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active">True</field>
</record>
<!-- Backfill voicemail audio/transcripts (triggered manually) -->
<record id="cron_rc_backfill_vm_media" model="ir.cron">
<field name="name">Fusion RingCentral: Backfill Voicemail Media</field>
<field name="cron_name">Fusion RingCentral: Backfill Voicemail Media</field>
<field name="model_id" ref="model_rc_voicemail"/>
<field name="state">code</field>
<field name="code">model._run_backfill_voicemail_media()</field>
<field name="interval_number">9999</field>
<field name="interval_type">days</field>
<field name="active">False</field>
</record>
<!-- Historical voicemail import (triggered manually from UI button) -->
<record id="cron_rc_historical_vm_import" model="ir.cron">
<field name="name">Fusion RingCentral: Historical Voicemail Import</field>
<field name="cron_name">Fusion RingCentral: Historical Voicemail Import</field>
<field name="model_id" ref="model_rc_voicemail"/>
<field name="state">code</field>
<field name="code">model._run_historical_voicemail_import()</field>
<field name="interval_number">9999</field>
<field name="interval_type">days</field>
<field name="active">False</field>
</record>
<!-- Sync voicemails every 15 minutes -->
<record id="ir_cron_sync_voicemails" model="ir.cron">
<field name="name">Fusion RingCentral: Sync Voicemails</field>
<field name="model_id" ref="model_rc_voicemail"/>
<field name="state">code</field>
<field name="code">model._cron_sync_voicemails()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Daily catchup: re-scan past 3 days for voicemails -->
<record id="ir_cron_daily_voicemail_catchup" model="ir.cron">
<field name="name">Fusion RingCentral: Daily Voicemail Catchup</field>
<field name="model_id" ref="model_rc_voicemail"/>
<field name="state">code</field>
<field name="code">model._cron_daily_voicemail_catchup()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
<!-- Transcribe voicemails with OpenAI Whisper every hour -->
<record id="ir_cron_transcribe_voicemails" model="ir.cron">
<field name="name">Fusion RingCentral: Transcribe Voicemails</field>
<field name="model_id" ref="model_rc_voicemail"/>
<field name="state">code</field>
<field name="code">model._cron_transcribe_voicemails()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_rc_call_history" model="ir.sequence">
<field name="name">RingCentral Call</field>
<field name="code">rc.call.history</field>
<field name="prefix">CALL/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_rc_voicemail" model="ir.sequence">
<field name="name">RingCentral Voicemail</field>
<field name="code">rc.voicemail</field>
<field name="prefix">VM/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import 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,
},
}

View File

@@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_rc_config_manager,rc.config.manager,model_rc_config,group_rc_manager,1,1,1,1
access_rc_call_history_user,rc.call.history.user,model_rc_call_history,group_rc_user,1,1,1,0
access_rc_call_history_manager,rc.call.history.manager,model_rc_call_history,group_rc_manager,1,1,1,1
access_rc_call_dashboard_user,rc.call.dashboard.user,model_rc_call_dashboard,group_rc_user,1,1,1,1
access_rc_voicemail_user,rc.voicemail.user,model_rc_voicemail,group_rc_user,1,1,1,0
access_rc_voicemail_manager,rc.voicemail.manager,model_rc_voicemail,group_rc_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_rc_config_manager rc.config.manager model_rc_config group_rc_manager 1 1 1 1
3 access_rc_call_history_user rc.call.history.user model_rc_call_history group_rc_user 1 1 1 0
4 access_rc_call_history_manager rc.call.history.manager model_rc_call_history group_rc_manager 1 1 1 1
5 access_rc_call_dashboard_user rc.call.dashboard.user model_rc_call_dashboard group_rc_user 1 1 1 1
6 access_rc_voicemail_user rc.voicemail.user model_rc_voicemail group_rc_user 1 1 1 0
7 access_rc_voicemail_manager rc.voicemail.manager model_rc_voicemail group_rc_manager 1 1 1 1

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- User group: can make calls and view own call history -->
<record id="group_rc_user" model="res.groups">
<field name="name">Fusion RingCentral / User</field>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Manager group: can configure RC and view all call history -->
<record id="group_rc_manager" model="res.groups">
<field name="name">Fusion RingCentral / Manager</field>
<field name="implied_ids" eval="[(4, ref('group_rc_user'))]"/>
</record>
<!-- Record rules -->
<!-- Users see only their own call history -->
<record id="rule_call_user_own" model="ir.rule">
<field name="name">RC Call: user sees own calls</field>
<field name="model_id" ref="model_rc_call_history"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_rc_user'))]"/>
</record>
<!-- Managers see all call history -->
<record id="rule_call_manager_all" model="ir.rule">
<field name="name">RC Call: manager sees all calls</field>
<field name="model_id" ref="model_rc_call_history"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_rc_manager'))]"/>
</record>
<!-- Users see all voicemails (no per-user filtering for VM) -->
<record id="rule_voicemail_user_all" model="ir.rule">
<field name="name">RC Voicemail: user sees all</field>
<field name="model_id" ref="model_rc_voicemail"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_rc_user'))]"/>
</record>
<!-- Managers see all voicemails -->
<record id="rule_voicemail_manager_all" model="ir.rule">
<field name="name">RC Voicemail: manager sees all</field>
<field name="model_id" ref="model_rc_voicemail"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_rc_manager'))]"/>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,37 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { PhoneField } from "@web/views/fields/phone/phone_field";
import { rpcBus } from "@web/core/network/rpc";
patch(PhoneField.prototype, {
onClickCall(ev) {
if (window.RCAdapter) {
ev.preventDefault();
ev.stopPropagation();
const phoneNumber = this.props.record.data[this.props.name];
if (phoneNumber) {
window.RCAdapter.clickToCall(phoneNumber, true);
}
return;
}
return super.onClickCall(...arguments);
},
});
rpcBus.addEventListener("RPC:RESPONSE", (ev) => {
const { result } = ev.detail;
if (!result || typeof result !== "object" || !window.RCAdapter) {
return;
}
const infos = result.infos;
if (!infos || !infos.rc_action || !infos.rc_phone_number) {
return;
}
const { rc_action, rc_phone_number } = infos;
if (rc_action === "call") {
window.RCAdapter.clickToCall(rc_phone_number, true);
} else if (rc_action === "sms") {
window.RCAdapter.clickToSMS(rc_phone_number);
}
});

View File

@@ -0,0 +1,86 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { session } from "@web/session";
import { rpc } from "@web/core/network/rpc";
const RC_ADAPTER_URL = "https://apps.ringcentral.com/integration/ringcentral-embeddable/latest/adapter.js";
let rcWidgetLoaded = false;
async function loadRcWidget() {
if (rcWidgetLoaded) return;
let config;
try {
config = await rpc("/ringcentral/widget-config", {});
} catch {
return;
}
if (!config || !config.enabled || !config.client_id) return;
rcWidgetLoaded = true;
const script = document.createElement("script");
const params = new URLSearchParams({
clientId: config.client_id,
appServer: config.app_server || "https://platform.ringcentral.com",
disableInactiveTabCallEvent: "1",
});
script.src = `${RC_ADAPTER_URL}?${params.toString()}`;
document.head.appendChild(script);
window.addEventListener("message", (e) => {
if (!e.data) return;
switch (e.data.type) {
case "rc-login-status-notify":
window.__rcLoggedIn = e.data.loggedIn;
break;
case "rc-active-call-notify":
window.__rcActiveCall = e.data.call;
break;
case "rc-call-end-notify":
_logCallToOdoo(e.data.call);
break;
case "rc-adapter-syncPresence":
window.__rcPresence = {
dndStatus: e.data.dndStatus,
userStatus: e.data.userStatus,
telephonyStatus: e.data.telephonyStatus,
};
window.dispatchEvent(new CustomEvent("rc-presence-changed", {
detail: window.__rcPresence,
}));
break;
}
});
}
async function _logCallToOdoo(call) {
if (!call) return;
try {
await rpc("/web/dataset/call_kw", {
model: "rc.call.history",
method: "create",
args: [{
rc_session_id: String(call.sessionId || ""),
direction: (call.direction || "").toLowerCase() === "inbound" ? "inbound" : "outbound",
from_number: call.from || "",
to_number: call.to || "",
duration: call.duration || 0,
status: call.duration > 0 ? "answered" : "missed",
}],
kwargs: {},
});
} catch {
// Silently fail -- cron will catch it
}
}
registry.category("services").add("rc_phone_widget", {
start() {
loadRcWidget();
},
});

View File

@@ -0,0 +1,89 @@
/** @odoo-module **/
import { Component, useState, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
class RcSystrayItem extends Component {
static template = "fusion_ringcentral.SystrayItem";
static props = {};
setup() {
this.state = useState({
status: "offline",
visible: false,
});
onMounted(() => {
this._checkWidgetReady();
window.addEventListener("rc-presence-changed", (ev) => {
this._updatePresence(ev.detail);
});
});
}
_checkWidgetReady() {
const interval = setInterval(() => {
if (window.RCAdapter) {
this.state.visible = true;
clearInterval(interval);
}
}, 2000);
setTimeout(() => clearInterval(interval), 30000);
}
_updatePresence(presence) {
if (!presence) return;
this.state.visible = true;
const userStatus = (presence.userStatus || "").toLowerCase();
const dndStatus = (presence.dndStatus || "").toLowerCase();
if (dndStatus === "donotacceptanycalls") {
this.state.status = "dnd";
} else if (userStatus === "busy" || presence.telephonyStatus === "Ringing") {
this.state.status = "busy";
} else if (userStatus === "available") {
this.state.status = "available";
} else {
this.state.status = "offline";
}
}
get statusColor() {
const colors = {
available: "text-success",
busy: "text-danger",
dnd: "text-warning",
offline: "text-muted",
};
return colors[this.state.status] || "text-muted";
}
get statusTitle() {
const titles = {
available: "Available",
busy: "Busy",
dnd: "Do Not Disturb",
offline: "Offline",
};
return titles[this.state.status] || "RingCentral";
}
onClick() {
if (window.RCAdapter) {
const frame = document.querySelector("#rc-widget-adapter-frame");
if (frame) {
const isMinimized = frame.style.display === "none" ||
frame.closest("[style*='display: none']");
if (isMinimized) {
window.RCAdapter.setMinimized(false);
} else {
window.RCAdapter.setMinimized(true);
}
}
}
}
}
registry.category("systray").add("fusion_ringcentral.SystrayItem", {
Component: RcSystrayItem,
}, { sequence: 5 });

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_ringcentral.SystrayItem">
<div class="o_nav_entry" t-if="state.visible" t-on-click="onClick"
t-att-title="statusTitle" role="button" style="cursor: pointer;">
<i class="fa fa-phone fa-fw" t-att-class="statusColor"/>
</div>
</t>
</templates>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Extend the Fax Form: add smart buttons, forward, and send new -->
<record id="view_fusion_fax_form_inherit_rc" model="ir.ui.view">
<field name="name">fusion.fax.form.inherit.rc</field>
<field name="model">fusion.fax</field>
<field name="inherit_id" ref="fusion_faxes.view_fusion_fax_form"/>
<field name="arch" type="xml">
<!-- Add Forward and Send New buttons to header -->
<xpath expr="//button[@name='action_resend']" position="after">
<button name="action_forward_fax" string="Forward Fax"
type="object" class="btn-secondary"
icon="fa-share"
invisible="direction != 'inbound' or state != 'received'"/>
<button name="action_send_new_fax" string="Send New Fax"
type="object" class="btn-secondary"
icon="fa-fax"/>
</xpath>
<!-- Add Contact, Sales Orders, Invoices smart buttons -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_contact" type="object"
class="oe_stat_button" icon="fa-user"
invisible="not partner_id">
<div class="o_stat_info">
<span class="o_stat_text"><field name="partner_id" readonly="1" nolabel="1" class="o_text_overflow"/></span>
</div>
</button>
<button name="action_view_sale_orders" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not partner_id">
<field name="sale_order_count" widget="statinfo" string="Sales Orders"/>
</button>
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not partner_id">
<field name="invoice_count" widget="statinfo" string="Invoices"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,282 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Call Dashboard Form View -->
<record id="view_rc_call_dashboard_form" model="ir.ui.view">
<field name="name">rc.call.dashboard.form</field>
<field name="model">rc.call.dashboard</field>
<field name="arch" type="xml">
<form string="Call Dashboard" create="0" delete="0" edit="0">
<sheet>
<field name="name" invisible="1"/>
<div class="mb-4">
<span class="text-muted">RingCentral call activity overview and analytics</span>
</div>
<!-- KPI Stat Cards -->
<div class="d-flex flex-nowrap gap-3 mb-4">
<div class="flex-fill">
<button name="action_open_all" type="object"
class="btn btn-primary w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="total_count"/>
</div>
<div style="font-size: 0.85rem;">Total Calls</div>
</div>
</button>
</div>
<div class="flex-fill">
<button name="action_open_inbound" type="object"
class="btn btn-success w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="inbound_count"/>
</div>
<div style="font-size: 0.85rem;">Inbound</div>
</div>
</button>
</div>
<div class="flex-fill">
<button name="action_open_outbound" type="object"
class="btn btn-info w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="outbound_count"/>
</div>
<div style="font-size: 0.85rem;">Outbound</div>
</div>
</button>
</div>
<div class="flex-fill">
<button name="action_open_missed" type="object"
class="btn btn-danger w-100 p-0 border-0 rounded-3"
invisible="missed_count == 0">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="missed_count"/>
</div>
<div style="font-size: 0.85rem;">Missed</div>
</div>
</button>
</div>
</div>
<!-- Secondary stats -->
<div class="d-flex flex-nowrap gap-3 mb-4">
<div class="flex-fill border rounded-3 p-3 text-center">
<div class="text-muted" style="font-size: 0.85rem;">Success Rate</div>
<div class="fw-bold" style="font-size: 1.5rem;">
<field name="success_rate"/>%
</div>
</div>
<div class="flex-fill border rounded-3 p-3 text-center">
<div class="text-muted" style="font-size: 0.85rem;">Avg Duration</div>
<div class="fw-bold" style="font-size: 1.5rem;">
<field name="avg_duration"/> min
</div>
</div>
<div class="flex-fill border rounded-3 p-3 text-center">
<div class="text-muted" style="font-size: 0.85rem;">Today</div>
<div class="fw-bold" style="font-size: 1.5rem;">
<field name="today_count"/>
</div>
</div>
<div class="flex-fill border rounded-3 p-3 text-center">
<div class="text-muted" style="font-size: 0.85rem;">This Week</div>
<div class="fw-bold" style="font-size: 1.5rem;">
<field name="week_count"/>
</div>
</div>
<div class="flex-fill border rounded-3 p-3 text-center">
<div class="text-muted" style="font-size: 0.85rem;">This Month</div>
<div class="fw-bold" style="font-size: 1.5rem;">
<field name="month_count"/>
</div>
</div>
<div class="flex-fill">
<button name="action_open_unread_voicemails" type="object"
class="btn btn-warning w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 1.5rem;">
<field name="unread_voicemail_count"/>
</div>
<div style="font-size: 0.85rem;">Voicemails</div>
<div class="text-muted" style="font-size: 0.75rem;">
<field name="voicemail_count"/> total
</div>
</div>
</button>
</div>
</div>
<!-- Quick Actions -->
<div class="d-flex gap-2 mb-4">
<button name="action_quick_call" type="object"
class="btn btn-primary" icon="fa-phone">
Call
</button>
<button name="action_quick_sms" type="object"
class="btn btn-success" icon="fa-comment">
Message
</button>
<button name="action_quick_fax" type="object"
class="btn btn-info" icon="fa-fax">
Fax
</button>
<div class="ms-auto d-flex gap-2">
<button name="action_open_all" type="object"
class="btn btn-secondary" icon="fa-list">
View All Calls
</button>
<button name="action_open_graph" type="object"
class="btn btn-secondary" icon="fa-bar-chart">
Analytics
</button>
</div>
</div>
<!-- Recent Call History -->
<separator string="Recent Calls"/>
<field name="recent_call_ids" nolabel="1" readonly="1">
<list decoration-danger="status in ('missed', 'no_answer')"
decoration-success="status == 'answered'">
<field name="name"/>
<field name="direction" widget="badge"
decoration-info="direction == 'outbound'"
decoration-success="direction == 'inbound'"/>
<field name="start_time"/>
<field name="from_number"/>
<field name="to_number"/>
<field name="partner_id"/>
<field name="duration_display" string="Duration"/>
<field name="status" widget="badge"
decoration-success="status == 'answered'"
decoration-danger="status in ('missed', 'no_answer')"/>
<field name="has_recording" string="Rec" widget="boolean"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<!-- Voicemail Dashboard Form View -->
<record id="view_rc_voicemail_dashboard_form" model="ir.ui.view">
<field name="name">rc.call.dashboard.voicemail.form</field>
<field name="model">rc.call.dashboard</field>
<field name="arch" type="xml">
<form string="Voicemails" create="0" delete="0" edit="0">
<sheet>
<field name="name" invisible="1"/>
<!-- Quick Action Buttons -->
<div class="d-flex gap-2 mb-4">
<button name="action_quick_call" type="object"
class="btn btn-primary" icon="fa-phone">
Call
</button>
<button name="action_quick_sms" type="object"
class="btn btn-success" icon="fa-comment">
Message
</button>
<button name="action_quick_fax" type="object"
class="btn btn-info" icon="fa-fax">
Fax
</button>
<div class="ms-auto d-flex gap-2">
<button name="action_open_voicemails" type="object"
class="btn btn-secondary" icon="fa-list">
All Voicemails
</button>
</div>
</div>
<!-- KPI Row -->
<div class="d-flex flex-nowrap gap-3 mb-4">
<div class="flex-fill border rounded-3 p-3 text-center">
<div class="text-muted" style="font-size: 0.85rem;">Total</div>
<div class="fw-bold" style="font-size: 1.5rem;">
<field name="voicemail_count"/>
</div>
</div>
<div class="flex-fill border rounded-3 p-3 text-center"
style="border-color: #dc3545 !important;">
<div class="text-muted" style="font-size: 0.85rem;">Unread</div>
<div class="fw-bold text-danger" style="font-size: 1.5rem;">
<field name="unread_voicemail_count"/>
</div>
</div>
</div>
<!-- Recent Voicemails (Past Week) -->
<separator string="Last 7 Days"/>
<field name="recent_voicemail_ids" nolabel="1" readonly="1">
<list decoration-danger="read_status == 'Unread'"
decoration-muted="read_status == 'Read'"
open_form_view="True">
<field name="name"/>
<field name="direction" widget="badge"
decoration-success="direction == 'inbound'"
decoration-info="direction == 'outbound'"/>
<field name="received_date"/>
<field name="caller_number"/>
<field name="caller_name" optional="show"/>
<field name="partner_id"/>
<field name="duration_display" string="Duration"/>
<field name="read_status" widget="badge"
decoration-danger="read_status == 'Unread'"
decoration-success="read_status == 'Read'"/>
</list>
</field>
<!-- Older Voicemails -->
<separator string="Older Voicemails"/>
<field name="older_voicemail_ids" nolabel="1" readonly="1">
<list decoration-danger="read_status == 'Unread'"
decoration-muted="read_status == 'Read'"
open_form_view="True">
<field name="name"/>
<field name="direction" widget="badge"
decoration-success="direction == 'inbound'"
decoration-info="direction == 'outbound'"/>
<field name="received_date"/>
<field name="caller_number"/>
<field name="caller_name" optional="show"/>
<field name="partner_id"/>
<field name="duration_display" string="Duration"/>
<field name="read_status" widget="badge"
decoration-danger="read_status == 'Unread'"
decoration-success="read_status == 'Read'"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<!-- Dashboard Action -->
<record id="action_rc_call_dashboard" model="ir.actions.act_window">
<field name="name">Dashboard</field>
<field name="res_model">rc.call.dashboard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_rc_call_dashboard_form"/>
<field name="target">current</field>
</record>
<!-- Dashboard Menu -->
<menuitem id="menu_rc_dashboard"
name="Dashboard"
parent="menu_rc_root"
action="action_rc_call_dashboard"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,264 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Call History Form View -->
<record id="view_rc_call_history_form" model="ir.ui.view">
<field name="name">rc.call.history.form</field>
<field name="model">rc.call.history</field>
<field name="arch" type="xml">
<form string="Call Record">
<header>
<button name="action_make_call" type="object"
string="Call" icon="fa-phone"
class="btn-primary"/>
<button name="action_send_sms" type="object"
string="Text" icon="fa-comment"
class="btn-secondary"/>
<button name="action_send_fax" type="object"
string="Fax" icon="fa-fax"
class="btn-secondary"
invisible="not partner_id"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_contact" type="object"
class="oe_stat_button" icon="fa-user"
invisible="not partner_id">
<div class="o_stat_info">
<span class="o_stat_text"><field name="partner_id" readonly="1" nolabel="1"/></span>
</div>
</button>
<button name="action_view_sale_orders" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not partner_id">
<field name="sale_order_count" widget="statinfo"
string="Sales Orders"/>
</button>
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not partner_id">
<field name="invoice_count" widget="statinfo"
string="Invoices"/>
</button>
</div>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<div class="mb-3">
<field name="direction" widget="badge"
decoration-info="direction == 'outbound'"
decoration-success="direction == 'inbound'"
readonly="1"/>
<field name="status" widget="badge"
decoration-success="status == 'answered'"
decoration-danger="status in ('missed', 'no_answer')"
decoration-warning="status == 'busy'"
readonly="1" class="ms-2"/>
</div>
<group>
<group string="Call Details">
<field name="from_number" widget="phone"/>
<field name="to_number" widget="phone"/>
<field name="start_time"/>
<field name="duration_display" string="Duration"/>
<field name="duration" invisible="1"/>
</group>
<group string="Linking">
<field name="partner_id"/>
<field name="user_id"/>
<field name="sale_order_id" options="{'no_create': True}"/>
</group>
</group>
<!-- Recording section -->
<group string="Recording" invisible="not has_recording">
<div>
<button name="action_play_recording" type="object"
string="Play Recording" icon="fa-play-circle"
class="btn-primary"/>
</div>
<field name="has_recording" invisible="1"/>
<field name="recording_content_uri" invisible="1"/>
</group>
<!-- Transcript section -->
<group string="Transcript" invisible="not has_transcript">
<field name="transcript_text" nolabel="1" readonly="1"/>
<field name="has_transcript" invisible="1"/>
</group>
<!-- Notes -->
<group string="Notes">
<field name="notes" nolabel="1" placeholder="Add notes about this call..."/>
</group>
<group>
<field name="rc_session_id" readonly="1" string="RingCentral Session ID"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Call History List View -->
<record id="view_rc_call_history_list" model="ir.ui.view">
<field name="name">rc.call.history.list</field>
<field name="model">rc.call.history</field>
<field name="arch" type="xml">
<list string="Call History"
decoration-danger="status in ('missed', 'no_answer')"
decoration-success="status == 'answered'"
decoration-warning="status == 'busy'">
<field name="name"/>
<field name="direction" widget="badge"
decoration-info="direction == 'outbound'"
decoration-success="direction == 'inbound'"/>
<field name="start_time"/>
<field name="from_number"/>
<field name="to_number"/>
<field name="partner_id"/>
<field name="duration_display" string="Duration"/>
<field name="status" widget="badge"
decoration-success="status == 'answered'"
decoration-danger="status in ('missed', 'no_answer')"
decoration-warning="status == 'busy'"/>
<field name="has_recording" string="Rec" widget="boolean"/>
<field name="has_transcript" string="Trans" widget="boolean" optional="show"/>
<field name="user_id" optional="hide"/>
</list>
</field>
</record>
<!-- Call History Search View -->
<record id="view_rc_call_history_search" model="ir.ui.view">
<field name="name">rc.call.history.search</field>
<field name="model">rc.call.history</field>
<field name="arch" type="xml">
<search string="Search Calls">
<field name="name"/>
<field name="partner_id"/>
<field name="from_number"/>
<field name="to_number"/>
<separator/>
<filter string="Inbound" name="filter_inbound"
domain="[('direction', '=', 'inbound')]"/>
<filter string="Outbound" name="filter_outbound"
domain="[('direction', '=', 'outbound')]"/>
<separator/>
<filter string="Answered" name="filter_answered"
domain="[('status', '=', 'answered')]"/>
<filter string="Missed" name="filter_missed"
domain="[('status', 'in', ('missed', 'no_answer'))]"/>
<filter string="Has Recording" name="filter_recording"
domain="[('has_recording', '=', True)]"/>
<filter string="Has Transcript" name="filter_transcript"
domain="[('has_transcript', '=', True)]"/>
<separator/>
<filter string="Today" name="filter_today"
domain="[('start_time', '>=', (context_today()).strftime('%%Y-%%m-%%d'))]"/>
<filter string="This Week" name="filter_week"
domain="[('start_time', '>=', (context_today() - relativedelta(weeks=1)).strftime('%%Y-%%m-%%d'))]"/>
<filter string="This Month" name="filter_month"
domain="[('start_time', '>=', (context_today() - relativedelta(months=1)).strftime('%%Y-%%m-%%d'))]"/>
<separator/>
<filter string="Direction" name="group_direction"
context="{'group_by': 'direction'}"/>
<filter string="Status" name="group_status"
context="{'group_by': 'status'}"/>
<filter string="Contact" name="group_partner"
context="{'group_by': 'partner_id'}"/>
<filter string="Date" name="group_date"
context="{'group_by': 'start_time:month'}"/>
</search>
</field>
</record>
<!-- Graph View -->
<record id="view_rc_call_history_graph" model="ir.ui.view">
<field name="name">rc.call.history.graph</field>
<field name="model">rc.call.history</field>
<field name="arch" type="xml">
<graph string="Call Analytics" type="bar">
<field name="start_time" interval="month"/>
<field name="direction"/>
<field name="duration" type="measure"/>
</graph>
</field>
</record>
<!-- Pivot View -->
<record id="view_rc_call_history_pivot" model="ir.ui.view">
<field name="name">rc.call.history.pivot</field>
<field name="model">rc.call.history</field>
<field name="arch" type="xml">
<pivot string="Call Analytics">
<field name="partner_id" type="row"/>
<field name="direction" type="col"/>
<field name="duration" type="measure"/>
</pivot>
</field>
</record>
<!-- Actions -->
<record id="action_rc_call_history" model="ir.actions.act_window">
<field name="name">Call History</field>
<field name="res_model">rc.call.history</field>
<field name="path">calls</field>
<field name="view_mode">list,form,graph,pivot</field>
<field name="search_view_id" ref="view_rc_call_history_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No call history yet
</p>
<p>
Call history is automatically synced from RingCentral every 15 minutes.
Connect your RingCentral account in Configuration to start.
</p>
</field>
</record>
<record id="action_rc_call_inbound" model="ir.actions.act_window">
<field name="name">Inbound Calls</field>
<field name="res_model">rc.call.history</field>
<field name="view_mode">list,form,graph</field>
<field name="domain">[('direction', '=', 'inbound')]</field>
<field name="search_view_id" ref="view_rc_call_history_search"/>
</record>
<record id="action_rc_call_outbound" model="ir.actions.act_window">
<field name="name">Outbound Calls</field>
<field name="res_model">rc.call.history</field>
<field name="view_mode">list,form,graph</field>
<field name="domain">[('direction', '=', 'outbound')]</field>
<field name="search_view_id" ref="view_rc_call_history_search"/>
</record>
<!-- Phone menu items (under Fusion RingCentral root) -->
<menuitem id="menu_rc_phone"
name="Phone"
parent="menu_rc_root"
sequence="20"/>
<menuitem id="menu_rc_call_history"
name="Call History"
parent="menu_rc_phone"
action="action_rc_call_history"
sequence="10"/>
<menuitem id="menu_rc_call_inbound"
name="Inbound Calls"
parent="menu_rc_phone"
action="action_rc_call_inbound"
sequence="20"/>
<menuitem id="menu_rc_call_outbound"
name="Outbound Calls"
parent="menu_rc_phone"
action="action_rc_call_outbound"
sequence="30"/>
</odoo>

View File

@@ -0,0 +1,161 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- RC Config Form View -->
<record id="view_rc_config_form" model="ir.ui.view">
<field name="name">rc.config.form</field>
<field name="model">rc.config</field>
<field name="arch" type="xml">
<form string="RingCentral Configuration">
<header>
<button name="action_test_connection" type="object"
string="Test Connection" icon="fa-plug"
class="btn-secondary"/>
<button name="action_oauth_connect" type="object"
string="Connect (OAuth)" icon="fa-sign-in"
class="btn-primary"
invisible="state == 'connected'"
confirm="You will be redirected to RingCentral to authorize. Continue?"/>
<button name="action_disconnect" type="object"
string="Disconnect" icon="fa-sign-out"
class="btn-danger"
invisible="state != 'connected'"
confirm="This will revoke the RingCentral connection. Continue?"/>
<button name="action_import_historical_calls" type="object"
string="Import Call History (12 months)"
icon="fa-history"
class="btn-secondary"
invisible="state != 'connected'"
confirm="This will import up to 12 months of call history from RingCentral. This may take a few minutes. Continue?"/>
<button name="action_import_historical_voicemails" type="object"
string="Import Voicemails (12 months)"
icon="fa-voicemail"
class="btn-secondary"
invisible="state != 'connected'"
confirm="This will import up to 12 months of voicemails from RingCentral. This runs in the background. Continue?"/>
<button name="action_backfill_voicemail_media" type="object"
string="Download Voicemail Audio"
icon="fa-download"
class="btn-secondary"
invisible="state != 'connected'"
confirm="This will download audio files and transcriptions for voicemails that are missing them. Continue?"/>
<button name="action_rematch_contacts" type="object"
string="Re-match Contacts"
icon="fa-users"
class="btn-secondary"
invisible="state != 'connected'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,connected"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="Configuration Name"/>
</h1>
</div>
<group>
<group string="Application Credentials">
<field name="client_id" placeholder="Client ID from RingCentral Developer Console"/>
<field name="client_secret" password="True"
placeholder="Client Secret"/>
<field name="server_url"
placeholder="https://platform.ringcentral.com"/>
</group>
<group string="Connection Status">
<field name="extension_name" invisible="state != 'connected'"/>
<field name="phone_widget_enabled"/>
<field name="webhook_subscription_id" invisible="not webhook_subscription_id"/>
<field name="token_expiry" invisible="state != 'connected'"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group string="Proxy Settings (Optional)" col="4">
<field name="proxy_url" placeholder="http://proxy.company.com"/>
<field name="proxy_port" placeholder="8080"/>
<field name="ssl_verify"/>
</group>
<div class="alert alert-info" role="alert"
invisible="state == 'connected'">
<strong>Setup Instructions:</strong>
<ol class="mb-0">
<li>Enter your Client ID and Client Secret from the
<a href="https://developers.ringcentral.com/my-account.html"
target="_blank">RingCentral Developer Console</a>.</li>
<li>Click <strong>Test Connection</strong> to verify connectivity.</li>
<li>Click <strong>Connect (OAuth)</strong> to authorize with your RingCentral account.</li>
<li>Add these redirect URIs to your RingCentral app:
<ul>
<li><code>{your-odoo-url}/ringcentral/oauth</code></li>
<li><code>https://apps.ringcentral.com/integration/ringcentral-embeddable/latest/redirect.html</code></li>
</ul>
</li>
</ol>
</div>
<div class="alert alert-success" role="alert"
invisible="state != 'connected'">
<i class="fa fa-check-circle"/> Connected to RingCentral as
<strong><field name="extension_name" nolabel="1" readonly="1" class="d-inline"/></strong>.
The phone widget, call history sync, and webhooks are active.
</div>
</sheet>
</form>
</field>
</record>
<!-- RC Config List View -->
<record id="view_rc_config_list" model="ir.ui.view">
<field name="name">rc.config.list</field>
<field name="model">rc.config</field>
<field name="arch" type="xml">
<list string="RingCentral Configurations">
<field name="name"/>
<field name="server_url"/>
<field name="extension_name"/>
<field name="phone_widget_enabled"/>
<field name="state" widget="badge"
decoration-success="state == 'connected'"
decoration-warning="state == 'draft'"
decoration-danger="state == 'error'"/>
</list>
</field>
</record>
<!-- Action and Menu -->
<record id="action_rc_config" model="ir.actions.act_window">
<field name="name">RingCentral Settings</field>
<field name="res_model">rc.config</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Configure RingCentral Integration
</p>
<p>
Create a configuration record to connect Odoo with your RingCentral account.
</p>
</field>
</record>
<!-- Top-level menu: Fusion Connect -->
<menuitem id="menu_rc_root"
name="Fusion Connect"
web_icon="fusion_ringcentral,static/description/icon.png"
sequence="44"/>
<!-- Configuration submenu -->
<menuitem id="menu_rc_configuration"
name="Configuration"
parent="menu_rc_root"
sequence="90"/>
<menuitem id="menu_rc_config_settings"
name="RingCentral Settings"
parent="menu_rc_configuration"
action="action_rc_config"
sequence="10"/>
</odoo>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Hide the standalone Fusion Faxes top-level menu -->
<record id="fusion_faxes.menu_fusion_faxes_root" model="ir.ui.menu">
<field name="active" eval="False"/>
</record>
<!-- Faxes submenu under Fusion RingCentral -->
<menuitem id="menu_rc_faxes"
name="Faxes"
parent="menu_rc_root"
sequence="30"/>
<menuitem id="menu_rc_fax_all"
name="All Faxes"
parent="menu_rc_faxes"
action="fusion_faxes.action_fusion_fax"
sequence="10"/>
<menuitem id="menu_rc_fax_received"
name="Received Faxes"
parent="menu_rc_faxes"
action="fusion_faxes.action_fusion_fax_received"
sequence="20"/>
<menuitem id="menu_rc_fax_sent"
name="Sent Faxes"
parent="menu_rc_faxes"
action="fusion_faxes.action_fusion_fax_sent"
sequence="30"/>
<menuitem id="menu_rc_fax_send"
name="Send Fax"
parent="menu_rc_faxes"
action="fusion_faxes.action_send_fax_wizard"
sequence="5"/>
<menuitem id="menu_rc_fax_dashboard"
name="Fax Dashboard"
parent="menu_rc_faxes"
action="fusion_faxes.action_fusion_fax_dashboard"
sequence="40"/>
</odoo>

View File

@@ -0,0 +1,217 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Voicemail Form View -->
<record id="view_rc_voicemail_form" model="ir.ui.view">
<field name="name">rc.voicemail.form</field>
<field name="model">rc.voicemail</field>
<field name="arch" type="xml">
<form string="Voicemail">
<header>
<button name="action_make_call" type="object"
string="Call" icon="fa-phone"
class="btn-primary"/>
<button name="action_send_sms" type="object"
string="Message" icon="fa-comment"
class="btn-success"/>
<button name="action_send_fax" type="object"
string="Fax" icon="fa-fax"
class="btn-secondary"
invisible="not partner_id"/>
<button name="action_transcribe" type="object"
string="Transcribe" icon="fa-file-text"
class="btn-secondary"
invisible="not audio_attachment_id or transcription_text"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_contact" type="object"
class="oe_stat_button" icon="fa-user"
invisible="not partner_id">
<div class="o_stat_info">
<span class="o_stat_text">
<field name="partner_id" readonly="1"
nolabel="1" class="o_text_overflow"/>
</span>
</div>
</button>
<button name="action_view_sale_orders" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not partner_id">
<field name="sale_order_count" widget="statinfo"
string="Sales Orders"/>
</button>
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not partner_id">
<field name="invoice_count" widget="statinfo"
string="Invoices"/>
</button>
</div>
<field name="audio_attachment_id" invisible="1"/>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<div class="mb-3">
<field name="direction" widget="badge"
decoration-success="direction == 'inbound'"
decoration-info="direction == 'outbound'"
readonly="1"/>
<field name="read_status" widget="badge"
decoration-danger="read_status == 'Unread'"
decoration-success="read_status == 'Read'"
readonly="1" class="ms-2"/>
</div>
<group>
<group string="Voicemail Details">
<field name="caller_number" widget="phone"/>
<field name="caller_name"/>
<field name="received_date"/>
<field name="duration_display" string="Duration"/>
<field name="duration" invisible="1"/>
</group>
<group string="Linking">
<field name="partner_id"/>
<field name="transcription_status"/>
</group>
</group>
<group string="Transcription"
invisible="not transcription_text">
<group>
<field name="transcription_language"
widget="badge" decoration-info="1"/>
</group>
<field name="transcription_text" nolabel="1"
readonly="1"/>
</group>
<group>
<field name="rc_message_id" readonly="1"
string="RingCentral Message ID"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Voicemail List View -->
<record id="view_rc_voicemail_list" model="ir.ui.view">
<field name="name">rc.voicemail.list</field>
<field name="model">rc.voicemail</field>
<field name="arch" type="xml">
<list string="Voicemails"
decoration-danger="read_status == 'Unread'"
decoration-muted="read_status == 'Read'">
<field name="name"/>
<field name="direction" widget="badge"
decoration-success="direction == 'inbound'"
decoration-info="direction == 'outbound'"/>
<field name="received_date"/>
<field name="caller_number"/>
<field name="caller_name" optional="show"/>
<field name="partner_id"/>
<field name="duration_display" string="Duration"/>
<field name="read_status" widget="badge"
decoration-danger="read_status == 'Unread'"
decoration-success="read_status == 'Read'"/>
<field name="has_transcription" string="Transcript"
widget="boolean"/>
</list>
</field>
</record>
<!-- Voicemail Search View -->
<record id="view_rc_voicemail_search" model="ir.ui.view">
<field name="name">rc.voicemail.search</field>
<field name="model">rc.voicemail</field>
<field name="arch" type="xml">
<search string="Search Voicemails">
<field name="name"/>
<field name="partner_id"/>
<field name="caller_number"/>
<field name="caller_name"/>
<separator/>
<filter string="Unread" name="filter_unread"
domain="[('read_status', '=', 'Unread')]"/>
<filter string="Read" name="filter_read"
domain="[('read_status', '=', 'Read')]"/>
<separator/>
<filter string="Inbound" name="filter_inbound"
domain="[('direction', '=', 'inbound')]"/>
<filter string="Outbound" name="filter_outbound"
domain="[('direction', '=', 'outbound')]"/>
<separator/>
<filter string="Today" name="filter_today"
domain="[('received_date', '>=', (context_today()).strftime('%%Y-%%m-%%d'))]"/>
<filter string="This Week" name="filter_week"
domain="[('received_date', '>=', (context_today() - relativedelta(weeks=1)).strftime('%%Y-%%m-%%d'))]"/>
<filter string="This Month" name="filter_month"
domain="[('received_date', '>=', (context_today() - relativedelta(months=1)).strftime('%%Y-%%m-%%d'))]"/>
<separator/>
<filter string="Contact" name="group_partner"
context="{'group_by': 'partner_id'}"/>
<filter string="Date" name="group_date"
context="{'group_by': 'received_date:month'}"/>
<filter string="Read Status" name="group_read"
context="{'group_by': 'read_status'}"/>
</search>
</field>
</record>
<!-- Actions -->
<record id="action_rc_voicemail" model="ir.actions.act_window">
<field name="name">Voicemails</field>
<field name="res_model">rc.voicemail</field>
<field name="path">voicemails</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_rc_voicemail_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No voicemails yet
</p>
<p>
Voicemails are automatically synced from RingCentral.
</p>
</field>
</record>
<!-- Voicemail Dashboard Action -->
<record id="action_rc_voicemail_dashboard" model="ir.actions.act_window">
<field name="name">Voicemail Dashboard</field>
<field name="res_model">rc.call.dashboard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_rc_voicemail_dashboard_form"/>
<field name="target">current</field>
</record>
<!-- Voicemail top-level menu -->
<menuitem id="menu_rc_voicemail_root"
name="Voicemail"
parent="menu_rc_root"
sequence="30"/>
<menuitem id="menu_rc_voicemail_dashboard"
name="Dashboard"
parent="menu_rc_voicemail_root"
action="action_rc_voicemail_dashboard"
sequence="10"/>
<menuitem id="menu_rc_voicemails"
name="Voicemails"
parent="menu_rc_voicemail_root"
action="action_rc_voicemail"
sequence="20"/>
<!-- Keep voicemails accessible under Phone too -->
<menuitem id="menu_rc_voicemails_phone"
name="Voicemails"
parent="menu_rc_phone"
action="action_rc_voicemail"
sequence="15"/>
</odoo>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_rc" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.fusion.rc</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion RingCentral" string="Fusion RingCentral" name="fusion_ringcentral">
<h2>AI Transcription</h2>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">OpenAI API Key</span>
<div class="text-muted">Used for automatic voicemail transcription via Whisper.</div>
<div class="mt-2">
<field name="rc_openai_api_key" password="True"/>
</div>
</div>
</div>
</div>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add RingCentral calls tab and smart button to contact form -->
<record id="view_partner_form_inherit_rc" model="ir.ui.view">
<field name="name">res.partner.form.inherit.fusion_ringcentral</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<!-- Smart button for call count -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_rc_calls" type="object"
class="oe_stat_button" icon="fa-phone"
invisible="rc_call_count == 0">
<field name="rc_call_count" widget="statinfo" string="Calls"/>
</button>
</xpath>
<!-- RingCentral Calls tab -->
<xpath expr="//page[@name='internal_notes']" position="after">
<page string="RingCentral Calls" name="rc_calls"
invisible="rc_call_count == 0">
<field name="rc_call_ids" readonly="1">
<list decoration-danger="status in ('missed', 'no_answer')"
decoration-success="status == 'answered'">
<field name="name"/>
<field name="direction" widget="badge"
decoration-info="direction == 'outbound'"
decoration-success="direction == 'inbound'"/>
<field name="start_time"/>
<field name="from_number"/>
<field name="to_number"/>
<field name="duration_display" string="Duration"/>
<field name="status" widget="badge"
decoration-success="status == 'answered'"
decoration-danger="status in ('missed', 'no_answer')"/>
<field name="has_recording" string="Rec" widget="boolean"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>