This commit is contained in:
gsinghpal
2026-03-09 15:21:22 -04:00
parent a3e85a23ef
commit acd3fc455e
243 changed files with 20459 additions and 4197 deletions

View File

@@ -205,11 +205,28 @@ class FusionFax(models.Model):
return super().create(vals_list)
# ------------------------------------------------------------------
# RingCentral SDK helpers
# RingCentral helpers
# ------------------------------------------------------------------
def _get_rc_config(self):
"""Return the active rc.config record or raise."""
try:
config = self.env['rc.config']._get_active_config()
except Exception:
config = False
if not config:
raise UserError(_(
'RingCentral is not connected. '
'Go to Settings > Fusion RingCentral and connect via OAuth.'
))
return config
def _get_rc_sdk(self):
"""Initialize and authenticate the RingCentral SDK. Returns (sdk, platform) tuple."""
"""Initialize and authenticate the RingCentral SDK. Returns (sdk, platform) tuple.
Tries JWT credentials first (Fusion Faxes settings), then falls back
to the rc.config OAuth credentials + SDK JWT if available.
"""
ICP = self.env['ir.config_parameter'].sudo()
enabled = ICP.get_param('fusion_faxes.ringcentral_enabled', 'False')
if enabled not in ('True', 'true', '1'):
@@ -222,8 +239,9 @@ class FusionFax(models.Model):
if not all([client_id, client_secret, jwt_token]):
raise UserError(_(
'RingCentral credentials are not configured. '
'Go to Settings > Fusion Faxes and enter Client ID, Client Secret, and JWT Token.'
'RingCentral JWT credentials are not configured. '
'Go to Settings > Fusion Faxes and enter Client ID, Client Secret, and JWT Token. '
'JWT is required for outbound fax sending.'
))
try:
@@ -247,7 +265,11 @@ class FusionFax(models.Model):
return self.attachment_ids
def _send_fax(self):
"""Send this fax record via RingCentral API."""
"""Send this fax record via RingCentral API.
Tries JWT/SDK first (if configured), then falls back to
rc.config OAuth with raw multipart POST.
"""
self.ensure_one()
attachments = self._get_ordered_attachments()
@@ -256,13 +278,58 @@ class FusionFax(models.Model):
self.write({'state': 'sending', 'error_message': False})
ICP = self.env['ir.config_parameter'].sudo()
jwt_token = ICP.get_param('fusion_faxes.ringcentral_jwt_token', '')
if jwt_token:
self._send_fax_sdk(attachments)
else:
self._send_fax_oauth(attachments)
def _send_fax_sdk(self, attachments):
"""Send fax using the RingCentral Python SDK (JWT auth)."""
try:
sdk, platform = self._get_rc_sdk()
# Use the SDK's multipart builder
builder = sdk.create_multipart_builder()
body = {
'to': [{'phoneNumber': self.fax_number}],
'faxResolution': 'High',
}
if self.cover_page_text:
body['coverPageText'] = self.cover_page_text
builder.set_body(body)
for attachment in attachments:
file_content = base64.b64decode(attachment.datas)
builder.add((attachment.name, file_content))
request = builder.request('/restapi/v1.0/account/~/extension/~/fax')
response = platform.send_request(request)
result = response.json()
message_id = ''
page_count = 0
if isinstance(result, dict):
message_id = str(result.get('id', ''))
page_count = result.get('pageCount', 0)
else:
message_id = str(getattr(result, 'id', ''))
page_count = getattr(result, 'pageCount', 0)
self._finalize_send(message_id, page_count)
except UserError:
raise
except Exception as e:
self._handle_send_error(e)
def _send_fax_oauth(self, attachments):
"""Send fax using rc.config OAuth with multipart POST."""
import requests as _requests
try:
rc_config = self._get_rc_config()
headers = rc_config._get_headers()
del headers['Content-Type']
# Set the JSON body (metadata)
body = {
'to': [{'phoneNumber': self.fax_number}],
'faxResolution': 'High',
@@ -270,55 +337,54 @@ class FusionFax(models.Model):
if self.cover_page_text:
body['coverPageText'] = self.cover_page_text
builder.set_body(body)
# Add document attachments in sequence order
files = [
('json', (None, json.dumps(body), 'application/json')),
]
for attachment in attachments:
file_content = base64.b64decode(attachment.datas)
builder.add((attachment.name, file_content))
mime = attachment.mimetype or 'application/pdf'
files.append(('attachment', (attachment.name, file_content, mime)))
# Build the request and send
request = builder.request('/restapi/v1.0/account/~/extension/~/fax')
response = platform.send_request(request)
result = response.json()
# Extract response fields
message_id = ''
page_count = 0
if hasattr(result, 'id'):
message_id = str(result.id)
elif isinstance(result, dict):
message_id = str(result.get('id', ''))
if hasattr(result, 'pageCount'):
page_count = result.pageCount
elif isinstance(result, dict):
page_count = result.get('pageCount', 0)
self.write({
'state': 'sent',
'ringcentral_message_id': message_id,
'sent_date': fields.Datetime.now(),
'sent_by_id': self.env.user.id,
'page_count': page_count,
})
# Post chatter message on linked documents
self._post_fax_chatter_message(success=True)
_logger.info("Fax %s sent successfully. RC Message ID: %s", self.name, message_id)
url = f'{rc_config.server_url}/restapi/v1.0/account/~/extension/~/fax'
resp = _requests.post(
url,
headers=headers,
files=files,
timeout=60,
verify=rc_config.ssl_verify,
proxies=rc_config._get_proxies(),
)
resp.raise_for_status()
result = resp.json()
message_id = str(result.get('id', ''))
page_count = result.get('pageCount', 0)
self._finalize_send(message_id, page_count)
except UserError:
raise
except Exception as e:
error_msg = str(e)
self.write({
'state': 'failed',
'error_message': error_msg,
})
self._post_fax_chatter_message(success=False)
_logger.exception("Fax %s failed to send", self.name)
raise UserError(_('Fax sending failed: %s') % error_msg)
self._handle_send_error(e)
def _finalize_send(self, message_id, page_count):
self.write({
'state': 'sent',
'ringcentral_message_id': message_id,
'sent_date': fields.Datetime.now(),
'sent_by_id': self.env.user.id,
'page_count': page_count,
})
self._post_fax_chatter_message(success=True)
_logger.info("Fax %s sent successfully. RC Message ID: %s", self.name, message_id)
def _handle_send_error(self, e):
error_msg = str(e)
self.write({
'state': 'failed',
'error_message': error_msg,
})
self._post_fax_chatter_message(success=False)
_logger.exception("Fax %s failed to send", self.name)
raise UserError(_('Fax sending failed: %s') % error_msg)
def _post_fax_chatter_message(self, success=True):
"""Post a chatter message on the linked sale order or invoice."""
@@ -390,157 +456,168 @@ class FusionFax(models.Model):
})
# ------------------------------------------------------------------
# Incoming fax polling
# Incoming fax polling (uses rc.config OAuth)
# ------------------------------------------------------------------
@api.model
def _cron_fetch_incoming_faxes(self):
"""Poll RingCentral for inbound faxes and create records."""
"""Poll RingCentral for inbound faxes via rc.config OAuth."""
ICP = self.env['ir.config_parameter'].sudo()
enabled = ICP.get_param('fusion_faxes.ringcentral_enabled', 'False')
if enabled not in ('True', 'true', '1'):
return
client_id = ICP.get_param('fusion_faxes.ringcentral_client_id', '')
client_secret = ICP.get_param('fusion_faxes.ringcentral_client_secret', '')
server_url = ICP.get_param('fusion_faxes.ringcentral_server_url', 'https://platform.ringcentral.com')
jwt_token = ICP.get_param('fusion_faxes.ringcentral_jwt_token', '')
if not all([client_id, client_secret, jwt_token]):
_logger.warning("Fusion Faxes: RingCentral credentials not configured, skipping inbound poll.")
return
try:
from ringcentral import SDK
except ImportError:
_logger.error("Fusion Faxes: ringcentral package not installed.")
rc_config = self.env['rc.config']._get_active_config()
except Exception:
rc_config = False
if not rc_config:
_logger.debug("Fusion Faxes: No active RingCentral config, skipping inbound poll.")
return
# Determine dateFrom: last poll or 1 year ago for first run
last_poll = ICP.get_param('fusion_faxes.last_inbound_poll', '')
if last_poll:
date_from = last_poll
if not last_poll:
date_from = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%dT%H:%M:%S.000Z')
else:
one_year_ago = datetime.utcnow() - timedelta(days=365)
date_from = one_year_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
date_from = last_poll
try:
sdk = SDK(client_id, client_secret, server_url)
platform = sdk.platform()
platform.login(jwt=jwt_token)
self._fetch_faxes_from_rc(rc_config, date_from)
except Exception:
_logger.exception("Fusion Faxes: Error fetching inbound faxes.")
total_imported = 0
total_skipped = 0
@api.model
def _run_historical_fax_import(self):
"""Background job: import up to 12 months of inbound faxes in monthly chunks."""
rc_config = self.env['rc.config']._get_active_config()
if not rc_config:
_logger.warning("Fax Historical Import: No connected RC config.")
return
# Fetch first page
endpoint = (
'/restapi/v1.0/account/~/extension/~/message-store'
f'?messageType=Fax&direction=Inbound&dateFrom={date_from}'
'&perPage=100'
ICP = self.env['ir.config_parameter'].sudo()
now = datetime.utcnow()
total_imported = 0
for months_back in range(12, 0, -1):
chunk_start = now - timedelta(days=months_back * 30)
chunk_key = f'fusion_rc.fax_import_done_{chunk_start.strftime("%Y%m")}'
if ICP.get_param(chunk_key, ''):
continue
date_from = chunk_start.strftime('%Y-%m-%dT%H:%M:%S.000Z')
date_to = (now - timedelta(days=(months_back - 1) * 30)).strftime('%Y-%m-%dT%H:%M:%S.000Z')
_logger.info("Fax Import: chunk %s to %s ...", date_from[:10], date_to[:10])
try:
count = self._fetch_faxes_from_rc(rc_config, date_from, date_to=date_to)
total_imported += count
ICP.set_param(chunk_key, 'done')
except Exception:
_logger.exception("Fax Import: chunk failed, will retry next run.")
_logger.info("Fax Historical Import complete: %d total imported.", total_imported)
@api.model
def _fetch_faxes_from_rc(self, rc_config, date_from, date_to=None):
"""Fetch inbound faxes from RingCentral and create records. Returns import count."""
import time as _time
ICP = self.env['ir.config_parameter'].sudo()
total_imported = 0
total_skipped = 0
params = {
'messageType': 'Fax',
'direction': 'Inbound',
'dateFrom': date_from,
'perPage': '100',
}
if date_to:
params['dateTo'] = date_to
endpoint = '/restapi/v1.0/account/~/extension/~/message-store'
page = 1
while True:
params['page'] = str(page)
data = rc_config._api_get(endpoint, params=params)
records = data.get('records', [])
if not records:
break
for msg in records:
msg_id = str(msg.get('id', ''))
if not msg_id:
continue
if self.search_count([('ringcentral_message_id', '=', msg_id)]):
total_skipped += 1
continue
if self._import_inbound_fax(msg, rc_config):
total_imported += 1
paging = data.get('paging', {})
if page >= paging.get('totalPages', 1):
break
page += 1
_time.sleep(2)
if not date_to:
ICP.set_param(
'fusion_faxes.last_inbound_poll',
datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z'),
)
while endpoint:
response = platform.get(endpoint)
data = response.json()
if total_imported:
_logger.info(
"Fusion Faxes: Imported %d inbound faxes, skipped %d duplicates.",
total_imported, total_skipped,
)
return total_imported
records = []
if hasattr(data, 'records'):
records = data.records
elif isinstance(data, dict):
records = data.get('records', [])
for msg in records:
msg_id = str(msg.get('id', '')) if isinstance(msg, dict) else str(getattr(msg, 'id', ''))
# Deduplicate
existing = self.search_count([('ringcentral_message_id', '=', msg_id)])
if existing:
total_skipped += 1
continue
imported = self._import_inbound_fax(msg, platform)
if imported:
total_imported += 1
# Handle pagination
endpoint = None
navigation = None
if isinstance(data, dict):
navigation = data.get('navigation', {})
elif hasattr(data, 'navigation'):
navigation = data.navigation
if navigation:
next_page = None
if isinstance(navigation, dict):
next_page = navigation.get('nextPage', {})
elif hasattr(navigation, 'nextPage'):
next_page = navigation.nextPage
if next_page:
next_uri = next_page.get('uri', '') if isinstance(next_page, dict) else getattr(next_page, 'uri', '')
if next_uri:
endpoint = next_uri
# Update last poll timestamp
ICP.set_param('fusion_faxes.last_inbound_poll', datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z'))
if total_imported:
_logger.info("Fusion Faxes: Imported %d inbound faxes, skipped %d duplicates.", total_imported, total_skipped)
except Exception:
_logger.exception("Fusion Faxes: Error fetching inbound faxes from RingCentral.")
def _import_inbound_fax(self, msg, platform):
"""Import a single inbound fax message from RingCentral."""
def _import_inbound_fax(self, msg, rc_config):
"""Import a single inbound fax message dict from RingCentral."""
try:
# Extract fields (handle both dict and SDK JsonObject responses)
if isinstance(msg, dict):
msg_id = str(msg.get('id', ''))
from_info = msg.get('from', {})
sender = from_info.get('phoneNumber', '') if isinstance(from_info, dict) else ''
creation_time = msg.get('creationTime', '')
read_status = msg.get('readStatus', '')
page_count = msg.get('faxPageCount', 0)
attachments = msg.get('attachments', [])
else:
msg_id = str(getattr(msg, 'id', ''))
# SDK exposes 'from' as 'from_' since 'from' is a Python keyword
from_info = getattr(msg, 'from_', None) or getattr(msg, 'from', None)
sender = getattr(from_info, 'phoneNumber', '') if from_info else ''
creation_time = getattr(msg, 'creationTime', '')
read_status = getattr(msg, 'readStatus', '')
page_count = getattr(msg, 'faxPageCount', 0)
attachments = getattr(msg, 'attachments', [])
import requests as _requests
msg_id = str(msg.get('id', ''))
from_info = msg.get('from', {})
sender = from_info.get('phoneNumber', '') if isinstance(from_info, dict) else ''
creation_time = msg.get('creationTime', '')
read_status = msg.get('readStatus', '')
page_count = msg.get('faxPageCount', 0)
attachments = msg.get('attachments', [])
# Parse received datetime
received_dt = False
if creation_time:
try:
clean_time = creation_time.replace('Z', '+00:00')
received_dt = datetime.fromisoformat(clean_time).strftime('%Y-%m-%d %H:%M:%S')
except (ValueError, AttributeError):
received_dt = False
pass
# Try to match sender to a partner
partner = False
if sender:
partner = self.env['res.partner'].sudo().search(
[('x_ff_fax_number', '=', sender)], limit=1
)
# Download the PDF attachment
document_lines = []
headers = rc_config._get_headers()
for att in attachments:
att_uri = att.get('uri', '') if isinstance(att, dict) else getattr(att, 'uri', '')
att_type = att.get('contentType', '') if isinstance(att, dict) else getattr(att, 'contentType', '')
att_uri = att.get('uri', '')
att_type = att.get('contentType', '')
if not att_uri:
continue
try:
att_response = platform.get(att_uri)
pdf_content = att_response.body()
resp = _requests.get(
att_uri,
headers=headers,
timeout=30,
verify=rc_config.ssl_verify,
proxies=rc_config._get_proxies(),
)
resp.raise_for_status()
pdf_content = resp.content
if not pdf_content:
continue
@@ -561,7 +638,6 @@ class FusionFax(models.Model):
except Exception:
_logger.exception("Fusion Faxes: Failed to download attachment for message %s", msg_id)
# Create the fax record
self.sudo().create({
'direction': 'inbound',
'state': 'received',