Initial commit
This commit is contained in:
609
fusion_ringcentral/models/rc_config.py
Normal file
609
fusion_ringcentral/models/rc_config.py
Normal file
@@ -0,0 +1,609 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
RC_RATE_LIMIT_DELAY = 10
|
||||
RC_RATE_LIMIT_RETRY_WAIT = 65
|
||||
RC_MAX_RETRIES = 5
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
RC_EMBEDDABLE_REDIRECT = (
|
||||
'https://apps.ringcentral.com/integration/ringcentral-embeddable/latest/redirect.html'
|
||||
)
|
||||
|
||||
|
||||
class RcConfig(models.Model):
|
||||
_name = 'rc.config'
|
||||
_description = 'RingCentral Configuration'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='Configuration Name', required=True, default='RingCentral')
|
||||
client_id = fields.Char(string='Client ID', required=True, groups='fusion_ringcentral.group_rc_manager')
|
||||
client_secret = fields.Char(string='Client Secret', required=True, groups='fusion_ringcentral.group_rc_manager')
|
||||
server_url = fields.Char(
|
||||
string='Server URL',
|
||||
required=True,
|
||||
default='https://platform.ringcentral.com',
|
||||
)
|
||||
access_token = fields.Char(string='Access Token', groups='base.group_system')
|
||||
refresh_token = fields.Char(string='Refresh Token', groups='base.group_system')
|
||||
token_expiry = fields.Datetime(string='Token Expires At', groups='base.group_system')
|
||||
webhook_subscription_id = fields.Char(string='Webhook Subscription ID', readonly=True)
|
||||
state = fields.Selection([
|
||||
('draft', 'Not Connected'),
|
||||
('connected', 'Connected'),
|
||||
('error', 'Error'),
|
||||
], string='Status', default='draft', required=True, tracking=True)
|
||||
phone_widget_enabled = fields.Boolean(string='Enable Phone Widget', default=True)
|
||||
|
||||
proxy_url = fields.Char(string='HTTP Proxy URL', help='Optional proxy for enterprise networks.')
|
||||
proxy_port = fields.Char(string='Proxy Port')
|
||||
ssl_verify = fields.Boolean(string='Verify SSL Certificates', default=True)
|
||||
|
||||
extension_name = fields.Char(string='Connected As', readonly=True)
|
||||
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Connection test
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def action_test_connection(self):
|
||||
"""Test DNS + HTTPS connectivity to the RingCentral server."""
|
||||
self.ensure_one()
|
||||
results = []
|
||||
|
||||
# DNS check
|
||||
hostname = self.server_url.replace('https://', '').replace('http://', '').rstrip('/')
|
||||
try:
|
||||
socket.getaddrinfo(hostname, 443)
|
||||
results.append(f'DNS: {hostname} resolved OK')
|
||||
except socket.gaierror:
|
||||
return self._notify('Connection Failed', f'DNS resolution failed for {hostname}', 'danger')
|
||||
|
||||
# HTTPS check
|
||||
try:
|
||||
resp = requests.get(
|
||||
f'{self.server_url}/restapi/v1.0',
|
||||
timeout=10,
|
||||
verify=self.ssl_verify,
|
||||
proxies=self._get_proxies(),
|
||||
)
|
||||
results.append(f'HTTPS: Status {resp.status_code}')
|
||||
except requests.RequestException as e:
|
||||
return self._notify('Connection Failed', f'HTTPS error: {e}', 'danger')
|
||||
|
||||
return self._notify('Connection Successful', '\n'.join(results), 'success')
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# OAuth flow
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def action_oauth_connect(self):
|
||||
"""Redirect user to RingCentral OAuth authorization page."""
|
||||
self.ensure_one()
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
||||
redirect_uri = f'{base_url}/ringcentral/oauth'
|
||||
state = json.dumps({'config_id': self.id})
|
||||
|
||||
auth_url = (
|
||||
f'{self.server_url}/restapi/oauth/authorize'
|
||||
f'?response_type=code'
|
||||
f'&client_id={self.client_id}'
|
||||
f'&redirect_uri={redirect_uri}'
|
||||
f'&state={state}'
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': auth_url,
|
||||
'target': 'self',
|
||||
}
|
||||
|
||||
def action_disconnect(self):
|
||||
"""Revoke tokens and disconnect."""
|
||||
self.ensure_one()
|
||||
if self.access_token:
|
||||
try:
|
||||
requests.post(
|
||||
f'{self.server_url}/restapi/oauth/revoke',
|
||||
data={'token': self.access_token},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
timeout=10,
|
||||
verify=self.ssl_verify,
|
||||
proxies=self._get_proxies(),
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception("Error revoking RingCentral token")
|
||||
|
||||
self.write({
|
||||
'access_token': False,
|
||||
'refresh_token': False,
|
||||
'token_expiry': False,
|
||||
'webhook_subscription_id': False,
|
||||
'state': 'draft',
|
||||
'extension_name': False,
|
||||
})
|
||||
return self._notify('Disconnected', 'RingCentral has been disconnected.', 'warning')
|
||||
|
||||
def exchange_auth_code(self, code, redirect_uri):
|
||||
"""Exchange authorization code for access/refresh tokens."""
|
||||
self.ensure_one()
|
||||
try:
|
||||
resp = requests.post(
|
||||
f'{self.server_url}/restapi/oauth/token',
|
||||
data={
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': redirect_uri,
|
||||
},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
timeout=15,
|
||||
verify=self.ssl_verify,
|
||||
proxies=self._get_proxies(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
expires_in = data.get('expires_in', 3600)
|
||||
self.write({
|
||||
'access_token': data.get('access_token'),
|
||||
'refresh_token': data.get('refresh_token'),
|
||||
'token_expiry': datetime.utcnow() + timedelta(seconds=expires_in),
|
||||
'state': 'connected',
|
||||
})
|
||||
|
||||
self._fetch_extension_info()
|
||||
self._create_webhook_subscription()
|
||||
return True
|
||||
except Exception:
|
||||
_logger.exception("RingCentral OAuth token exchange failed")
|
||||
self.write({'state': 'error'})
|
||||
return False
|
||||
|
||||
def _refresh_token(self):
|
||||
"""Refresh the access token using the refresh token."""
|
||||
self.ensure_one()
|
||||
if not self.refresh_token:
|
||||
self.write({'state': 'error'})
|
||||
return False
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f'{self.server_url}/restapi/oauth/token',
|
||||
data={
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': self.refresh_token,
|
||||
},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
timeout=15,
|
||||
verify=self.ssl_verify,
|
||||
proxies=self._get_proxies(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
expires_in = data.get('expires_in', 3600)
|
||||
self.write({
|
||||
'access_token': data.get('access_token'),
|
||||
'refresh_token': data.get('refresh_token', self.refresh_token),
|
||||
'token_expiry': datetime.utcnow() + timedelta(seconds=expires_in),
|
||||
'state': 'connected',
|
||||
})
|
||||
return True
|
||||
except Exception:
|
||||
_logger.exception("RingCentral token refresh failed")
|
||||
self.write({'state': 'error'})
|
||||
return False
|
||||
|
||||
def _ensure_token(self):
|
||||
"""Ensure we have a valid access token, refreshing if needed."""
|
||||
self.ensure_one()
|
||||
if not self.access_token:
|
||||
raise UserError(_('RingCentral is not connected. Please connect via OAuth first.'))
|
||||
if self.token_expiry and self.token_expiry < fields.Datetime.now():
|
||||
if not self._refresh_token():
|
||||
raise UserError(_('Failed to refresh RingCentral token. Please reconnect.'))
|
||||
|
||||
def _get_headers(self):
|
||||
"""Return authorization headers for API calls."""
|
||||
self._ensure_token()
|
||||
return {
|
||||
'Authorization': f'Bearer {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
def _api_get(self, endpoint, params=None):
|
||||
"""Make an authenticated GET request with rate-limit handling."""
|
||||
return self._api_request('GET', endpoint, params=params)
|
||||
|
||||
def _api_post(self, endpoint, data=None):
|
||||
"""Make an authenticated POST request with rate-limit handling."""
|
||||
return self._api_request('POST', endpoint, json_data=data)
|
||||
|
||||
def _api_request(self, method, endpoint, params=None, json_data=None):
|
||||
"""Central API request method with automatic rate-limit retry.
|
||||
|
||||
RingCentral Heavy group (Call Log): 10 req / 60 sec, 60 sec penalty.
|
||||
We pace at 10 sec between calls (~6 req/min) and auto-retry on
|
||||
429 or 401 (token can appear invalid during penalty window).
|
||||
"""
|
||||
self._ensure_token()
|
||||
url = endpoint if endpoint.startswith('http') else f'{self.server_url}{endpoint}'
|
||||
kwargs = {
|
||||
'timeout': 30,
|
||||
'verify': self.ssl_verify,
|
||||
'proxies': self._get_proxies(),
|
||||
}
|
||||
if params:
|
||||
kwargs['params'] = params
|
||||
if json_data is not None:
|
||||
kwargs['json'] = json_data
|
||||
|
||||
last_error = None
|
||||
for attempt in range(1, RC_MAX_RETRIES + 1):
|
||||
kwargs['headers'] = self._get_headers()
|
||||
resp = requests.request(method, url, **kwargs)
|
||||
|
||||
if resp.status_code == 429:
|
||||
retry_after = int(resp.headers.get('Retry-After', RC_RATE_LIMIT_RETRY_WAIT))
|
||||
_logger.warning(
|
||||
"RC rate limit 429. Waiting %d sec (attempt %d/%d)...",
|
||||
retry_after, attempt, RC_MAX_RETRIES,
|
||||
)
|
||||
time.sleep(retry_after)
|
||||
self._ensure_token()
|
||||
continue
|
||||
|
||||
if resp.status_code == 401 and attempt < RC_MAX_RETRIES:
|
||||
_logger.warning(
|
||||
"RC 401 Unauthorized -- token may be stale from rate-limit penalty. "
|
||||
"Refreshing token and waiting 60 sec (attempt %d/%d)...",
|
||||
attempt, RC_MAX_RETRIES,
|
||||
)
|
||||
time.sleep(60)
|
||||
try:
|
||||
self._refresh_token()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
if resp.status_code >= 400:
|
||||
last_error = resp
|
||||
_logger.error(
|
||||
"RC API error %d on %s %s: %s",
|
||||
resp.status_code, method, url, resp.text[:300],
|
||||
)
|
||||
|
||||
resp.raise_for_status()
|
||||
time.sleep(RC_RATE_LIMIT_DELAY)
|
||||
return resp.json()
|
||||
|
||||
if last_error is not None:
|
||||
last_error.raise_for_status()
|
||||
raise UserError(_('RingCentral API request failed after %d attempts.') % RC_MAX_RETRIES)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Extension info
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def _fetch_extension_info(self):
|
||||
"""Fetch the connected user's extension info."""
|
||||
self.ensure_one()
|
||||
try:
|
||||
data = self._api_get('/restapi/v1.0/account/~/extension/~')
|
||||
self.extension_name = data.get('name', 'Unknown')
|
||||
except Exception:
|
||||
_logger.exception("Failed to fetch extension info")
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Webhook management
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def _create_webhook_subscription(self):
|
||||
"""Create a webhook subscription for telephony events."""
|
||||
self.ensure_one()
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
||||
webhook_url = f'{base_url}/ringcentral/webhook'
|
||||
|
||||
try:
|
||||
data = self._api_post('/restapi/v1.0/subscription', data={
|
||||
'eventFilters': [
|
||||
'/restapi/v1.0/account/~/extension/~/telephony/sessions',
|
||||
],
|
||||
'deliveryMode': {
|
||||
'transportType': 'WebHook',
|
||||
'address': webhook_url,
|
||||
},
|
||||
'expiresIn': 630720000,
|
||||
})
|
||||
self.webhook_subscription_id = data.get('id', '')
|
||||
_logger.info("RingCentral webhook subscription created: %s", self.webhook_subscription_id)
|
||||
except Exception:
|
||||
_logger.exception("Failed to create RingCentral webhook subscription")
|
||||
|
||||
def _renew_webhook_subscription(self):
|
||||
"""Renew the webhook subscription."""
|
||||
self.ensure_one()
|
||||
if not self.webhook_subscription_id:
|
||||
self._create_webhook_subscription()
|
||||
return
|
||||
|
||||
try:
|
||||
self._api_post(
|
||||
f'/restapi/v1.0/subscription/{self.webhook_subscription_id}/renew'
|
||||
)
|
||||
_logger.info("RingCentral webhook renewed: %s", self.webhook_subscription_id)
|
||||
except Exception:
|
||||
_logger.warning("Webhook renewal failed, recreating...")
|
||||
self._create_webhook_subscription()
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def _get_proxies(self):
|
||||
"""Return proxy dict for requests if configured."""
|
||||
if self.proxy_url:
|
||||
proxy = f'{self.proxy_url}:{self.proxy_port}' if self.proxy_port else self.proxy_url
|
||||
return {'http': proxy, 'https': proxy}
|
||||
return None
|
||||
|
||||
def _notify(self, title, message, level='info'):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _(title),
|
||||
'message': message,
|
||||
'type': level,
|
||||
'sticky': level == 'danger',
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_active_config(self):
|
||||
"""Return the first connected config, or False."""
|
||||
return self.search([('state', '=', 'connected')], limit=1)
|
||||
|
||||
def action_rematch_contacts(self):
|
||||
"""Re-run contact matching on all calls without a linked partner."""
|
||||
self.ensure_one()
|
||||
CallHistory = self.env['rc.call.history']
|
||||
unlinked = CallHistory.search([('partner_id', '=', False)])
|
||||
matched = 0
|
||||
for call in unlinked:
|
||||
partner = CallHistory._match_partner(
|
||||
call.from_number or '', call.to_number or '', call.direction,
|
||||
)
|
||||
if partner:
|
||||
call.write({'partner_id': partner.id})
|
||||
matched += 1
|
||||
|
||||
return self._notify(
|
||||
'Contact Matching Complete',
|
||||
f'Matched {matched} calls out of {len(unlinked)} unlinked records.',
|
||||
'success' if matched else 'info',
|
||||
)
|
||||
|
||||
def action_import_historical_calls(self):
|
||||
"""Trigger historical call import in the background via cron.
|
||||
|
||||
Returns immediately so the UI doesn't freeze. The actual work
|
||||
happens in _run_historical_import() called by a one-shot cron.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self._ensure_token()
|
||||
|
||||
cron = self.env.ref(
|
||||
'fusion_ringcentral.cron_rc_historical_import', raise_if_not_found=False,
|
||||
)
|
||||
if cron:
|
||||
cron.sudo().write({
|
||||
'active': True,
|
||||
'nextcall': fields.Datetime.now(),
|
||||
})
|
||||
else:
|
||||
self.env['ir.cron'].sudo().create({
|
||||
'name': 'RingCentral: Historical Import',
|
||||
'cron_name': 'RingCentral: Historical Import',
|
||||
'model_id': self.env['ir.model']._get('rc.config').id,
|
||||
'state': 'code',
|
||||
'code': 'model._run_historical_import()',
|
||||
'interval_number': 9999,
|
||||
'interval_type': 'days',
|
||||
'nextcall': fields.Datetime.now(),
|
||||
'active': True,
|
||||
})
|
||||
|
||||
return self._notify(
|
||||
'Import Started',
|
||||
'Historical call import is running in the background. '
|
||||
'Check Fusion RingCentral > Call History in a few minutes to see results.',
|
||||
'info',
|
||||
)
|
||||
|
||||
def action_import_historical_voicemails(self):
|
||||
"""Trigger historical voicemail import in the background via cron."""
|
||||
self.ensure_one()
|
||||
self._ensure_token()
|
||||
|
||||
cron = self.env.ref(
|
||||
'fusion_ringcentral.cron_rc_historical_vm_import',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if cron:
|
||||
cron.sudo().write({
|
||||
'active': True,
|
||||
'nextcall': fields.Datetime.now(),
|
||||
})
|
||||
else:
|
||||
self.env['ir.cron'].sudo().create({
|
||||
'name': 'RingCentral: Historical Voicemail Import',
|
||||
'cron_name': 'RingCentral: Historical Voicemail Import',
|
||||
'model_id': self.env['ir.model']._get('rc.voicemail').id,
|
||||
'state': 'code',
|
||||
'code': 'model._run_historical_voicemail_import()',
|
||||
'interval_number': 9999,
|
||||
'interval_type': 'days',
|
||||
'nextcall': fields.Datetime.now(),
|
||||
'active': True,
|
||||
})
|
||||
|
||||
return self._notify(
|
||||
'Voicemail Import Started',
|
||||
'Historical voicemail import is running in the background. '
|
||||
'Check Fusion RingCentral > Voicemails in a few minutes.',
|
||||
'info',
|
||||
)
|
||||
|
||||
def action_backfill_voicemail_media(self):
|
||||
"""Re-download audio and transcriptions for voicemails missing them."""
|
||||
self.ensure_one()
|
||||
self._ensure_token()
|
||||
|
||||
cron = self.env.ref(
|
||||
'fusion_ringcentral.cron_rc_backfill_vm_media',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if cron:
|
||||
cron.sudo().write({
|
||||
'active': True,
|
||||
'nextcall': fields.Datetime.now(),
|
||||
})
|
||||
else:
|
||||
self.env['ir.cron'].sudo().create({
|
||||
'name': 'RingCentral: Backfill Voicemail Media',
|
||||
'cron_name': 'RingCentral: Backfill Voicemail Media',
|
||||
'model_id': self.env['ir.model']._get('rc.voicemail').id,
|
||||
'state': 'code',
|
||||
'code': 'model._run_backfill_voicemail_media()',
|
||||
'interval_number': 9999,
|
||||
'interval_type': 'days',
|
||||
'nextcall': fields.Datetime.now(),
|
||||
'active': True,
|
||||
})
|
||||
|
||||
return self._notify(
|
||||
'Voicemail Media Backfill Started',
|
||||
'Downloading audio and transcriptions for voicemails that are missing them. '
|
||||
'This runs in the background with rate-limit pacing.',
|
||||
'info',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _run_historical_import(self):
|
||||
"""Background job: import up to 12 months of call history.
|
||||
|
||||
Tracks which monthly chunks completed successfully via
|
||||
ir.config_parameter so it never re-queries months that
|
||||
already finished. Only fetches chunks that failed or
|
||||
haven't been attempted yet.
|
||||
"""
|
||||
config = self._get_active_config()
|
||||
if not config:
|
||||
_logger.warning("RC Historical Import: No connected config found.")
|
||||
return
|
||||
|
||||
try:
|
||||
config._ensure_token()
|
||||
except Exception:
|
||||
_logger.exception("RC Historical Import: Token invalid, aborting.")
|
||||
return
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
CallHistory = self.env['rc.call.history']
|
||||
now = datetime.utcnow()
|
||||
total_imported = 0
|
||||
total_skipped_chunks = 0
|
||||
failed_chunks = 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.import_done_{chunk_start.strftime("%Y%m")}'
|
||||
already_done = ICP.get_param(chunk_key, '')
|
||||
if already_done:
|
||||
_logger.info(
|
||||
"RC Import [%d/12]: %s to %s -- already imported, skipping.",
|
||||
chunk_num, date_from[:10], date_to[:10],
|
||||
)
|
||||
total_skipped_chunks += 1
|
||||
continue
|
||||
|
||||
_logger.info(
|
||||
"RC Import [%d/12]: %s to %s ...",
|
||||
chunk_num, date_from[:10], date_to[:10],
|
||||
)
|
||||
try:
|
||||
chunk_count = CallHistory._sync_calls_from_date(config, date_from, date_to)
|
||||
total_imported += chunk_count
|
||||
ICP.set_param(chunk_key, fields.Datetime.now().isoformat())
|
||||
_logger.info(
|
||||
"RC Import [%d/12]: %d calls imported.",
|
||||
chunk_num, chunk_count,
|
||||
)
|
||||
except Exception:
|
||||
failed_chunks += 1
|
||||
_logger.exception(
|
||||
"RC Import [%d/12]: Failed, will retry next run.",
|
||||
chunk_num,
|
||||
)
|
||||
|
||||
ICP.set_param(
|
||||
'fusion_rc.last_call_sync',
|
||||
now.strftime('%Y-%m-%dT%H:%M:%S.000Z'),
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"RC Historical Import complete: %d imported, %d chunks skipped (already done), %d failed.",
|
||||
total_imported, total_skipped_chunks, failed_chunks,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Cron: token refresh
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _cron_refresh_tokens(self):
|
||||
"""Refresh tokens for all connected configs nearing expiry."""
|
||||
soon = fields.Datetime.now() + timedelta(minutes=10)
|
||||
configs = self.search([
|
||||
('state', '=', 'connected'),
|
||||
('token_expiry', '<=', soon),
|
||||
('refresh_token', '!=', False),
|
||||
])
|
||||
for cfg in configs:
|
||||
try:
|
||||
cfg._refresh_token()
|
||||
except Exception:
|
||||
_logger.exception("Cron: Failed to refresh token for config %s", cfg.name)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Cron: webhook renewal
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@api.model
|
||||
def _cron_renew_webhooks(self):
|
||||
"""Renew webhook subscriptions for all connected configs."""
|
||||
configs = self.search([('state', '=', 'connected')])
|
||||
for cfg in configs:
|
||||
try:
|
||||
cfg._renew_webhook_subscription()
|
||||
except Exception:
|
||||
_logger.exception("Cron: Failed to renew webhook for config %s", cfg.name)
|
||||
Reference in New Issue
Block a user