# -*- 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)