Files
Odoo-Modules/fusion_ringcentral/models/rc_call_history.py
2026-02-22 01:22:18 -05:00

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)