Initial commit
This commit is contained in:
488
fusion_ringcentral/models/rc_call_history.py
Normal file
488
fusion_ringcentral/models/rc_call_history.py
Normal file
@@ -0,0 +1,488 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RcCallHistory(models.Model):
|
||||
_name = 'rc.call.history'
|
||||
_description = 'RingCentral Call History'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'start_time desc'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: _('New'),
|
||||
)
|
||||
rc_session_id = fields.Char(string='RC Session ID', index=True, readonly=True)
|
||||
direction = fields.Selection([
|
||||
('inbound', 'Inbound'),
|
||||
('outbound', 'Outbound'),
|
||||
], string='Direction', required=True, tracking=True)
|
||||
from_number = fields.Char(string='From Number')
|
||||
to_number = fields.Char(string='To Number')
|
||||
start_time = fields.Datetime(string='Start Time', index=True)
|
||||
duration = fields.Integer(string='Duration (sec)')
|
||||
duration_display = fields.Char(string='Duration', compute='_compute_duration_display')
|
||||
status = fields.Selection([
|
||||
('answered', 'Answered'),
|
||||
('no_answer', 'No Answer'),
|
||||
('busy', 'Busy'),
|
||||
('missed', 'Missed'),
|
||||
('voicemail', 'Voicemail'),
|
||||
('rejected', 'Rejected'),
|
||||
('unknown', 'Unknown'),
|
||||
], string='Status', default='unknown', tracking=True)
|
||||
|
||||
partner_id = fields.Many2one('res.partner', string='Contact', tracking=True)
|
||||
user_id = fields.Many2one('res.users', string='Odoo User', default=lambda self: self.env.user)
|
||||
|
||||
sale_order_count = fields.Integer(string='Sales Orders', compute='_compute_partner_counts')
|
||||
invoice_count = fields.Integer(string='Invoices', compute='_compute_partner_counts')
|
||||
|
||||
has_recording = fields.Boolean(string='Has Recording', default=False)
|
||||
recording_url = fields.Char(string='Recording URL')
|
||||
recording_content_uri = fields.Char(string='Recording Content URI')
|
||||
|
||||
has_transcript = fields.Boolean(string='Has Transcript', default=False)
|
||||
transcript_text = fields.Text(string='Transcript')
|
||||
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order', ondelete='set null')
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
@api.depends('partner_id')
|
||||
def _compute_partner_counts(self):
|
||||
for rec in self:
|
||||
if rec.partner_id:
|
||||
partner = rec.partner_id.commercial_partner_id or rec.partner_id
|
||||
partner_ids = (partner | partner.child_ids).ids
|
||||
rec.sale_order_count = self.env['sale.order'].search_count(
|
||||
[('partner_id', 'in', partner_ids)]
|
||||
)
|
||||
rec.invoice_count = self.env['account.move'].search_count(
|
||||
[('partner_id', 'in', partner_ids),
|
||||
('move_type', 'in', ('out_invoice', 'out_refund'))]
|
||||
)
|
||||
else:
|
||||
rec.sale_order_count = 0
|
||||
rec.invoice_count = 0
|
||||
|
||||
@api.depends('duration')
|
||||
def _compute_duration_display(self):
|
||||
for rec in self:
|
||||
if rec.duration:
|
||||
minutes, seconds = divmod(rec.duration, 60)
|
||||
rec.duration_display = f'{minutes}m {seconds}s' if minutes else f'{seconds}s'
|
||||
else:
|
||||
rec.duration_display = '0s'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('rc.call.history') or _('New')
|
||||
if not vals.get('partner_id'):
|
||||
partner = self._match_partner(
|
||||
vals.get('from_number', ''),
|
||||
vals.get('to_number', ''),
|
||||
vals.get('direction', ''),
|
||||
)
|
||||
if partner:
|
||||
vals['partner_id'] = partner.id
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.model
|
||||
def _match_partner(self, from_number, to_number, direction):
|
||||
"""Auto-link a call to a partner by matching phone numbers.
|
||||
|
||||
For outbound calls, match the to_number (who we called).
|
||||
For inbound calls, match the from_number (who called us).
|
||||
Compares last 10 digits to handle +1, (905), etc.
|
||||
"""
|
||||
search_number = to_number if direction == 'outbound' else from_number
|
||||
if not search_number:
|
||||
return False
|
||||
|
||||
cleaned = self._normalize_phone(search_number)
|
||||
if not cleaned or len(cleaned) < 7:
|
||||
return False
|
||||
|
||||
Partner = self.env['res.partner']
|
||||
phone_fields = [f for f in ('phone', 'mobile') if f in Partner._fields]
|
||||
if not phone_fields:
|
||||
return False
|
||||
|
||||
domain = ['|'] * (len(phone_fields) - 1)
|
||||
for f in phone_fields:
|
||||
domain.append((f, '!=', False))
|
||||
partners = Partner.search(domain, order='customer_rank desc, write_date desc')
|
||||
|
||||
for p in partners:
|
||||
for f in phone_fields:
|
||||
val = p[f]
|
||||
if val and self._normalize_phone(val) == cleaned:
|
||||
return p
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_phone(number):
|
||||
"""Strip a phone number to last 10 digits for comparison."""
|
||||
return re.sub(r'\D', '', number or '')[-10:] if number else ''
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Smart button actions
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def action_view_contact(self):
|
||||
"""Open the linked contact."""
|
||||
self.ensure_one()
|
||||
if not self.partner_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'res.partner',
|
||||
'res_id': self.partner_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_sale_orders(self):
|
||||
"""Open sale orders for the linked contact."""
|
||||
self.ensure_one()
|
||||
if not self.partner_id:
|
||||
return
|
||||
partner = self.partner_id.commercial_partner_id or self.partner_id
|
||||
partner_ids = (partner | partner.child_ids).ids
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Sales Orders - %s') % self.partner_id.name,
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('partner_id', 'in', partner_ids)],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_invoices(self):
|
||||
"""Open invoices for the linked contact."""
|
||||
self.ensure_one()
|
||||
if not self.partner_id:
|
||||
return
|
||||
partner = self.partner_id.commercial_partner_id or self.partner_id
|
||||
partner_ids = (partner | partner.child_ids).ids
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Invoices - %s') % self.partner_id.name,
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('partner_id', 'in', partner_ids),
|
||||
('move_type', 'in', ('out_invoice', 'out_refund'))],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def _get_contact_number(self):
|
||||
"""Return the external phone number for this call."""
|
||||
self.ensure_one()
|
||||
if self.direction == 'outbound':
|
||||
return self.to_number or self.from_number
|
||||
return self.from_number or self.to_number
|
||||
|
||||
def action_make_call(self):
|
||||
"""Trigger a call via the RingCentral Embeddable widget (handled by JS)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window_close',
|
||||
'infos': {
|
||||
'rc_action': 'call',
|
||||
'rc_phone_number': self._get_contact_number(),
|
||||
},
|
||||
}
|
||||
|
||||
def action_send_sms(self):
|
||||
"""Open the RingCentral Embeddable SMS compose (handled by JS)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window_close',
|
||||
'infos': {
|
||||
'rc_action': 'sms',
|
||||
'rc_phone_number': self._get_contact_number(),
|
||||
},
|
||||
}
|
||||
|
||||
def action_send_fax(self):
|
||||
"""Open the Send Fax wizard pre-filled with this call's contact."""
|
||||
self.ensure_one()
|
||||
ctx = {}
|
||||
if self.partner_id:
|
||||
ctx['default_partner_id'] = self.partner_id.id
|
||||
fax_num = getattr(self.partner_id, 'x_ff_fax_number', '')
|
||||
if fax_num:
|
||||
ctx['default_fax_number'] = fax_num
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Send Fax'),
|
||||
'res_model': 'fusion_faxes.send.fax.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': ctx,
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Recording playback
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def action_play_recording(self):
|
||||
"""Open the recording proxy URL in a new window."""
|
||||
self.ensure_one()
|
||||
if not self.has_recording:
|
||||
return
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'{base_url}/ringcentral/recording/{self.id}',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Cron: sync call history from RingCentral
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _cron_sync_call_history(self):
|
||||
"""Incremental sync: only fetch calls since last successful sync.
|
||||
|
||||
Uses `fusion_rc.last_call_sync` to know where to start.
|
||||
Only queries RingCentral for new records, skips everything
|
||||
already imported. Runs every 15 min by default.
|
||||
"""
|
||||
config = self.env['rc.config']._get_active_config()
|
||||
if not config:
|
||||
return
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
last_sync = ICP.get_param('fusion_rc.last_call_sync', '')
|
||||
|
||||
if not last_sync:
|
||||
two_days_ago = datetime.utcnow() - timedelta(days=2)
|
||||
last_sync = two_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
|
||||
now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
total_imported = self._sync_calls_from_date(config, last_sync, now_str)
|
||||
|
||||
ICP.set_param('fusion_rc.last_call_sync', now_str)
|
||||
|
||||
if total_imported:
|
||||
_logger.info(
|
||||
"RC Incremental Sync: %d new calls imported (from %s).",
|
||||
total_imported, last_sync[:16],
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_daily_catchup_sync(self):
|
||||
"""Daily catchup: re-scan the past 3 days to catch anything missed.
|
||||
|
||||
Covers calls made from physical phones, mobile app, or any source
|
||||
outside Odoo. Deduplicates by rc_session_id so no duplicates
|
||||
are created. Runs once per day.
|
||||
"""
|
||||
config = self.env['rc.config']._get_active_config()
|
||||
if not config:
|
||||
return
|
||||
|
||||
three_days_ago = datetime.utcnow() - timedelta(days=3)
|
||||
date_from = three_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
|
||||
total_imported = self._sync_calls_from_date(config, date_from, now_str)
|
||||
_logger.info(
|
||||
"RC Daily Catchup: %d new calls imported (past 3 days).",
|
||||
total_imported,
|
||||
)
|
||||
|
||||
def _sync_calls_from_date(self, config, date_from, date_to=None):
|
||||
"""Fetch call records from RingCentral for a date range.
|
||||
|
||||
Skips records whose rc_session_id already exists in our DB
|
||||
(checked via a pre-loaded set, not per-record SQL queries).
|
||||
Returns the total number of newly imported records.
|
||||
"""
|
||||
total_imported = 0
|
||||
total_skipped = 0
|
||||
try:
|
||||
params = {
|
||||
'dateFrom': date_from,
|
||||
'type': 'Voice',
|
||||
'view': 'Detailed',
|
||||
'perPage': 250,
|
||||
}
|
||||
if date_to:
|
||||
params['dateTo'] = date_to
|
||||
|
||||
data = config._api_get(
|
||||
'/restapi/v1.0/account/~/extension/~/call-log', params=params,
|
||||
)
|
||||
imported, skipped = self._process_call_page(data)
|
||||
total_imported += imported
|
||||
total_skipped += skipped
|
||||
|
||||
while data.get('navigation', {}).get('nextPage', {}).get('uri'):
|
||||
next_uri = data['navigation']['nextPage']['uri']
|
||||
data = config._api_get(next_uri)
|
||||
imported, skipped = self._process_call_page(data)
|
||||
total_imported += imported
|
||||
total_skipped += skipped
|
||||
|
||||
except Exception:
|
||||
_logger.exception("Fusion RingCentral: Error syncing call history.")
|
||||
|
||||
if total_skipped:
|
||||
_logger.debug("RC Sync: skipped %d already-imported records.", total_skipped)
|
||||
return total_imported
|
||||
|
||||
def _process_call_page(self, data):
|
||||
"""Process a single page of call log records.
|
||||
|
||||
Uses a bulk session_id lookup (one SQL query per page)
|
||||
instead of per-record queries.
|
||||
Returns (imported_count, skipped_count).
|
||||
"""
|
||||
records = data.get('records', [])
|
||||
if not records:
|
||||
return 0, 0
|
||||
|
||||
page_session_ids = [
|
||||
r.get('sessionId', '') for r in records if r.get('sessionId')
|
||||
]
|
||||
|
||||
if page_session_ids:
|
||||
existing = set(
|
||||
r.rc_session_id
|
||||
for r in self.search([('rc_session_id', 'in', page_session_ids)])
|
||||
)
|
||||
else:
|
||||
existing = set()
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
for rec in records:
|
||||
session_id = rec.get('sessionId', '')
|
||||
if not session_id:
|
||||
continue
|
||||
if session_id in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
self._import_call_record(rec)
|
||||
existing.add(session_id)
|
||||
imported += 1
|
||||
|
||||
return imported, skipped
|
||||
|
||||
def _import_call_record(self, rec):
|
||||
"""Import a single call log record from the API."""
|
||||
try:
|
||||
direction_raw = rec.get('direction', '').lower()
|
||||
direction = 'inbound' if direction_raw == 'inbound' else 'outbound'
|
||||
|
||||
from_info = rec.get('from', {})
|
||||
to_info = rec.get('to', {})
|
||||
from_number = from_info.get('phoneNumber', '') or from_info.get('name', '')
|
||||
to_number = to_info.get('phoneNumber', '') or to_info.get('name', '')
|
||||
|
||||
start_time = False
|
||||
raw_time = rec.get('startTime', '')
|
||||
if raw_time:
|
||||
try:
|
||||
clean_time = raw_time.replace('Z', '+00:00')
|
||||
start_time = datetime.fromisoformat(clean_time).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
result = rec.get('result', 'Unknown')
|
||||
status_map = {
|
||||
'Accepted': 'answered',
|
||||
'Call connected': 'answered',
|
||||
'Voicemail': 'voicemail',
|
||||
'Missed': 'missed',
|
||||
'No Answer': 'no_answer',
|
||||
'Busy': 'busy',
|
||||
'Rejected': 'rejected',
|
||||
'Hang Up': 'answered',
|
||||
'Reply': 'answered',
|
||||
}
|
||||
status = status_map.get(result, 'unknown')
|
||||
|
||||
recording = rec.get('recording', {})
|
||||
has_recording = bool(recording)
|
||||
recording_content_uri = recording.get('contentUri', '') if recording else ''
|
||||
|
||||
self.sudo().create({
|
||||
'rc_session_id': rec.get('sessionId', ''),
|
||||
'direction': direction,
|
||||
'from_number': from_number,
|
||||
'to_number': to_number,
|
||||
'start_time': start_time,
|
||||
'duration': rec.get('duration', 0),
|
||||
'status': status,
|
||||
'has_recording': has_recording,
|
||||
'recording_content_uri': recording_content_uri,
|
||||
})
|
||||
except Exception:
|
||||
_logger.exception("Failed to import call record: %s", rec.get('sessionId', ''))
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Cron: fetch transcripts
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _cron_fetch_transcripts(self):
|
||||
"""Fetch AI transcripts for calls that have recordings but no transcript."""
|
||||
config = self.env['rc.config']._get_active_config()
|
||||
if not config:
|
||||
return
|
||||
|
||||
calls = self.search([
|
||||
('has_recording', '=', True),
|
||||
('has_transcript', '=', False),
|
||||
('recording_content_uri', '!=', False),
|
||||
], limit=20)
|
||||
|
||||
for call in calls:
|
||||
try:
|
||||
content_uri = call.recording_content_uri
|
||||
if not content_uri:
|
||||
continue
|
||||
|
||||
media_url = f'{content_uri}?access_token={config.access_token}'
|
||||
data = config._api_post(
|
||||
'/ai/speech-to-text/v1/async?webhook=false',
|
||||
data={
|
||||
'contentUri': media_url,
|
||||
'source': 'CallRecording',
|
||||
},
|
||||
)
|
||||
|
||||
transcript = ''
|
||||
segments = data.get('utterances', [])
|
||||
for seg in segments:
|
||||
speaker = seg.get('speakerName', 'Speaker')
|
||||
text = seg.get('text', '')
|
||||
transcript += f'{speaker}: {text}\n'
|
||||
|
||||
if transcript:
|
||||
call.write({
|
||||
'has_transcript': True,
|
||||
'transcript_text': transcript.strip(),
|
||||
})
|
||||
except Exception:
|
||||
_logger.debug("Transcript not available yet for call %s", call.name)
|
||||
Reference in New Issue
Block a user