Files
shcool/odoosh_connector/models/odoosh_client.py
T
2026-09-10 19:36:08 +08:00

176 lines
7.8 KiB
Python

# -*- coding: utf-8 -*-
"""The single place that talks to the OdooshCN platform.
Keeping every outbound request here means retry classification, timeouts and error
decoding have one implementation, and the access token is read in this file only.
Errors come in two kinds, which decide whether the queue retries or gives up:
* OdooshRetryable - network trouble, rate limiting, platform 5xx. Worth retrying.
* OdooshPermanent - bad parameters, name already taken, beyond the token limits.
Retrying cannot help; a human has to look at it.
"""
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from odoo import _, models
_logger = logging.getLogger(__name__)
TIMEOUT = 20 # seconds; syncing runs in a background queue, never in a user request
PARAM_URL = 'odoosh.base_url'
PARAM_TOKEN = 'odoosh.token'
class OdooshError(Exception):
"""A call to OdooshCN failed. `code` is the platform's stable error code."""
def __init__(self, message, code='unknown', status=None):
super().__init__(message)
self.message = message
self.code = code
self.status = status
def __str__(self):
return '[%s] %s' % (self.code, self.message)
class OdooshRetryable(OdooshError):
"""Temporary failure, worth retrying."""
class OdooshPermanent(OdooshError):
"""The caller's own problem; retrying will not help."""
class OdooshClient(models.AbstractModel):
_name = 'odoosh.client'
_description = 'OdooshCN API Client'
# ------------------------------------------------------------------ configuration
def _config(self):
"""Read the platform URL and the token. Read here only, and always as sudo:
regular users have no access to system parameters."""
icp = self.env['ir.config_parameter'].sudo()
base = (icp.get_param(PARAM_URL) or '').strip().rstrip('/')
token = (icp.get_param(PARAM_TOKEN) or '').strip()
if not base or not token:
raise OdooshPermanent(
_("The OdooshCN platform URL or access token is not configured yet. "
"Go to Settings > General Settings > OdooshCN Lab Environments."),
code='not_configured')
return self._prefer_ipv4(base), token
@staticmethod
def _prefer_ipv4(base):
"""Local development trap on Windows: `localhost` resolves to the IPv6 address
::1 first, while WSL2 and Docker only forward IPv4. urllib has no happy-eyeballs
fallback, so it waits out the full timeout (20s) on every single request.
Swapping localhost for 127.0.0.1 avoids it. Real deployments use a domain name."""
parts = urllib.parse.urlsplit(base)
if parts.hostname and parts.hostname.lower() == 'localhost':
netloc = '127.0.0.1' + (':%s' % parts.port if parts.port else '')
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
return base
def _enabled(self):
return self.env['ir.config_parameter'].sudo().get_param('odoosh.enabled') in ('True', 'true', '1', True)
# ------------------------------------------------------------------ request
def _request(self, method, path, payload=None):
"""Send one request and return the decoded body.
Raises OdooshRetryable / OdooshPermanent on failure."""
base, token = self._config()
url = '%s%s' % (base, path)
data = None
headers = {'Authorization': 'Bearer %s' % token, 'Accept': 'application/json'}
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
headers['Content-Type'] = 'application/json'
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode('utf-8') or '{}')
except urllib.error.HTTPError as err:
raise self._http_error(err, method, path)
except urllib.error.URLError as err:
# DNS failure, refused connection, timeout: the platform may just be restarting
raise OdooshRetryable(_("Cannot reach OdooshCN (%(url)s): %(reason)s",
url=base, reason=err.reason), code='unreachable')
except json.JSONDecodeError:
raise OdooshRetryable(_("OdooshCN returned a body that is not valid JSON"), code='bad_response')
def _http_error(self, err, method, path):
"""Turn an HTTP error into a coded exception and decide whether it is worth retrying."""
raw = ''
try:
raw = err.read().decode('utf-8')
except Exception: # noqa: BLE001
pass
code, detail = 'http_%s' % err.code, raw or err.reason
try:
parsed = json.loads(raw or '{}')
code = parsed.get('code') or code
d = parsed.get('detail')
if isinstance(d, list): # 422 validation errors come as a list
d = '; '.join(str(x.get('msg') or x) for x in d)
detail = d or detail
except (ValueError, AttributeError):
pass
_logger.warning("OdooshCN %s %s -> %s %s", method, path, err.code, detail)
exc = OdooshRetryable if err.code in (408, 429) or err.code >= 500 else OdooshPermanent
return exc(detail, code=code, status=err.code)
# ------------------------------------------------------------------ endpoints
def ping(self):
"""Connectivity probe: validates URL and token only, no side effect.
Returns the token's source, organisation, scopes and limits."""
return self._request('GET', '/api/ext/ping')
def upsert_user(self, payload):
"""Create or update a user, keyed by external_id and idempotent.
`payload['provision']` additionally asks for a lab environment."""
return self._request('POST', '/api/ext/users', payload)
def deactivate_user(self, external_id, stop_envs=True):
"""Deactivate the platform account. Nothing is deleted."""
return self._request('POST', '/api/ext/users/deactivate',
{'external_id': str(external_id), 'stop_envs': stop_envs})
def delete_user(self, external_id, purge_envs=False):
"""Delete the platform account for good. Requires the token to allow deletion.
The user's environments go to the platform recycle bin unless purge_envs is set."""
return self._request('POST', '/api/ext/users/delete',
{'external_id': str(external_id), 'purge_envs': purge_envs})
def reconcile(self, external_ids, stop_envs=True, confirm=False):
"""Full reconciliation: accounts of this source that are not in the list get
deactivated. The platform refuses an obviously incomplete list unless confirmed."""
return self._request('POST', '/api/ext/users/reconcile',
{'external_ids': [str(x) for x in external_ids],
'stop_envs': stop_envs, 'confirm': confirm})
def list_envs(self, uid):
"""List the lab environments of one user."""
return self._request('GET', '/api/ext/envs?uid=%s' % urllib.parse.quote(str(uid)))
def create_env(self, uid, spec):
"""Provision one lab environment for a user."""
body = dict(spec or {})
body['uid'] = str(uid)
return self._request('POST', '/api/ext/envs', body)
def handoff(self, uid, target='console', env=None):
"""Exchange for a passwordless entry URL: {'url': ..., 'expires_in': ...}.
The URL is short lived and single use: redirect to it straight away, never
store it and never log it."""
body = {'uid': str(uid), 'target': target}
if env:
body['env'] = env
return self._request('POST', '/api/ext/handoff', body)