changes
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user