# -*- 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', '?') )