Files
gsinghpal e8e554de95 changes
2026-02-23 00:32:20 -05:00

392 lines
15 KiB
Python

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