392 lines
15 KiB
Python
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', '?')
|
|
)
|