updates
This commit is contained in:
6
fusion_ringcentral/fusion_ringcentral/__init__.py
Normal file
6
fusion_ringcentral/fusion_ringcentral/__init__.py
Normal 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
|
||||
70
fusion_ringcentral/fusion_ringcentral/__manifest__.py
Normal file
70
fusion_ringcentral/fusion_ringcentral/__manifest__.py
Normal 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', 'ringcentral'],
|
||||
},
|
||||
'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,
|
||||
}
|
||||
@@ -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
|
||||
51
fusion_ringcentral/fusion_ringcentral/controllers/oauth.py
Normal file
51
fusion_ringcentral/fusion_ringcentral/controllers/oauth.py
Normal 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')
|
||||
@@ -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)
|
||||
96
fusion_ringcentral/fusion_ringcentral/controllers/webhook.py
Normal file
96
fusion_ringcentral/fusion_ringcentral/controllers/webhook.py
Normal 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")
|
||||
23
fusion_ringcentral/fusion_ringcentral/controllers/widget.py
Normal file
23
fusion_ringcentral/fusion_ringcentral/controllers/widget.py
Normal 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'].sudo()._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 or 'https://platform.ringcentral.com',
|
||||
'connected': config.state == 'connected',
|
||||
}
|
||||
142
fusion_ringcentral/fusion_ringcentral/data/ir_cron_data.xml
Normal file
142
fusion_ringcentral/fusion_ringcentral/data/ir_cron_data.xml
Normal file
@@ -0,0 +1,142 @@
|
||||
<?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>
|
||||
|
||||
<!-- Historical fax import (triggered manually from UI button) -->
|
||||
<record id="cron_rc_historical_fax_import" model="ir.cron">
|
||||
<field name="name">Fusion Connect: Historical Fax Import</field>
|
||||
<field name="cron_name">Fusion Connect: Historical Fax Import</field>
|
||||
<field name="model_id" ref="model_fusion_fax"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._run_historical_fax_import()</field>
|
||||
<field name="interval_number">9999</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -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>
|
||||
11
fusion_ringcentral/fusion_ringcentral/models/__init__.py
Normal file
11
fusion_ringcentral/fusion_ringcentral/models/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import rc_config
|
||||
from . import rc_call_history
|
||||
from . import rc_call_dashboard
|
||||
from . import res_partner
|
||||
from . import fusion_fax
|
||||
from . import rc_voicemail
|
||||
from . import res_config_settings
|
||||
391
fusion_ringcentral/fusion_ringcentral/models/fusion_fax.py
Normal file
391
fusion_ringcentral/fusion_ringcentral/models/fusion_fax.py
Normal file
@@ -0,0 +1,391 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
RC_RATE_LIMIT_DELAY = 6
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Historical fax import (via rc.config OAuth)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _run_historical_fax_import(self):
|
||||
"""Background job: import up to 12 months of faxes in monthly chunks."""
|
||||
config = self.env['rc.config']._get_active_config()
|
||||
if not config:
|
||||
_logger.warning("RC Fax Historical Import: No connected config.")
|
||||
return
|
||||
|
||||
try:
|
||||
config._ensure_token()
|
||||
except Exception:
|
||||
_logger.exception("RC Fax Historical Import: Token invalid.")
|
||||
return
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
now = datetime.utcnow()
|
||||
total_imported = 0
|
||||
total_skipped = 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.fax_import_done_{chunk_start.strftime("%Y%m")}'
|
||||
if ICP.get_param(chunk_key, ''):
|
||||
_logger.info(
|
||||
"RC Fax Import [%d/12]: %s to %s -- already done, skipping.",
|
||||
chunk_num, date_from[:10], date_to[:10],
|
||||
)
|
||||
total_skipped += 1
|
||||
continue
|
||||
|
||||
_logger.info(
|
||||
"RC Fax Import [%d/12]: %s to %s ...",
|
||||
chunk_num, date_from[:10], date_to[:10],
|
||||
)
|
||||
try:
|
||||
chunk_count = self._sync_faxes_from_rc(config, date_from, date_to)
|
||||
ICP.set_param(chunk_key, 'done')
|
||||
total_imported += chunk_count
|
||||
_logger.info(
|
||||
"RC Fax Import [%d/12]: imported %d faxes.",
|
||||
chunk_num, chunk_count,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"RC Fax Import [%d/12]: chunk failed, will retry next run.",
|
||||
chunk_num,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"RC Fax Historical Import complete: %d imported, %d chunks skipped.",
|
||||
total_imported, total_skipped,
|
||||
)
|
||||
|
||||
def _sync_faxes_from_rc(self, config, date_from, date_to):
|
||||
"""Fetch faxes (inbound + outbound) from RC message-store via OAuth."""
|
||||
imported = 0
|
||||
|
||||
for direction in ('Inbound', 'Outbound'):
|
||||
params = {
|
||||
'messageType': 'Fax',
|
||||
'direction': direction,
|
||||
'dateFrom': date_from,
|
||||
'dateTo': date_to,
|
||||
'perPage': 100,
|
||||
}
|
||||
endpoint = '/restapi/v1.0/account/~/extension/~/message-store'
|
||||
|
||||
while endpoint:
|
||||
time.sleep(RC_RATE_LIMIT_DELAY)
|
||||
resp = config._api_request('GET', endpoint, params=params)
|
||||
data = resp.json() if hasattr(resp, 'json') else resp
|
||||
|
||||
records = data.get('records', []) if isinstance(data, dict) else []
|
||||
|
||||
for msg in records:
|
||||
msg_id = str(msg.get('id', ''))
|
||||
if not msg_id:
|
||||
continue
|
||||
if self.search_count([('ringcentral_message_id', '=', msg_id)]):
|
||||
continue
|
||||
|
||||
self._import_fax_from_rc(msg, config, direction.lower())
|
||||
imported += 1
|
||||
|
||||
params = None
|
||||
nav = data.get('navigation', {}) if isinstance(data, dict) else {}
|
||||
next_page = nav.get('nextPage', {}) if isinstance(nav, dict) else {}
|
||||
next_uri = next_page.get('uri', '') if isinstance(next_page, dict) else ''
|
||||
endpoint = next_uri or None
|
||||
|
||||
return imported
|
||||
|
||||
def _import_fax_from_rc(self, msg, config, direction):
|
||||
"""Import a single fax message from RingCentral API response."""
|
||||
try:
|
||||
msg_id = str(msg.get('id', ''))
|
||||
from_info = msg.get('from', {}) or {}
|
||||
to_info = msg.get('to', [{}])
|
||||
if isinstance(to_info, list) and to_info:
|
||||
to_info = to_info[0]
|
||||
elif not isinstance(to_info, dict):
|
||||
to_info = {}
|
||||
|
||||
sender = from_info.get('phoneNumber', '')
|
||||
recipient = to_info.get('phoneNumber', '')
|
||||
creation_time = msg.get('creationTime', '')
|
||||
read_status = msg.get('readStatus', '')
|
||||
page_count = msg.get('faxPageCount', 0)
|
||||
attachments = msg.get('attachments', [])
|
||||
|
||||
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
|
||||
|
||||
fax_number = recipient if direction == 'outbound' else sender
|
||||
search_number = sender if direction == 'inbound' else recipient
|
||||
partner = self._match_fax_partner(
|
||||
fax_number, search_number, direction
|
||||
)
|
||||
|
||||
document_lines = []
|
||||
for att in attachments:
|
||||
att_uri = att.get('uri', '')
|
||||
att_type = att.get('contentType', '')
|
||||
if not att_uri or 'text' in (att_type or ''):
|
||||
continue
|
||||
|
||||
try:
|
||||
time.sleep(RC_RATE_LIMIT_DELAY)
|
||||
resp = config._api_request('GET', att_uri)
|
||||
pdf_content = resp.content if hasattr(resp, 'content') else resp
|
||||
if not pdf_content:
|
||||
continue
|
||||
|
||||
file_ext = 'pdf' if 'pdf' in (att_type or '') else 'bin'
|
||||
file_name = (
|
||||
f'FAX_{"IN" if direction == "inbound" else "OUT"}'
|
||||
f'_{msg_id}.{file_ext}'
|
||||
)
|
||||
|
||||
ir_att = self.env['ir.attachment'].sudo().create({
|
||||
'name': file_name,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(
|
||||
pdf_content
|
||||
if isinstance(pdf_content, bytes)
|
||||
else pdf_content.encode()
|
||||
),
|
||||
'mimetype': att_type or 'application/pdf',
|
||||
'res_model': 'fusion.fax',
|
||||
})
|
||||
document_lines.append((0, 0, {
|
||||
'sequence': 10,
|
||||
'attachment_id': ir_att.id,
|
||||
}))
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"RC Fax Import: Failed to download attachment for %s",
|
||||
msg_id,
|
||||
)
|
||||
|
||||
state = 'received' if direction == 'inbound' else 'sent'
|
||||
|
||||
self.sudo().create({
|
||||
'direction': direction,
|
||||
'state': state,
|
||||
'fax_number': fax_number or 'Unknown',
|
||||
'sender_number': sender if direction == 'inbound' else False,
|
||||
'partner_id': partner.id if partner else False,
|
||||
'ringcentral_message_id': msg_id,
|
||||
'received_date': received_dt if direction == 'inbound' else False,
|
||||
'sent_date': received_dt if direction == 'outbound' else False,
|
||||
'page_count': page_count or 0,
|
||||
'rc_read_status': read_status,
|
||||
'document_ids': document_lines,
|
||||
})
|
||||
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"RC Fax Import: Failed to import fax %s", msg.get('id', '?')
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class RcCallDashboard(models.TransientModel):
|
||||
_name = 'rc.call.dashboard'
|
||||
_description = 'RingCentral Call Dashboard'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(default='Call Dashboard', readonly=True)
|
||||
|
||||
total_count = fields.Integer(compute='_compute_stats')
|
||||
inbound_count = fields.Integer(compute='_compute_stats')
|
||||
outbound_count = fields.Integer(compute='_compute_stats')
|
||||
answered_count = fields.Integer(compute='_compute_stats')
|
||||
missed_count = fields.Integer(compute='_compute_stats')
|
||||
avg_duration = fields.Float(compute='_compute_stats', string='Avg Duration (min)')
|
||||
success_rate = fields.Float(compute='_compute_stats', string='Success Rate (%)')
|
||||
|
||||
today_count = fields.Integer(compute='_compute_time_stats', string='Today')
|
||||
week_count = fields.Integer(compute='_compute_time_stats', string='This Week')
|
||||
month_count = fields.Integer(compute='_compute_time_stats', string='This Month')
|
||||
|
||||
voicemail_count = fields.Integer(compute='_compute_voicemail_stats')
|
||||
unread_voicemail_count = fields.Integer(compute='_compute_voicemail_stats')
|
||||
|
||||
recent_call_ids = fields.Many2many(
|
||||
'rc.call.history',
|
||||
compute='_compute_recent_calls',
|
||||
)
|
||||
|
||||
recent_voicemail_ids = fields.Many2many(
|
||||
'rc.voicemail', 'rc_dashboard_recent_vm_rel',
|
||||
compute='_compute_voicemail_lists',
|
||||
)
|
||||
older_voicemail_ids = fields.Many2many(
|
||||
'rc.voicemail', 'rc_dashboard_older_vm_rel',
|
||||
compute='_compute_voicemail_lists',
|
||||
)
|
||||
|
||||
@api.depends_context('uid')
|
||||
def _compute_stats(self):
|
||||
Call = self.env['rc.call.history']
|
||||
for rec in self:
|
||||
all_calls = Call.search([])
|
||||
rec.total_count = len(all_calls)
|
||||
rec.inbound_count = Call.search_count([('direction', '=', 'inbound')])
|
||||
rec.outbound_count = Call.search_count([('direction', '=', 'outbound')])
|
||||
rec.answered_count = Call.search_count([('status', '=', 'answered')])
|
||||
rec.missed_count = Call.search_count([('status', 'in', ('missed', 'no_answer'))])
|
||||
|
||||
durations = all_calls.mapped('duration')
|
||||
rec.avg_duration = round(sum(durations) / len(durations) / 60, 1) if durations else 0.0
|
||||
rec.success_rate = round(
|
||||
(rec.answered_count / rec.total_count * 100) if rec.total_count else 0.0, 1
|
||||
)
|
||||
|
||||
@api.depends_context('uid')
|
||||
def _compute_time_stats(self):
|
||||
Call = self.env['rc.call.history']
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start = today_start - timedelta(days=today_start.weekday())
|
||||
month_start = today_start.replace(day=1)
|
||||
|
||||
for rec in self:
|
||||
rec.today_count = Call.search_count([('start_time', '>=', today_start)])
|
||||
rec.week_count = Call.search_count([('start_time', '>=', week_start)])
|
||||
rec.month_count = Call.search_count([('start_time', '>=', month_start)])
|
||||
|
||||
@api.depends_context('uid')
|
||||
def _compute_recent_calls(self):
|
||||
for rec in self:
|
||||
rec.recent_call_ids = self.env['rc.call.history'].search([], limit=20)
|
||||
|
||||
@api.depends_context('uid')
|
||||
def _compute_voicemail_lists(self):
|
||||
VM = self.env['rc.voicemail']
|
||||
one_week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
for rec in self:
|
||||
rec.recent_voicemail_ids = VM.search([
|
||||
('received_date', '>=', one_week_ago),
|
||||
], limit=50)
|
||||
rec.older_voicemail_ids = VM.search([
|
||||
('received_date', '<', one_week_ago),
|
||||
], limit=100)
|
||||
|
||||
@api.depends_context('uid')
|
||||
def _compute_voicemail_stats(self):
|
||||
VM = self.env['rc.voicemail']
|
||||
for rec in self:
|
||||
rec.voicemail_count = VM.search_count([])
|
||||
rec.unread_voicemail_count = VM.search_count(
|
||||
[('read_status', '=', 'Unread')],
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Navigation actions
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def action_open_all(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Call History',
|
||||
'res_model': 'rc.call.history',
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
def action_open_inbound(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Inbound Calls',
|
||||
'res_model': 'rc.call.history',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('direction', '=', 'inbound')],
|
||||
}
|
||||
|
||||
def action_open_outbound(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Outbound Calls',
|
||||
'res_model': 'rc.call.history',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('direction', '=', 'outbound')],
|
||||
}
|
||||
|
||||
def action_open_missed(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Missed Calls',
|
||||
'res_model': 'rc.call.history',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('status', 'in', ('missed', 'no_answer'))],
|
||||
}
|
||||
|
||||
def action_open_graph(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Call Analytics',
|
||||
'res_model': 'rc.call.history',
|
||||
'view_mode': 'graph,pivot,list',
|
||||
}
|
||||
|
||||
def action_open_voicemails(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Voicemails',
|
||||
'res_model': 'rc.voicemail',
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
def action_open_unread_voicemails(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Voicemails',
|
||||
'res_model': 'rc.call.dashboard',
|
||||
'view_mode': 'form',
|
||||
'view_id': self.env.ref(
|
||||
'fusion_ringcentral.view_rc_voicemail_dashboard_form',
|
||||
).id,
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Quick action buttons (Call / Message / Fax)
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def action_quick_call(self):
|
||||
"""Open the RingCentral dialer via the Embeddable widget."""
|
||||
return {
|
||||
'type': 'ir.actions.act_window_close',
|
||||
'infos': {
|
||||
'rc_action': 'call',
|
||||
'rc_phone_number': '',
|
||||
},
|
||||
}
|
||||
|
||||
def action_quick_sms(self):
|
||||
"""Open the RingCentral SMS compose via the Embeddable widget."""
|
||||
return {
|
||||
'type': 'ir.actions.act_window_close',
|
||||
'infos': {
|
||||
'rc_action': 'sms',
|
||||
'rc_phone_number': '',
|
||||
},
|
||||
}
|
||||
|
||||
def action_quick_fax(self):
|
||||
"""Open the Send Fax wizard."""
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Send Fax',
|
||||
'res_model': 'fusion_faxes.send.fax.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
488
fusion_ringcentral/fusion_ringcentral/models/rc_call_history.py
Normal file
488
fusion_ringcentral/fusion_ringcentral/models/rc_call_history.py
Normal file
@@ -0,0 +1,488 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RcCallHistory(models.Model):
|
||||
_name = 'rc.call.history'
|
||||
_description = 'RingCentral Call History'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'start_time desc'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: _('New'),
|
||||
)
|
||||
rc_session_id = fields.Char(string='RC Session ID', index=True, readonly=True)
|
||||
direction = fields.Selection([
|
||||
('inbound', 'Inbound'),
|
||||
('outbound', 'Outbound'),
|
||||
], string='Direction', required=True, tracking=True)
|
||||
from_number = fields.Char(string='From Number')
|
||||
to_number = fields.Char(string='To Number')
|
||||
start_time = fields.Datetime(string='Start Time', index=True)
|
||||
duration = fields.Integer(string='Duration (sec)')
|
||||
duration_display = fields.Char(string='Duration', compute='_compute_duration_display')
|
||||
status = fields.Selection([
|
||||
('answered', 'Answered'),
|
||||
('no_answer', 'No Answer'),
|
||||
('busy', 'Busy'),
|
||||
('missed', 'Missed'),
|
||||
('voicemail', 'Voicemail'),
|
||||
('rejected', 'Rejected'),
|
||||
('unknown', 'Unknown'),
|
||||
], string='Status', default='unknown', tracking=True)
|
||||
|
||||
partner_id = fields.Many2one('res.partner', string='Contact', tracking=True)
|
||||
user_id = fields.Many2one('res.users', string='Odoo User', default=lambda self: self.env.user)
|
||||
|
||||
sale_order_count = fields.Integer(string='Sales Orders', compute='_compute_partner_counts')
|
||||
invoice_count = fields.Integer(string='Invoices', compute='_compute_partner_counts')
|
||||
|
||||
has_recording = fields.Boolean(string='Has Recording', default=False)
|
||||
recording_url = fields.Char(string='Recording URL')
|
||||
recording_content_uri = fields.Char(string='Recording Content URI')
|
||||
|
||||
has_transcript = fields.Boolean(string='Has Transcript', default=False)
|
||||
transcript_text = fields.Text(string='Transcript')
|
||||
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order', ondelete='set null')
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
@api.depends('partner_id')
|
||||
def _compute_partner_counts(self):
|
||||
for rec in self:
|
||||
if rec.partner_id:
|
||||
partner = rec.partner_id.commercial_partner_id or rec.partner_id
|
||||
partner_ids = (partner | partner.child_ids).ids
|
||||
rec.sale_order_count = self.env['sale.order'].search_count(
|
||||
[('partner_id', 'in', partner_ids)]
|
||||
)
|
||||
rec.invoice_count = self.env['account.move'].search_count(
|
||||
[('partner_id', 'in', partner_ids),
|
||||
('move_type', 'in', ('out_invoice', 'out_refund'))]
|
||||
)
|
||||
else:
|
||||
rec.sale_order_count = 0
|
||||
rec.invoice_count = 0
|
||||
|
||||
@api.depends('duration')
|
||||
def _compute_duration_display(self):
|
||||
for rec in self:
|
||||
if rec.duration:
|
||||
minutes, seconds = divmod(rec.duration, 60)
|
||||
rec.duration_display = f'{minutes}m {seconds}s' if minutes else f'{seconds}s'
|
||||
else:
|
||||
rec.duration_display = '0s'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('rc.call.history') or _('New')
|
||||
if not vals.get('partner_id'):
|
||||
partner = self._match_partner(
|
||||
vals.get('from_number', ''),
|
||||
vals.get('to_number', ''),
|
||||
vals.get('direction', ''),
|
||||
)
|
||||
if partner:
|
||||
vals['partner_id'] = partner.id
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.model
|
||||
def _match_partner(self, from_number, to_number, direction):
|
||||
"""Auto-link a call to a partner by matching phone numbers.
|
||||
|
||||
For outbound calls, match the to_number (who we called).
|
||||
For inbound calls, match the from_number (who called us).
|
||||
Compares last 10 digits to handle +1, (905), etc.
|
||||
"""
|
||||
search_number = to_number if direction == 'outbound' else from_number
|
||||
if not search_number:
|
||||
return False
|
||||
|
||||
cleaned = self._normalize_phone(search_number)
|
||||
if not cleaned or len(cleaned) < 7:
|
||||
return False
|
||||
|
||||
Partner = self.env['res.partner']
|
||||
phone_fields = [f for f in ('phone', 'mobile') if f in Partner._fields]
|
||||
if not phone_fields:
|
||||
return False
|
||||
|
||||
domain = ['|'] * (len(phone_fields) - 1)
|
||||
for f in phone_fields:
|
||||
domain.append((f, '!=', False))
|
||||
partners = Partner.search(domain, order='customer_rank desc, write_date desc')
|
||||
|
||||
for p in partners:
|
||||
for f in phone_fields:
|
||||
val = p[f]
|
||||
if val and self._normalize_phone(val) == cleaned:
|
||||
return p
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_phone(number):
|
||||
"""Strip a phone number to last 10 digits for comparison."""
|
||||
return re.sub(r'\D', '', number or '')[-10:] if number else ''
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Smart button actions
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def action_view_contact(self):
|
||||
"""Open the linked contact."""
|
||||
self.ensure_one()
|
||||
if not self.partner_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'res.partner',
|
||||
'res_id': self.partner_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_sale_orders(self):
|
||||
"""Open sale orders for the linked contact."""
|
||||
self.ensure_one()
|
||||
if not self.partner_id:
|
||||
return
|
||||
partner = self.partner_id.commercial_partner_id or self.partner_id
|
||||
partner_ids = (partner | partner.child_ids).ids
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Sales Orders - %s') % self.partner_id.name,
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('partner_id', 'in', partner_ids)],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_invoices(self):
|
||||
"""Open invoices for the linked contact."""
|
||||
self.ensure_one()
|
||||
if not self.partner_id:
|
||||
return
|
||||
partner = self.partner_id.commercial_partner_id or self.partner_id
|
||||
partner_ids = (partner | partner.child_ids).ids
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Invoices - %s') % self.partner_id.name,
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('partner_id', 'in', partner_ids),
|
||||
('move_type', 'in', ('out_invoice', 'out_refund'))],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def _get_contact_number(self):
|
||||
"""Return the external phone number for this call."""
|
||||
self.ensure_one()
|
||||
if self.direction == 'outbound':
|
||||
return self.to_number or self.from_number
|
||||
return self.from_number or self.to_number
|
||||
|
||||
def action_make_call(self):
|
||||
"""Trigger a call via the RingCentral Embeddable widget (handled by JS)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window_close',
|
||||
'infos': {
|
||||
'rc_action': 'call',
|
||||
'rc_phone_number': self._get_contact_number(),
|
||||
},
|
||||
}
|
||||
|
||||
def action_send_sms(self):
|
||||
"""Open the RingCentral Embeddable SMS compose (handled by JS)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window_close',
|
||||
'infos': {
|
||||
'rc_action': 'sms',
|
||||
'rc_phone_number': self._get_contact_number(),
|
||||
},
|
||||
}
|
||||
|
||||
def action_send_fax(self):
|
||||
"""Open the Send Fax wizard pre-filled with this call's contact."""
|
||||
self.ensure_one()
|
||||
ctx = {}
|
||||
if self.partner_id:
|
||||
ctx['default_partner_id'] = self.partner_id.id
|
||||
fax_num = getattr(self.partner_id, 'x_ff_fax_number', '')
|
||||
if fax_num:
|
||||
ctx['default_fax_number'] = fax_num
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Send Fax'),
|
||||
'res_model': 'fusion_faxes.send.fax.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': ctx,
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Recording playback
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def action_play_recording(self):
|
||||
"""Open the recording proxy URL in a new window."""
|
||||
self.ensure_one()
|
||||
if not self.has_recording:
|
||||
return
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'{base_url}/ringcentral/recording/{self.id}',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Cron: sync call history from RingCentral
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _cron_sync_call_history(self):
|
||||
"""Incremental sync: only fetch calls since last successful sync.
|
||||
|
||||
Uses `fusion_rc.last_call_sync` to know where to start.
|
||||
Only queries RingCentral for new records, skips everything
|
||||
already imported. Runs every 15 min by default.
|
||||
"""
|
||||
config = self.env['rc.config']._get_active_config()
|
||||
if not config:
|
||||
return
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
last_sync = ICP.get_param('fusion_rc.last_call_sync', '')
|
||||
|
||||
if not last_sync:
|
||||
two_days_ago = datetime.utcnow() - timedelta(days=2)
|
||||
last_sync = two_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
|
||||
now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
total_imported = self._sync_calls_from_date(config, last_sync, now_str)
|
||||
|
||||
ICP.set_param('fusion_rc.last_call_sync', now_str)
|
||||
|
||||
if total_imported:
|
||||
_logger.info(
|
||||
"RC Incremental Sync: %d new calls imported (from %s).",
|
||||
total_imported, last_sync[:16],
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_daily_catchup_sync(self):
|
||||
"""Daily catchup: re-scan the past 3 days to catch anything missed.
|
||||
|
||||
Covers calls made from physical phones, mobile app, or any source
|
||||
outside Odoo. Deduplicates by rc_session_id so no duplicates
|
||||
are created. Runs once per day.
|
||||
"""
|
||||
config = self.env['rc.config']._get_active_config()
|
||||
if not config:
|
||||
return
|
||||
|
||||
three_days_ago = datetime.utcnow() - timedelta(days=3)
|
||||
date_from = three_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
|
||||
total_imported = self._sync_calls_from_date(config, date_from, now_str)
|
||||
_logger.info(
|
||||
"RC Daily Catchup: %d new calls imported (past 3 days).",
|
||||
total_imported,
|
||||
)
|
||||
|
||||
def _sync_calls_from_date(self, config, date_from, date_to=None):
|
||||
"""Fetch call records from RingCentral for a date range.
|
||||
|
||||
Skips records whose rc_session_id already exists in our DB
|
||||
(checked via a pre-loaded set, not per-record SQL queries).
|
||||
Returns the total number of newly imported records.
|
||||
"""
|
||||
total_imported = 0
|
||||
total_skipped = 0
|
||||
try:
|
||||
params = {
|
||||
'dateFrom': date_from,
|
||||
'type': 'Voice',
|
||||
'view': 'Detailed',
|
||||
'perPage': 250,
|
||||
}
|
||||
if date_to:
|
||||
params['dateTo'] = date_to
|
||||
|
||||
data = config._api_get(
|
||||
'/restapi/v1.0/account/~/extension/~/call-log', params=params,
|
||||
)
|
||||
imported, skipped = self._process_call_page(data)
|
||||
total_imported += imported
|
||||
total_skipped += skipped
|
||||
|
||||
while data.get('navigation', {}).get('nextPage', {}).get('uri'):
|
||||
next_uri = data['navigation']['nextPage']['uri']
|
||||
data = config._api_get(next_uri)
|
||||
imported, skipped = self._process_call_page(data)
|
||||
total_imported += imported
|
||||
total_skipped += skipped
|
||||
|
||||
except Exception:
|
||||
_logger.exception("Fusion RingCentral: Error syncing call history.")
|
||||
|
||||
if total_skipped:
|
||||
_logger.debug("RC Sync: skipped %d already-imported records.", total_skipped)
|
||||
return total_imported
|
||||
|
||||
def _process_call_page(self, data):
|
||||
"""Process a single page of call log records.
|
||||
|
||||
Uses a bulk session_id lookup (one SQL query per page)
|
||||
instead of per-record queries.
|
||||
Returns (imported_count, skipped_count).
|
||||
"""
|
||||
records = data.get('records', [])
|
||||
if not records:
|
||||
return 0, 0
|
||||
|
||||
page_session_ids = [
|
||||
r.get('sessionId', '') for r in records if r.get('sessionId')
|
||||
]
|
||||
|
||||
if page_session_ids:
|
||||
existing = set(
|
||||
r.rc_session_id
|
||||
for r in self.search([('rc_session_id', 'in', page_session_ids)])
|
||||
)
|
||||
else:
|
||||
existing = set()
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
for rec in records:
|
||||
session_id = rec.get('sessionId', '')
|
||||
if not session_id:
|
||||
continue
|
||||
if session_id in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
self._import_call_record(rec)
|
||||
existing.add(session_id)
|
||||
imported += 1
|
||||
|
||||
return imported, skipped
|
||||
|
||||
def _import_call_record(self, rec):
|
||||
"""Import a single call log record from the API."""
|
||||
try:
|
||||
direction_raw = rec.get('direction', '').lower()
|
||||
direction = 'inbound' if direction_raw == 'inbound' else 'outbound'
|
||||
|
||||
from_info = rec.get('from', {})
|
||||
to_info = rec.get('to', {})
|
||||
from_number = from_info.get('phoneNumber', '') or from_info.get('name', '')
|
||||
to_number = to_info.get('phoneNumber', '') or to_info.get('name', '')
|
||||
|
||||
start_time = False
|
||||
raw_time = rec.get('startTime', '')
|
||||
if raw_time:
|
||||
try:
|
||||
clean_time = raw_time.replace('Z', '+00:00')
|
||||
start_time = datetime.fromisoformat(clean_time).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
result = rec.get('result', 'Unknown')
|
||||
status_map = {
|
||||
'Accepted': 'answered',
|
||||
'Call connected': 'answered',
|
||||
'Voicemail': 'voicemail',
|
||||
'Missed': 'missed',
|
||||
'No Answer': 'no_answer',
|
||||
'Busy': 'busy',
|
||||
'Rejected': 'rejected',
|
||||
'Hang Up': 'answered',
|
||||
'Reply': 'answered',
|
||||
}
|
||||
status = status_map.get(result, 'unknown')
|
||||
|
||||
recording = rec.get('recording', {})
|
||||
has_recording = bool(recording)
|
||||
recording_content_uri = recording.get('contentUri', '') if recording else ''
|
||||
|
||||
self.sudo().create({
|
||||
'rc_session_id': rec.get('sessionId', ''),
|
||||
'direction': direction,
|
||||
'from_number': from_number,
|
||||
'to_number': to_number,
|
||||
'start_time': start_time,
|
||||
'duration': rec.get('duration', 0),
|
||||
'status': status,
|
||||
'has_recording': has_recording,
|
||||
'recording_content_uri': recording_content_uri,
|
||||
})
|
||||
except Exception:
|
||||
_logger.exception("Failed to import call record: %s", rec.get('sessionId', ''))
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Cron: fetch transcripts
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _cron_fetch_transcripts(self):
|
||||
"""Fetch AI transcripts for calls that have recordings but no transcript."""
|
||||
config = self.env['rc.config']._get_active_config()
|
||||
if not config:
|
||||
return
|
||||
|
||||
calls = self.search([
|
||||
('has_recording', '=', True),
|
||||
('has_transcript', '=', False),
|
||||
('recording_content_uri', '!=', False),
|
||||
], limit=20)
|
||||
|
||||
for call in calls:
|
||||
try:
|
||||
content_uri = call.recording_content_uri
|
||||
if not content_uri:
|
||||
continue
|
||||
|
||||
media_url = f'{content_uri}?access_token={config.access_token}'
|
||||
data = config._api_post(
|
||||
'/ai/speech-to-text/v1/async?webhook=false',
|
||||
data={
|
||||
'contentUri': media_url,
|
||||
'source': 'CallRecording',
|
||||
},
|
||||
)
|
||||
|
||||
transcript = ''
|
||||
segments = data.get('utterances', [])
|
||||
for seg in segments:
|
||||
speaker = seg.get('speakerName', 'Speaker')
|
||||
text = seg.get('text', '')
|
||||
transcript += f'{speaker}: {text}\n'
|
||||
|
||||
if transcript:
|
||||
call.write({
|
||||
'has_transcript': True,
|
||||
'transcript_text': transcript.strip(),
|
||||
})
|
||||
except Exception:
|
||||
_logger.debug("Transcript not available yet for call %s", call.name)
|
||||
643
fusion_ringcentral/fusion_ringcentral/models/rc_config.py
Normal file
643
fusion_ringcentral/fusion_ringcentral/models/rc_config.py
Normal file
@@ -0,0 +1,643 @@
|
||||
# -*- 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_import_historical_faxes(self):
|
||||
"""Trigger historical fax import in the background via cron."""
|
||||
self.ensure_one()
|
||||
self._ensure_token()
|
||||
|
||||
cron = self.env.ref(
|
||||
'fusion_ringcentral.cron_rc_historical_fax_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 Fax Import',
|
||||
'cron_name': 'RingCentral: Historical Fax Import',
|
||||
'model_id': self.env['ir.model']._get('fusion.fax').id,
|
||||
'state': 'code',
|
||||
'code': 'model._run_historical_fax_import()',
|
||||
'interval_number': 9999,
|
||||
'interval_type': 'days',
|
||||
'nextcall': fields.Datetime.now(),
|
||||
'active': True,
|
||||
})
|
||||
|
||||
return self._notify(
|
||||
'Fax Import Started',
|
||||
'Historical fax import (12 months) is running in the background. '
|
||||
'Check Fusion Connect > Faxes in a few minutes.',
|
||||
'info',
|
||||
)
|
||||
|
||||
def action_backfill_voicemail_media(self):
|
||||
"""Re-download audio and transcriptions for voicemails missing them."""
|
||||
self.ensure_one()
|
||||
self._ensure_token()
|
||||
|
||||
cron = self.env.ref(
|
||||
'fusion_ringcentral.cron_rc_backfill_vm_media',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if cron:
|
||||
cron.sudo().write({
|
||||
'active': True,
|
||||
'nextcall': fields.Datetime.now(),
|
||||
})
|
||||
else:
|
||||
self.env['ir.cron'].sudo().create({
|
||||
'name': 'RingCentral: Backfill Voicemail Media',
|
||||
'cron_name': 'RingCentral: Backfill Voicemail Media',
|
||||
'model_id': self.env['ir.model']._get('rc.voicemail').id,
|
||||
'state': 'code',
|
||||
'code': 'model._run_backfill_voicemail_media()',
|
||||
'interval_number': 9999,
|
||||
'interval_type': 'days',
|
||||
'nextcall': fields.Datetime.now(),
|
||||
'active': True,
|
||||
})
|
||||
|
||||
return self._notify(
|
||||
'Voicemail Media Backfill Started',
|
||||
'Downloading audio and transcriptions for voicemails that are missing them. '
|
||||
'This runs in the background with rate-limit pacing.',
|
||||
'info',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _run_historical_import(self):
|
||||
"""Background job: import up to 12 months of call history.
|
||||
|
||||
Tracks which monthly chunks completed successfully via
|
||||
ir.config_parameter so it never re-queries months that
|
||||
already finished. Only fetches chunks that failed or
|
||||
haven't been attempted yet.
|
||||
"""
|
||||
config = self._get_active_config()
|
||||
if not config:
|
||||
_logger.warning("RC Historical Import: No connected config found.")
|
||||
return
|
||||
|
||||
try:
|
||||
config._ensure_token()
|
||||
except Exception:
|
||||
_logger.exception("RC Historical Import: Token invalid, aborting.")
|
||||
return
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
CallHistory = self.env['rc.call.history']
|
||||
now = datetime.utcnow()
|
||||
total_imported = 0
|
||||
total_skipped_chunks = 0
|
||||
failed_chunks = 0
|
||||
|
||||
for months_back in range(12, 0, -1):
|
||||
chunk_start = now - timedelta(days=months_back * 30)
|
||||
chunk_end = now - timedelta(days=(months_back - 1) * 30)
|
||||
chunk_num = 13 - months_back
|
||||
|
||||
date_from = chunk_start.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
date_to = chunk_end.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
|
||||
chunk_key = f'fusion_rc.import_done_{chunk_start.strftime("%Y%m")}'
|
||||
already_done = ICP.get_param(chunk_key, '')
|
||||
if already_done:
|
||||
_logger.info(
|
||||
"RC Import [%d/12]: %s to %s -- already imported, skipping.",
|
||||
chunk_num, date_from[:10], date_to[:10],
|
||||
)
|
||||
total_skipped_chunks += 1
|
||||
continue
|
||||
|
||||
_logger.info(
|
||||
"RC Import [%d/12]: %s to %s ...",
|
||||
chunk_num, date_from[:10], date_to[:10],
|
||||
)
|
||||
try:
|
||||
chunk_count = CallHistory._sync_calls_from_date(config, date_from, date_to)
|
||||
total_imported += chunk_count
|
||||
ICP.set_param(chunk_key, fields.Datetime.now().isoformat())
|
||||
_logger.info(
|
||||
"RC Import [%d/12]: %d calls imported.",
|
||||
chunk_num, chunk_count,
|
||||
)
|
||||
except Exception:
|
||||
failed_chunks += 1
|
||||
_logger.exception(
|
||||
"RC Import [%d/12]: Failed, will retry next run.",
|
||||
chunk_num,
|
||||
)
|
||||
|
||||
ICP.set_param(
|
||||
'fusion_rc.last_call_sync',
|
||||
now.strftime('%Y-%m-%dT%H:%M:%S.000Z'),
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"RC Historical Import complete: %d imported, %d chunks skipped (already done), %d failed.",
|
||||
total_imported, total_skipped_chunks, failed_chunks,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Cron: token refresh
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _cron_refresh_tokens(self):
|
||||
"""Refresh tokens for all connected configs nearing expiry."""
|
||||
soon = fields.Datetime.now() + timedelta(minutes=10)
|
||||
configs = self.search([
|
||||
('state', '=', 'connected'),
|
||||
('token_expiry', '<=', soon),
|
||||
('refresh_token', '!=', False),
|
||||
])
|
||||
for cfg in configs:
|
||||
try:
|
||||
cfg._refresh_token()
|
||||
except Exception:
|
||||
_logger.exception("Cron: Failed to refresh token for config %s", cfg.name)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Cron: webhook renewal
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _cron_renew_webhooks(self):
|
||||
"""Renew webhook subscriptions for all connected configs."""
|
||||
configs = self.search([('state', '=', 'connected')])
|
||||
for cfg in configs:
|
||||
try:
|
||||
cfg._renew_webhook_subscription()
|
||||
except Exception:
|
||||
_logger.exception("Cron: Failed to renew webhook for config %s", cfg.name)
|
||||
849
fusion_ringcentral/fusion_ringcentral/models/rc_voicemail.py
Normal file
849
fusion_ringcentral/fusion_ringcentral/models/rc_voicemail.py
Normal file
@@ -0,0 +1,849 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
import io
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests as py_requests
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
RC_MEDIA_DELAY = 10
|
||||
RC_MEDIA_RETRY_WAIT = 65
|
||||
RC_MEDIA_MAX_RETRIES = 5
|
||||
|
||||
OPENAI_WHISPER_URL = 'https://api.openai.com/v1/audio/transcriptions'
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RcVoicemail(models.Model):
|
||||
_name = 'rc.voicemail'
|
||||
_description = 'RingCentral Voicemail'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'received_date desc'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference', required=True, copy=False, readonly=True,
|
||||
default=lambda self: _('New'),
|
||||
)
|
||||
rc_message_id = fields.Char(
|
||||
string='RC Message ID', readonly=True, copy=False, index=True,
|
||||
)
|
||||
caller_number = fields.Char(string='Caller Number', readonly=True)
|
||||
caller_name = fields.Char(string='Caller Name', readonly=True)
|
||||
direction = fields.Selection([
|
||||
('inbound', 'Inbound'),
|
||||
('outbound', 'Outbound'),
|
||||
], string='Direction', default='inbound', readonly=True)
|
||||
|
||||
received_date = fields.Datetime(string='Received At', readonly=True)
|
||||
duration = fields.Integer(string='Duration (sec)', readonly=True)
|
||||
duration_display = fields.Char(
|
||||
string='Duration', compute='_compute_duration_display',
|
||||
)
|
||||
|
||||
read_status = fields.Selection([
|
||||
('Read', 'Read'),
|
||||
('Unread', 'Unread'),
|
||||
], string='Read Status', readonly=True)
|
||||
|
||||
transcription_status = fields.Char(
|
||||
string='Transcription Status', readonly=True,
|
||||
)
|
||||
transcription_text = fields.Text(string='Transcription', readonly=True)
|
||||
transcription_timeline = fields.Text(
|
||||
string='Timeline', readonly=True,
|
||||
help='Timestamped transcription with silence gaps.',
|
||||
)
|
||||
transcription_language = fields.Char(
|
||||
string='Language', readonly=True,
|
||||
)
|
||||
has_transcription = fields.Boolean(
|
||||
compute='_compute_has_transcription', store=True,
|
||||
)
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Contact', tracking=True,
|
||||
)
|
||||
audio_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='Audio File', readonly=True,
|
||||
)
|
||||
|
||||
sale_order_count = fields.Integer(compute='_compute_partner_counts')
|
||||
invoice_count = fields.Integer(compute='_compute_partner_counts')
|
||||
|
||||
@api.depends('duration')
|
||||
def _compute_duration_display(self):
|
||||
for rec in self:
|
||||
if rec.duration:
|
||||
mins, secs = divmod(rec.duration, 60)
|
||||
rec.duration_display = f'{mins}m {secs}s' if mins else f'{secs}s'
|
||||
else:
|
||||
rec.duration_display = ''
|
||||
|
||||
@api.depends('transcription_text')
|
||||
def _compute_has_transcription(self):
|
||||
for rec in self:
|
||||
rec.has_transcription = bool(rec.transcription_text)
|
||||
|
||||
@api.depends('partner_id')
|
||||
def _compute_partner_counts(self):
|
||||
for rec in self:
|
||||
if rec.partner_id:
|
||||
partner = rec.partner_id.commercial_partner_id or rec.partner_id
|
||||
partner_ids = (partner | partner.child_ids).ids
|
||||
rec.sale_order_count = self.env['sale.order'].search_count(
|
||||
[('partner_id', 'in', partner_ids)],
|
||||
)
|
||||
rec.invoice_count = self.env['account.move'].search_count(
|
||||
[('partner_id', 'in', partner_ids),
|
||||
('move_type', 'in', ('out_invoice', 'out_refund'))],
|
||||
)
|
||||
else:
|
||||
rec.sale_order_count = 0
|
||||
rec.invoice_count = 0
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = (
|
||||
self.env['ir.sequence'].next_by_code('rc.voicemail')
|
||||
or _('New')
|
||||
)
|
||||
if not vals.get('partner_id') and vals.get('caller_number'):
|
||||
partner = self._match_voicemail_partner(vals['caller_number'])
|
||||
if partner:
|
||||
vals['partner_id'] = partner.id
|
||||
return super().create(vals_list)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Contact matching
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _match_voicemail_partner(self, number):
|
||||
if not number:
|
||||
return False
|
||||
cleaned = self._normalize_phone(number)
|
||||
if not cleaned or len(cleaned) < 7:
|
||||
return False
|
||||
|
||||
Partner = self.env['res.partner']
|
||||
phone_fields = [f for f in ('phone', 'mobile') if f in Partner._fields]
|
||||
if not phone_fields:
|
||||
return False
|
||||
|
||||
domain = ['|'] * (len(phone_fields) - 1)
|
||||
for f in phone_fields:
|
||||
domain.append((f, '!=', False))
|
||||
partners = Partner.search(domain, order='customer_rank desc, write_date desc')
|
||||
|
||||
for p in partners:
|
||||
for f in phone_fields:
|
||||
val = p[f]
|
||||
if val and self._normalize_phone(val) == cleaned:
|
||||
return p
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_phone(number):
|
||||
return re.sub(r'\D', '', number or '')[-10:] if number else ''
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Smart button actions
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def action_view_contact(self):
|
||||
self.ensure_one()
|
||||
if not self.partner_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'res.partner',
|
||||
'res_id': self.partner_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_sale_orders(self):
|
||||
self.ensure_one()
|
||||
if not self.partner_id:
|
||||
return
|
||||
partner = self.partner_id.commercial_partner_id or self.partner_id
|
||||
partner_ids = (partner | partner.child_ids).ids
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Sales Orders - %s') % self.partner_id.name,
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('partner_id', 'in', partner_ids)],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_invoices(self):
|
||||
self.ensure_one()
|
||||
if not self.partner_id:
|
||||
return
|
||||
partner = self.partner_id.commercial_partner_id or self.partner_id
|
||||
partner_ids = (partner | partner.child_ids).ids
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Invoices - %s') % self.partner_id.name,
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('partner_id', 'in', partner_ids),
|
||||
('move_type', 'in', ('out_invoice', 'out_refund'))],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Call / Fax buttons (stay on same page via act_window_close)
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def action_make_call(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window_close',
|
||||
'infos': {
|
||||
'rc_action': 'call',
|
||||
'rc_phone_number': self.caller_number or '',
|
||||
},
|
||||
}
|
||||
|
||||
def action_send_sms(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window_close',
|
||||
'infos': {
|
||||
'rc_action': 'sms',
|
||||
'rc_phone_number': self.caller_number or '',
|
||||
},
|
||||
}
|
||||
|
||||
def action_send_fax(self):
|
||||
self.ensure_one()
|
||||
ctx = {}
|
||||
if self.partner_id:
|
||||
ctx['default_partner_id'] = self.partner_id.id
|
||||
fax_num = getattr(self.partner_id, 'x_ff_fax_number', '')
|
||||
if fax_num:
|
||||
ctx['default_fax_number'] = fax_num
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Send Fax'),
|
||||
'res_model': 'fusion_faxes.send.fax.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': ctx,
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Cron: incremental voicemail sync
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _cron_sync_voicemails(self):
|
||||
"""Incremental sync: fetch voicemails since last sync."""
|
||||
config = self.env['rc.config']._get_active_config()
|
||||
if not config:
|
||||
return
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
last_sync = ICP.get_param('fusion_rc.last_voicemail_sync', '')
|
||||
if not last_sync:
|
||||
two_days_ago = datetime.utcnow() - timedelta(days=2)
|
||||
last_sync = two_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
|
||||
now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
total = self._sync_voicemails_from_date(config, last_sync, now_str)
|
||||
ICP.set_param('fusion_rc.last_voicemail_sync', now_str)
|
||||
|
||||
if total:
|
||||
_logger.info("RC Voicemail Sync: %d new voicemails imported.", total)
|
||||
|
||||
@api.model
|
||||
def _cron_daily_voicemail_catchup(self):
|
||||
"""Re-scan past 3 days for any missed voicemails."""
|
||||
config = self.env['rc.config']._get_active_config()
|
||||
if not config:
|
||||
return
|
||||
|
||||
three_days_ago = datetime.utcnow() - timedelta(days=3)
|
||||
date_from = three_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
|
||||
total = self._sync_voicemails_from_date(config, date_from, now_str)
|
||||
_logger.info("RC Voicemail Catchup: %d new voicemails (past 3 days).", total)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Backfill: re-download audio/transcripts for existing records
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _run_backfill_voicemail_media(self):
|
||||
"""Re-fetch audio and transcription for records missing them.
|
||||
|
||||
Called via cron after the initial import that missed audio due
|
||||
to rate limiting. Queries the RC API for each voicemail's
|
||||
message data and downloads any missing media.
|
||||
"""
|
||||
config = self.env['rc.config']._get_active_config()
|
||||
if not config:
|
||||
_logger.warning("RC VM Backfill: No connected config.")
|
||||
return
|
||||
|
||||
try:
|
||||
config._ensure_token()
|
||||
except Exception:
|
||||
_logger.exception("RC VM Backfill: Token invalid.")
|
||||
return
|
||||
|
||||
missing = self.search([
|
||||
('audio_attachment_id', '=', False),
|
||||
('rc_message_id', '!=', False),
|
||||
], order='received_date asc')
|
||||
|
||||
_logger.info("RC VM Backfill: %d voicemails need audio.", len(missing))
|
||||
success = 0
|
||||
for vm in missing:
|
||||
try:
|
||||
data = config._api_get(
|
||||
f'/restapi/v1.0/account/~/extension/~/message-store/{vm.rc_message_id}',
|
||||
)
|
||||
attachments = data.get('attachments', [])
|
||||
audio_uri = ''
|
||||
audio_content_type = 'audio/x-wav'
|
||||
transcript_uri = ''
|
||||
for att in attachments:
|
||||
att_type = att.get('type', '')
|
||||
if att_type == 'AudioRecording':
|
||||
audio_uri = att.get('uri', '')
|
||||
audio_content_type = att.get('contentType', 'audio/x-wav')
|
||||
elif att_type == 'AudioTranscription':
|
||||
transcript_uri = att.get('uri', '')
|
||||
|
||||
if not vm.transcription_text and transcript_uri:
|
||||
text = self._download_transcription(transcript_uri, config)
|
||||
if text:
|
||||
vm.sudo().write({'transcription_text': text})
|
||||
|
||||
if audio_uri:
|
||||
self._download_and_attach_audio(
|
||||
vm, audio_uri, audio_content_type, config,
|
||||
)
|
||||
success += 1
|
||||
|
||||
if not vm.transcription_text and vm.audio_attachment_id:
|
||||
self._try_openai_transcribe(vm)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"RC VM Backfill: Failed for %s (msg %s).",
|
||||
vm.name, vm.rc_message_id,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"RC VM Backfill complete: %d/%d audio files downloaded.",
|
||||
success, len(missing),
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Historical import (all available voicemails, 12 months)
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _run_historical_voicemail_import(self):
|
||||
"""Background job: import up to 12 months of voicemails in monthly chunks.
|
||||
|
||||
Tracks which monthly chunks completed successfully via
|
||||
ir.config_parameter so it never re-queries months already done.
|
||||
Rate-limit safe: _api_get already paces at ~6 req/min with
|
||||
auto-retry on 429.
|
||||
"""
|
||||
config = self.env['rc.config']._get_active_config()
|
||||
if not config:
|
||||
_logger.warning("RC VM Historical Import: No connected config.")
|
||||
return
|
||||
|
||||
try:
|
||||
config._ensure_token()
|
||||
except Exception:
|
||||
_logger.exception("RC VM Historical Import: Token invalid.")
|
||||
return
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
now = datetime.utcnow()
|
||||
total_imported = 0
|
||||
total_skipped = 0
|
||||
failed_chunks = 0
|
||||
|
||||
for months_back in range(12, 0, -1):
|
||||
chunk_start = now - timedelta(days=months_back * 30)
|
||||
chunk_end = now - timedelta(days=(months_back - 1) * 30)
|
||||
chunk_num = 13 - months_back
|
||||
|
||||
date_from = chunk_start.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
date_to = chunk_end.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
|
||||
chunk_key = (
|
||||
f'fusion_rc.vm_import_done_{chunk_start.strftime("%Y%m")}'
|
||||
)
|
||||
if ICP.get_param(chunk_key, ''):
|
||||
_logger.info(
|
||||
"RC VM Import [%d/12]: %s to %s -- already done, skipping.",
|
||||
chunk_num, date_from[:10], date_to[:10],
|
||||
)
|
||||
total_skipped += 1
|
||||
continue
|
||||
|
||||
_logger.info(
|
||||
"RC VM Import [%d/12]: %s to %s ...",
|
||||
chunk_num, date_from[:10], date_to[:10],
|
||||
)
|
||||
try:
|
||||
chunk_count = self._sync_voicemails_from_date(
|
||||
config, date_from, date_to,
|
||||
)
|
||||
total_imported += chunk_count
|
||||
ICP.set_param(chunk_key, fields.Datetime.now().isoformat())
|
||||
_logger.info(
|
||||
"RC VM Import [%d/12]: %d voicemails imported.",
|
||||
chunk_num, chunk_count,
|
||||
)
|
||||
except Exception:
|
||||
failed_chunks += 1
|
||||
_logger.exception(
|
||||
"RC VM Import [%d/12]: Failed, will retry next run.",
|
||||
chunk_num,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"RC VM Historical Import complete: %d imported, %d skipped, %d failed.",
|
||||
total_imported, total_skipped, failed_chunks,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Core sync logic
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def _sync_voicemails_from_date(self, config, date_from, date_to=None):
|
||||
"""Fetch voicemails from RC Message Store for a date range."""
|
||||
total_imported = 0
|
||||
try:
|
||||
params = {
|
||||
'messageType': 'VoiceMail',
|
||||
'dateFrom': date_from,
|
||||
'perPage': 100,
|
||||
}
|
||||
if date_to:
|
||||
params['dateTo'] = date_to
|
||||
|
||||
data = config._api_get(
|
||||
'/restapi/v1.0/account/~/extension/~/message-store',
|
||||
params=params,
|
||||
)
|
||||
total_imported += self._process_voicemail_page(data, config)
|
||||
|
||||
nav = data.get('navigation', {})
|
||||
while nav.get('nextPage', {}).get('uri'):
|
||||
next_uri = nav['nextPage']['uri']
|
||||
data = config._api_get(next_uri)
|
||||
total_imported += self._process_voicemail_page(data, config)
|
||||
nav = data.get('navigation', {})
|
||||
|
||||
except Exception:
|
||||
_logger.exception("RC Voicemail: Error syncing voicemails.")
|
||||
return total_imported
|
||||
|
||||
def _process_voicemail_page(self, data, config):
|
||||
"""Process one page of voicemail records with bulk dedup."""
|
||||
records = data.get('records', [])
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
page_ids = [str(r.get('id', '')) for r in records if r.get('id')]
|
||||
if page_ids:
|
||||
existing = set(
|
||||
r.rc_message_id
|
||||
for r in self.search([('rc_message_id', 'in', page_ids)])
|
||||
)
|
||||
else:
|
||||
existing = set()
|
||||
|
||||
imported = 0
|
||||
for msg in records:
|
||||
msg_id = str(msg.get('id', ''))
|
||||
if not msg_id or msg_id in existing:
|
||||
continue
|
||||
if self._import_voicemail(msg, config):
|
||||
existing.add(msg_id)
|
||||
imported += 1
|
||||
return imported
|
||||
|
||||
def _import_voicemail(self, msg, config):
|
||||
"""Import a single voicemail message and download its audio."""
|
||||
try:
|
||||
msg_id = str(msg.get('id', ''))
|
||||
from_info = msg.get('from', {})
|
||||
caller_number = from_info.get('phoneNumber', '')
|
||||
caller_name = from_info.get('name', '')
|
||||
|
||||
creation_time = msg.get('creationTime', '')
|
||||
received_dt = False
|
||||
if creation_time:
|
||||
try:
|
||||
clean = creation_time.replace('Z', '+00:00')
|
||||
received_dt = datetime.fromisoformat(clean).strftime(
|
||||
'%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
direction = (msg.get('direction', '') or '').lower()
|
||||
if direction not in ('inbound', 'outbound'):
|
||||
direction = 'inbound'
|
||||
|
||||
attachments = msg.get('attachments', [])
|
||||
vm_duration = 0
|
||||
audio_uri = ''
|
||||
audio_content_type = 'audio/x-wav'
|
||||
transcript_uri = ''
|
||||
for att in attachments:
|
||||
att_type = att.get('type', '')
|
||||
if att_type == 'AudioRecording':
|
||||
vm_duration = att.get('vmDuration', 0)
|
||||
audio_uri = att.get('uri', '')
|
||||
audio_content_type = att.get('contentType', 'audio/x-wav')
|
||||
elif att_type == 'AudioTranscription':
|
||||
transcript_uri = att.get('uri', '')
|
||||
|
||||
transcription_text = ''
|
||||
if transcript_uri:
|
||||
transcription_text = self._download_transcription(
|
||||
transcript_uri, config,
|
||||
)
|
||||
|
||||
partner = self._match_voicemail_partner(caller_number)
|
||||
|
||||
vm = self.sudo().create({
|
||||
'rc_message_id': msg_id,
|
||||
'caller_number': caller_number,
|
||||
'caller_name': caller_name,
|
||||
'direction': direction,
|
||||
'received_date': received_dt,
|
||||
'duration': vm_duration,
|
||||
'read_status': msg.get('readStatus', 'Unread'),
|
||||
'transcription_status': 'Completed' if transcription_text else '',
|
||||
'transcription_text': transcription_text,
|
||||
'partner_id': partner.id if partner else False,
|
||||
})
|
||||
|
||||
if audio_uri:
|
||||
self._download_and_attach_audio(vm, audio_uri, audio_content_type, config)
|
||||
|
||||
if not vm.transcription_text and vm.audio_attachment_id:
|
||||
self._try_openai_transcribe(vm)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"RC Voicemail: Failed to import message %s",
|
||||
msg.get('id', ''),
|
||||
)
|
||||
return False
|
||||
|
||||
def _rc_media_get(self, uri, config):
|
||||
"""Download binary content from RC with rate-limit retry."""
|
||||
config._ensure_token()
|
||||
for attempt in range(1, RC_MEDIA_MAX_RETRIES + 1):
|
||||
resp = py_requests.get(
|
||||
uri,
|
||||
headers={'Authorization': f'Bearer {config.access_token}'},
|
||||
timeout=30,
|
||||
verify=config.ssl_verify,
|
||||
proxies=config._get_proxies(),
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
wait = int(resp.headers.get('Retry-After', RC_MEDIA_RETRY_WAIT))
|
||||
_logger.warning(
|
||||
"RC media 429. Waiting %ds (attempt %d/%d)...",
|
||||
wait, attempt, RC_MEDIA_MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
config._ensure_token()
|
||||
continue
|
||||
if resp.status_code == 401 and attempt < RC_MEDIA_MAX_RETRIES:
|
||||
_logger.warning(
|
||||
"RC media 401. Refreshing token, waiting 60s (attempt %d/%d)...",
|
||||
attempt, RC_MEDIA_MAX_RETRIES,
|
||||
)
|
||||
time.sleep(60)
|
||||
try:
|
||||
config._refresh_token()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
time.sleep(RC_MEDIA_DELAY)
|
||||
return resp
|
||||
resp.raise_for_status()
|
||||
|
||||
def _download_transcription(self, transcript_uri, config):
|
||||
"""Download voicemail transcription text from RC."""
|
||||
try:
|
||||
resp = self._rc_media_get(transcript_uri, config)
|
||||
return (resp.text or '').strip()
|
||||
except Exception:
|
||||
_logger.warning("RC Voicemail: Could not fetch transcription.")
|
||||
return ''
|
||||
|
||||
def _download_and_attach_audio(self, vm, audio_uri, content_type, config):
|
||||
"""Download the voicemail audio and post it to the record's chatter."""
|
||||
try:
|
||||
resp = self._rc_media_get(audio_uri, config)
|
||||
audio_data = resp.content
|
||||
if not audio_data:
|
||||
return
|
||||
|
||||
ext = 'wav' if 'wav' in content_type else 'mp3'
|
||||
filename = f'{vm.name}.{ext}'
|
||||
|
||||
attachment = self.env['ir.attachment'].sudo().create({
|
||||
'name': filename,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(audio_data),
|
||||
'mimetype': content_type,
|
||||
'res_model': 'rc.voicemail',
|
||||
'res_id': vm.id,
|
||||
})
|
||||
vm.sudo().write({'audio_attachment_id': attachment.id})
|
||||
vm.sudo().message_post(
|
||||
body=_('Voicemail audio (%ss)') % vm.duration,
|
||||
attachment_ids=[attachment.id],
|
||||
message_type='notification',
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"RC Voicemail: Failed to download audio for %s", vm.name,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# OpenAI Whisper transcription
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def _get_openai_key(self):
|
||||
return (
|
||||
self.env['ir.config_parameter']
|
||||
.sudo()
|
||||
.get_param('fusion_rc.openai_api_key', '')
|
||||
)
|
||||
|
||||
def _try_openai_transcribe(self, vm):
|
||||
"""Transcribe a voicemail if OpenAI key is configured. Non-fatal."""
|
||||
api_key = self._get_openai_key()
|
||||
if not api_key:
|
||||
return
|
||||
try:
|
||||
result = self._transcribe_audio_openai(vm.audio_attachment_id, api_key)
|
||||
if result:
|
||||
vm.sudo().write(result)
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
"RC Voicemail: OpenAI transcription failed for %s", vm.name,
|
||||
)
|
||||
|
||||
def _transcribe_audio_openai(self, attachment, api_key):
|
||||
"""Send audio to OpenAI Whisper verbose_json and return structured data.
|
||||
|
||||
Returns a dict with transcription_text (English), language tag,
|
||||
and a timeline string showing silence gaps between segments.
|
||||
"""
|
||||
audio_data = base64.b64decode(attachment.datas)
|
||||
if not audio_data:
|
||||
return {}
|
||||
|
||||
ext = 'wav'
|
||||
if attachment.mimetype and 'mp3' in attachment.mimetype:
|
||||
ext = 'mp3'
|
||||
|
||||
resp = py_requests.post(
|
||||
OPENAI_WHISPER_URL,
|
||||
headers={'Authorization': f'Bearer {api_key}'},
|
||||
files={
|
||||
'file': (
|
||||
f'voicemail.{ext}',
|
||||
io.BytesIO(audio_data),
|
||||
attachment.mimetype or 'audio/wav',
|
||||
),
|
||||
},
|
||||
data={
|
||||
'model': 'whisper-1',
|
||||
'response_format': 'verbose_json',
|
||||
'timestamp_granularities[]': 'segment',
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
language = (data.get('language') or '').strip()
|
||||
full_text = (data.get('text') or '').strip()
|
||||
segments = data.get('segments', [])
|
||||
|
||||
english_text = full_text
|
||||
if language and language != 'english':
|
||||
english_text = self._translate_to_english(full_text, api_key)
|
||||
|
||||
timeline = self._build_timeline(segments)
|
||||
|
||||
return {
|
||||
'transcription_text': english_text,
|
||||
'transcription_language': language.title() if language else '',
|
||||
'transcription_timeline': timeline,
|
||||
'transcription_status': 'Completed',
|
||||
}
|
||||
|
||||
def _translate_to_english(self, text, api_key):
|
||||
"""Translate text to English using OpenAI chat completions."""
|
||||
try:
|
||||
resp = py_requests.post(
|
||||
'https://api.openai.com/v1/chat/completions',
|
||||
headers={
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json={
|
||||
'model': 'gpt-4o-mini',
|
||||
'messages': [
|
||||
{
|
||||
'role': 'system',
|
||||
'content': 'Translate the following text to English. '
|
||||
'Return only the translation, nothing else.',
|
||||
},
|
||||
{'role': 'user', 'content': text},
|
||||
],
|
||||
'temperature': 0.1,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return (
|
||||
resp.json()
|
||||
.get('choices', [{}])[0]
|
||||
.get('message', {})
|
||||
.get('content', text)
|
||||
.strip()
|
||||
)
|
||||
except Exception:
|
||||
_logger.warning("RC Voicemail: Translation failed, using original.")
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _build_timeline(segments):
|
||||
"""Build a timeline string from Whisper segments showing silence gaps.
|
||||
|
||||
Example output:
|
||||
.....3s..... Do you have apartments? .....6s..... Please call back.
|
||||
"""
|
||||
if not segments:
|
||||
return ''
|
||||
|
||||
parts = []
|
||||
prev_end = 0.0
|
||||
|
||||
for seg in segments:
|
||||
seg_start = seg.get('start', 0.0)
|
||||
seg_end = seg.get('end', 0.0)
|
||||
seg_text = (seg.get('text') or '').strip()
|
||||
|
||||
gap = seg_start - prev_end
|
||||
if gap >= 1.0:
|
||||
parts.append(f'.....{int(gap)}s.....')
|
||||
|
||||
if seg_text:
|
||||
parts.append(seg_text)
|
||||
|
||||
prev_end = seg_end
|
||||
|
||||
return ' '.join(parts)
|
||||
|
||||
def action_transcribe(self):
|
||||
"""Manual button: transcribe this voicemail with OpenAI Whisper."""
|
||||
self.ensure_one()
|
||||
api_key = self._get_openai_key()
|
||||
if not api_key:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('No API Key'),
|
||||
'message': _('Set your OpenAI API key in Settings > Fusion RingCentral.'),
|
||||
'type': 'warning',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
if not self.audio_attachment_id:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('No Audio'),
|
||||
'message': _('This voicemail has no audio file to transcribe.'),
|
||||
'type': 'warning',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
try:
|
||||
result = self._transcribe_audio_openai(self.audio_attachment_id, api_key)
|
||||
if result:
|
||||
self.sudo().write(result)
|
||||
except Exception:
|
||||
_logger.exception("RC Voicemail: Manual transcription failed for %s", self.name)
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Transcription Failed'),
|
||||
'message': _('Could not transcribe. Check logs for details.'),
|
||||
'type': 'danger',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _cron_transcribe_voicemails(self):
|
||||
"""Batch-transcribe voicemails that have audio but no transcript."""
|
||||
api_key = self._get_openai_key()
|
||||
if not api_key:
|
||||
return
|
||||
|
||||
pending = self.search([
|
||||
('audio_attachment_id', '!=', False),
|
||||
('transcription_text', '=', False),
|
||||
], limit=50, order='received_date desc')
|
||||
|
||||
if not pending:
|
||||
return
|
||||
|
||||
_logger.info("RC VM Transcribe: %d voicemails to process.", len(pending))
|
||||
success = 0
|
||||
for vm in pending:
|
||||
try:
|
||||
result = self._transcribe_audio_openai(vm.audio_attachment_id, api_key)
|
||||
if result:
|
||||
vm.sudo().write(result)
|
||||
success += 1
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
"RC VM Transcribe: Failed for %s, skipping.", vm.name,
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
_logger.info("RC VM Transcribe: %d/%d completed.", success, len(pending))
|
||||
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
rc_openai_api_key = fields.Char(
|
||||
string='OpenAI API Key',
|
||||
config_parameter='fusion_rc.openai_api_key',
|
||||
help='Used for automatic voicemail transcription via Whisper.',
|
||||
)
|
||||
48
fusion_ringcentral/fusion_ringcentral/models/res_partner.py
Normal file
48
fusion_ringcentral/fusion_ringcentral/models/res_partner.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
rc_call_ids = fields.One2many(
|
||||
'rc.call.history',
|
||||
'partner_id',
|
||||
string='RingCentral Calls',
|
||||
)
|
||||
rc_call_count = fields.Integer(
|
||||
string='Call Count',
|
||||
compute='_compute_rc_call_count',
|
||||
)
|
||||
|
||||
@api.depends('rc_call_ids')
|
||||
def _compute_rc_call_count(self):
|
||||
for partner in self:
|
||||
partner.rc_call_count = len(partner.rc_call_ids)
|
||||
|
||||
def action_view_rc_calls(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Calls',
|
||||
'res_model': 'rc.call.history',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('partner_id', '=', self.id)],
|
||||
}
|
||||
|
||||
def action_rc_call(self):
|
||||
"""Placeholder for click-to-call -- actual dialing is handled by the JS widget."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Click-to-Dial',
|
||||
'message': f'Use the RingCentral phone widget to call {self.phone or self.mobile or "this contact"}.',
|
||||
'type': 'info',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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_company,rc.call.history.company,model_rc_call_history,group_rc_company_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_company,rc.voicemail.company,model_rc_voicemail,group_rc_company_user,1,1,1,0
|
||||
access_rc_voicemail_manager,rc.voicemail.manager,model_rc_voicemail,group_rc_manager,1,1,1,1
|
||||
access_fusion_fax_rc_user,fusion.fax.rc.user,fusion_faxes.model_fusion_fax,group_rc_user,1,1,1,0
|
||||
access_fusion_fax_rc_company,fusion.fax.rc.company,fusion_faxes.model_fusion_fax,group_rc_company_user,1,1,1,0
|
||||
access_fusion_fax_rc_manager,fusion.fax.rc.manager,fusion_faxes.model_fusion_fax,group_rc_manager,1,1,1,1
|
||||
access_fusion_fax_doc_rc_user,fusion.fax.doc.rc.user,fusion_faxes.model_fusion_fax_document,group_rc_user,1,1,1,0
|
||||
access_fusion_fax_doc_rc_company,fusion.fax.doc.rc.company,fusion_faxes.model_fusion_fax_document,group_rc_company_user,1,1,1,0
|
||||
access_fusion_fax_doc_rc_manager,fusion.fax.doc.rc.manager,fusion_faxes.model_fusion_fax_document,group_rc_manager,1,1,1,1
|
||||
access_fusion_fax_dash_rc_user,fusion.fax.dash.rc.user,fusion_faxes.model_fusion_fax_dashboard,group_rc_user,1,1,1,1
|
||||
access_fusion_fax_wiz_rc_user,fusion.fax.wiz.rc.user,fusion_faxes.model_fusion_faxes_send_fax_wizard,group_rc_user,1,1,1,1
|
||||
access_fusion_fax_wiz_line_rc_user,fusion.fax.wiz.line.rc.user,fusion_faxes.model_fusion_faxes_send_fax_wizard_line,group_rc_user,1,1,1,1
|
||||
|
129
fusion_ringcentral/fusion_ringcentral/security/security.xml
Normal file
129
fusion_ringcentral/fusion_ringcentral/security/security.xml
Normal file
@@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ================================================================== -->
|
||||
<!-- MODULE CATEGORY -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="module_category_fusion_connect" model="ir.module.category">
|
||||
<field name="name">Fusion Connect</field>
|
||||
<field name="sequence">46</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- PRIVILEGE (Odoo 19 pattern for user settings dropdown) -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="res_groups_privilege_fusion_connect" model="res.groups.privilege">
|
||||
<field name="name">Fusion Connect</field>
|
||||
<field name="sequence">46</field>
|
||||
<field name="category_id" ref="module_category_fusion_connect"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- USER GROUP: sees own calls, voicemails, faxes -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="group_rc_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_connect"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- COMPANY USER GROUP: sees ALL company calls, voicemails, faxes -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="group_rc_company_user" model="res.groups">
|
||||
<field name="name">Company User</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_rc_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_connect"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- MANAGER GROUP: full access + RingCentral configuration -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="group_rc_manager" model="res.groups">
|
||||
<field name="name">Manager</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_rc_company_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_connect"/>
|
||||
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<data noupdate="0">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Call History record rules -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<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>
|
||||
|
||||
<record id="rule_call_company_all" model="ir.rule">
|
||||
<field name="name">RC Call: company user 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_company_user'))]"/>
|
||||
</record>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Voicemail record rules -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="rule_voicemail_user_own" model="ir.rule">
|
||||
<field name="name">RC Voicemail: user sees own</field>
|
||||
<field name="model_id" ref="model_rc_voicemail"/>
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_rc_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_voicemail_company_all" model="ir.rule">
|
||||
<field name="name">RC Voicemail: company 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_company_user'))]"/>
|
||||
</record>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Fax record rules -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="rule_fax_user_own" model="ir.rule">
|
||||
<field name="name">Fax: user sees own faxes</field>
|
||||
<field name="model_id" ref="fusion_faxes.model_fusion_fax"/>
|
||||
<field name="domain_force">['|', ('sent_by_id', '=', user.id), ('create_uid', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_rc_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_fax_company_all" model="ir.rule">
|
||||
<field name="name">Fax: company user sees all faxes</field>
|
||||
<field name="model_id" ref="fusion_faxes.model_fusion_fax"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_rc_company_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_fax_manager_all" model="ir.rule">
|
||||
<field name="name">Fax: manager sees all faxes</field>
|
||||
<field name="model_id" ref="fusion_faxes.model_fusion_fax"/>
|
||||
<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 |
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/** @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", {});
|
||||
console.log("[RC Widget] Config received:", JSON.stringify(config));
|
||||
} catch (e) {
|
||||
console.error("[RC Widget] Failed to fetch config:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config || !config.enabled || !config.client_id) {
|
||||
console.warn("[RC Widget] Widget disabled or missing client_id", config);
|
||||
return;
|
||||
}
|
||||
|
||||
rcWidgetLoaded = true;
|
||||
|
||||
const script = document.createElement("script");
|
||||
const params = new URLSearchParams({
|
||||
clientId: config.client_id,
|
||||
appServer: config.app_server || "https://app.ringcentral.com",
|
||||
disableInactiveTabCallEvent: "1",
|
||||
});
|
||||
console.log("[RC Widget] Loading adapter with params:", params.toString());
|
||||
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();
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
170
fusion_ringcentral/fusion_ringcentral/views/rc_config_views.xml
Normal file
170
fusion_ringcentral/fusion_ringcentral/views/rc_config_views.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?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_import_historical_faxes" type="object"
|
||||
string="Import Faxes (12 months)"
|
||||
icon="fa-fax"
|
||||
class="btn-secondary"
|
||||
invisible="state != 'connected'"
|
||||
confirm="This will import up to 12 months of faxes 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 (visible to all RC users) -->
|
||||
<menuitem id="menu_rc_root"
|
||||
name="Fusion Connect"
|
||||
web_icon="fusion_ringcentral,static/description/icon.png"
|
||||
groups="group_rc_user"
|
||||
sequence="44"/>
|
||||
|
||||
<!-- Configuration submenu (managers only) -->
|
||||
<menuitem id="menu_rc_configuration"
|
||||
name="Configuration"
|
||||
parent="menu_rc_root"
|
||||
groups="group_rc_manager"
|
||||
sequence="90"/>
|
||||
|
||||
<menuitem id="menu_rc_config_settings"
|
||||
name="RingCentral Settings"
|
||||
parent="menu_rc_configuration"
|
||||
action="action_rc_config"
|
||||
groups="group_rc_manager"
|
||||
sequence="10"/>
|
||||
|
||||
</odoo>
|
||||
45
fusion_ringcentral/fusion_ringcentral/views/rc_fax_menus.xml
Normal file
45
fusion_ringcentral/fusion_ringcentral/views/rc_fax_menus.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user