489 lines
19 KiB
Python
489 lines
19 KiB
Python
# -*- 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)
|