350 lines
17 KiB
Python
350 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""User synchronisation: the hooks, and the platform permission fields on the user.
|
|
|
|
There are only three rules, all implemented here:
|
|
1. Once the platform URL and token are configured, creating a user syncs it right away
|
|
and turns that user's sync switch on.
|
|
2. Updating a user syncs when the switch is on and does nothing when it is off.
|
|
3. Archiving deactivates the platform account, deleting deletes it, and restoring an
|
|
archived user activates it again.
|
|
|
|
Everything the platform knows about a user - role, organisation, quotas, switches, menu
|
|
visibility - is a field on the user form, so there is no hidden policy in the code.
|
|
|
|
Syncing never issues HTTP inside a request: the hooks only write a local queue row and a
|
|
dedicated thread pushes after commit (see odoosh_sync_log.py).'
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
from odoo import _, api, fields, models
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Fields that end up in the payload; only these are worth a push
|
|
POLICY_FIELDS = (
|
|
'odoosh_username', 'odoosh_role', 'odoosh_tenant', 'odoosh_tenant_role',
|
|
'odoosh_shell_enabled', 'odoosh_can_use_enterprise', 'odoosh_auto_provision',
|
|
'odoosh_max_envs', 'odoosh_max_dbs',
|
|
'odoosh_menu_dashboard', 'odoosh_menu_repos', 'odoosh_menu_ai', 'odoosh_menu_backups',
|
|
)
|
|
WATCHED_FIELDS = {'login', 'name', 'active', 'odoosh_sync_enabled', *POLICY_FIELDS}
|
|
# Platform username rule: starts alphanumeric, may contain . _ - , 2 to 64 characters
|
|
USERNAME_SAFE = re.compile(r'[^A-Za-z0-9_.-]')
|
|
# Built-in accounts that are never synced: OdooBot, the public user, the portal template
|
|
# and the new-user default template. They are not people. Administrator is a real login
|
|
# and is synced like anybody else.
|
|
SYSTEM_XMLIDS = ('base.user_root', 'base.public_user', 'base.template_user', 'base.default_user')
|
|
|
|
|
|
class ResUsers(models.Model):
|
|
_inherit = 'res.users'
|
|
|
|
# ------------------------------------------------------------------ sync control and status
|
|
odoosh_sync_enabled = fields.Boolean(
|
|
string='Sync to lab platform', default=True, index=True,
|
|
help="When off, nothing about this user is pushed: no updates, and archiving or "
|
|
"deleting leaves the platform account untouched.")
|
|
odoosh_sync_state = fields.Selection([
|
|
('none', 'Not synced'),
|
|
('pending', 'Pending'),
|
|
('synced', 'Synced'),
|
|
('failed', 'Failed'),
|
|
], string='Sync status', default='none', readonly=True, copy=False, index=True)
|
|
odoosh_synced_at = fields.Datetime(string='Last synced', readonly=True, copy=False)
|
|
odoosh_last_error = fields.Text(string='Sync error', readonly=True, copy=False)
|
|
odoosh_payload_hash = fields.Char(string='Payload fingerprint', readonly=True, copy=False,
|
|
help="Fingerprint of the last successful push; identical content is not resent.")
|
|
odoosh_platform_user_id = fields.Integer(string='Platform user ID', readonly=True, copy=False)
|
|
odoosh_platform_username = fields.Char(string='Platform username', readonly=True, copy=False,
|
|
help="The username actually in use on the platform.")
|
|
|
|
# ------------------------------------------------------------------ platform permissions
|
|
odoosh_username = fields.Char(
|
|
string='Platform username override', copy=False,
|
|
help="Leave empty to derive it from the login (the part before @, cleaned up). Must be unique on the platform.")
|
|
odoosh_role = fields.Selection([
|
|
('member', 'Regular user'),
|
|
('admin', 'Platform administrator'),
|
|
], string='Platform role', default='member', required=True,
|
|
help="A platform administrator sees every organisation. Requires the access token to allow it.")
|
|
odoosh_tenant = fields.Char(
|
|
string='Organisation', copy=False,
|
|
help="Slug of the platform organisation. Empty falls back to the default in the settings, "
|
|
"then to the organisation the token is bound to. Another organisation requires the token to allow it.")
|
|
odoosh_tenant_role = fields.Selection([
|
|
('tester', 'Tester (open instances only)'),
|
|
('developer', 'Developer (edit code, read logs, reset)'),
|
|
('owner', 'Organisation manager (see every instance)'),
|
|
], string='Role in organisation', default='developer', required=True,
|
|
help="Must not exceed the access token's maximum grantable role.")
|
|
odoosh_shell_enabled = fields.Boolean(string='Web Shell', default=True,
|
|
help="Allow opening a terminal on the instance, needed for psql.")
|
|
odoosh_can_use_enterprise = fields.Boolean(string='Enterprise edition', default=False,
|
|
help="Allow creating enterprise edition instances.")
|
|
odoosh_auto_provision = fields.Boolean(string='Auto-provision environment', default=True,
|
|
help="On sync, create a lab environment if this user has none yet.")
|
|
odoosh_max_envs = fields.Integer(string='Instance limit', default=1, help="0 means unlimited.")
|
|
odoosh_max_dbs = fields.Integer(string='Extra database limit', default=2, help="0 means unlimited.")
|
|
odoosh_menu_dashboard = fields.Boolean(string='Menu: Overview', default=False)
|
|
odoosh_menu_repos = fields.Boolean(string='Menu: Git repositories', default=False)
|
|
odoosh_menu_ai = fields.Boolean(string='Menu: AI assistant', default=True)
|
|
odoosh_menu_backups = fields.Boolean(string='Menu: Backups', default=False)
|
|
|
|
# Readable by the user themselves on the preferences page, but not writable
|
|
@property
|
|
def SELF_READABLE_FIELDS(self):
|
|
return super().SELF_READABLE_FIELDS + ['odoosh_sync_state', 'odoosh_synced_at', 'odoosh_platform_username']
|
|
|
|
# ------------------------------------------------------------------ scope
|
|
@api.model
|
|
def _odoosh_configured(self):
|
|
"""Hooks stay silent until the URL, the token and the master switch are all set."""
|
|
icp = self.env['ir.config_parameter'].sudo()
|
|
return (icp.get_param('odoosh.enabled') in ('True', 'true', '1', True)
|
|
and bool((icp.get_param('odoosh.base_url') or '').strip())
|
|
and bool((icp.get_param('odoosh.token') or '').strip()))
|
|
|
|
@api.model
|
|
def _odoosh_scope_ctx(self):
|
|
"""Constants shared by a whole batch: the ids of the built-in accounts."""
|
|
system_ids = set()
|
|
for xmlid in SYSTEM_XMLIDS:
|
|
rec = self.env.ref(xmlid, raise_if_not_found=False)
|
|
if rec:
|
|
system_ids.add(rec.id)
|
|
return {'system_ids': system_ids}
|
|
|
|
def _odoosh_eligible(self, ctx=None):
|
|
"""Accounts that may be synced: internal users that are not built-in (switch ignored)."""
|
|
ctx = ctx or self._odoosh_scope_ctx()
|
|
system_ids = ctx['system_ids']
|
|
return self.sudo().filtered(lambda u: not u.share and u.id not in system_ids)
|
|
|
|
def _odoosh_filter_in_scope(self, ctx=None):
|
|
"""Users to sync: eligible and switch on."""
|
|
return self._odoosh_eligible(ctx).filtered('odoosh_sync_enabled')
|
|
|
|
def _odoosh_in_scope(self, ctx=None):
|
|
self.ensure_one()
|
|
return bool(self._odoosh_filter_in_scope(ctx))
|
|
|
|
# ------------------------------------------------------------------ payload
|
|
def _odoosh_username_auto(self):
|
|
"""Derive the platform username from the login: the part before @, cleaned up."""
|
|
self.ensure_one()
|
|
raw = (self.login or '').split('@')[0]
|
|
cleaned = USERNAME_SAFE.sub('', raw) or ('u%s' % self.id)
|
|
if not cleaned[0].isalnum():
|
|
cleaned = 'u' + cleaned
|
|
if len(cleaned) < 2:
|
|
cleaned = 'u%s' % self.id
|
|
return cleaned[:64]
|
|
|
|
def _odoosh_tenant_value(self):
|
|
icp = self.env['ir.config_parameter'].sudo()
|
|
return (self.odoosh_tenant or '').strip() or (icp.get_param('odoosh.tenant') or '').strip() or None
|
|
|
|
def _odoosh_payload(self, ctx=None):
|
|
"""What is sent to the platform; every value comes from a field on this user."""
|
|
self.ensure_one()
|
|
payload = {
|
|
'external_id': str(self.id),
|
|
'username': (self.odoosh_username or '').strip() or self._odoosh_username_auto(),
|
|
'display_name': self.name or self.login,
|
|
'active': bool(self.active),
|
|
'role': self.odoosh_role or 'member',
|
|
'tenant': self._odoosh_tenant_value(),
|
|
'tenant_role': self.odoosh_tenant_role or 'developer',
|
|
'shell_enabled': bool(self.odoosh_shell_enabled),
|
|
'can_use_enterprise': bool(self.odoosh_can_use_enterprise),
|
|
'quota': {
|
|
'max_envs': None if not self.odoosh_max_envs else int(self.odoosh_max_envs), # 0 = unlimited
|
|
'max_dbs': None if not self.odoosh_max_dbs else int(self.odoosh_max_dbs),
|
|
},
|
|
'menus': {
|
|
'dashboard': bool(self.odoosh_menu_dashboard),
|
|
'repos': bool(self.odoosh_menu_repos),
|
|
'ai': bool(self.odoosh_menu_ai),
|
|
'backups': bool(self.odoosh_menu_backups),
|
|
},
|
|
}
|
|
if self.odoosh_auto_provision and self.active:
|
|
payload['provision'] = self._odoosh_env_spec()
|
|
return payload
|
|
|
|
def _odoosh_env_spec(self):
|
|
"""Specification of the auto-provisioned environment: named after the platform
|
|
username, everything else from the settings."""
|
|
self.ensure_one()
|
|
icp = self.env['ir.config_parameter'].sudo()
|
|
spec = {
|
|
'name': (self.odoosh_username or '').strip() or self._odoosh_username_auto(),
|
|
'tenant': self._odoosh_tenant_value(),
|
|
'kind': 'dev',
|
|
'odoo_version': icp.get_param('odoosh.default_version') or '18',
|
|
'edition': 'community',
|
|
'lang': icp.get_param('odoosh.default_lang') or 'en_US',
|
|
}
|
|
for key, param, cast in (('mem_mb', 'odoosh.default_mem_mb', int),
|
|
('sleep_idle_hours', 'odoosh.default_sleep_hours', float)):
|
|
raw = icp.get_param(param)
|
|
if raw:
|
|
try:
|
|
spec[key] = cast(raw)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return spec
|
|
|
|
def _odoosh_payload_hash(self, payload=None, ctx=None):
|
|
self.ensure_one()
|
|
data = payload if payload is not None else self._odoosh_payload(ctx)
|
|
raw = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(',', ':'))
|
|
return hashlib.sha1(raw.encode('utf-8')).hexdigest()
|
|
|
|
# ------------------------------------------------------------------ hooks
|
|
def _odoosh_kick(self):
|
|
dbname = self.env.cr.dbname
|
|
self.env.cr.postcommit.add(lambda: self.env['odoosh.sync.log'].kick_async(dbname))
|
|
|
|
def _odoosh_enqueue(self, operation):
|
|
"""Queue and let the post-commit thread push. Local writes only; every exception is
|
|
swallowed and logged, because syncing must never block user management."""
|
|
if self.env.context.get('odoosh_no_sync'):
|
|
return
|
|
try:
|
|
if not self._odoosh_configured():
|
|
return
|
|
targets = self._odoosh_filter_in_scope()
|
|
if not targets:
|
|
return
|
|
self.env['odoosh.sync.log'].sudo().enqueue(targets, operation)
|
|
targets.with_context(odoosh_no_sync=True).write({'odoosh_sync_state': 'pending'})
|
|
self._odoosh_kick()
|
|
except Exception: # noqa: BLE001
|
|
_logger.exception("OdooshCN enqueue failed (ignored, user operation unaffected)")
|
|
|
|
def _odoosh_enqueue_known(self, operation):
|
|
"""Deactivate or delete: only for users whose switch is on and that the platform knows."""
|
|
if self.env.context.get('odoosh_no_sync'):
|
|
return
|
|
try:
|
|
if not self._odoosh_configured():
|
|
return
|
|
known = self.sudo().filtered(
|
|
lambda u: u.odoosh_sync_enabled and u.odoosh_sync_state in ('synced', 'pending', 'failed'))
|
|
if not known:
|
|
return
|
|
queue = self.env['odoosh.sync.log'].sudo()
|
|
if operation == 'delete':
|
|
queue.enqueue_delete(known)
|
|
else:
|
|
queue.enqueue(known, operation)
|
|
self._odoosh_kick()
|
|
except Exception: # noqa: BLE001
|
|
_logger.exception("OdooshCN %s enqueue failed (ignored)", operation)
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
users = super().create(vals_list)
|
|
if self._odoosh_configured():
|
|
# Configured: sync immediately and switch the new users on
|
|
off = users._odoosh_eligible().filtered(lambda u: not u.odoosh_sync_enabled)
|
|
if off:
|
|
off.with_context(odoosh_no_sync=True).write({'odoosh_sync_enabled': True})
|
|
users._odoosh_enqueue('upsert')
|
|
return users
|
|
|
|
def write(self, vals):
|
|
result = super().write(vals)
|
|
if self.env.context.get('odoosh_no_sync') or not (WATCHED_FIELDS & set(vals)):
|
|
return result
|
|
if 'odoosh_sync_enabled' in vals and not vals['odoosh_sync_enabled']:
|
|
return result # just switched off: leave the platform as it is
|
|
todo = self.sudo().filtered('odoosh_sync_enabled')
|
|
if not todo:
|
|
return result # switch off: nothing to do
|
|
if 'active' in vals and not vals['active']:
|
|
todo._odoosh_enqueue_known('deactivate') # archived -> deactivate on the platform
|
|
else:
|
|
todo._odoosh_enqueue('upsert') # edited, or restored from the archive
|
|
return result
|
|
|
|
def unlink(self):
|
|
# Deleted here means deleted there. The platform moves the user's environments to
|
|
# its recycle bin first, so the data stays recoverable for a while.
|
|
self.sudo().filtered('odoosh_sync_enabled')._odoosh_enqueue_known('delete')
|
|
return super().unlink()
|
|
|
|
# ------------------------------------------------------------------ status write-back
|
|
def _odoosh_mark(self, state, error=None, payload_hash=None, result=None):
|
|
"""Called by the sync thread. The odoosh_no_sync context stops it from looping."""
|
|
vals = {'odoosh_sync_state': state, 'odoosh_last_error': error or False}
|
|
if state == 'synced':
|
|
vals['odoosh_synced_at'] = fields.Datetime.now()
|
|
if payload_hash:
|
|
vals['odoosh_payload_hash'] = payload_hash
|
|
if result:
|
|
if result.get('user_id'):
|
|
vals['odoosh_platform_user_id'] = int(result['user_id'])
|
|
if result.get('username'):
|
|
vals['odoosh_platform_username'] = result['username']
|
|
self.sudo().with_context(odoosh_no_sync=True).write(vals)
|
|
|
|
# ------------------------------------------------------------------ actions
|
|
def action_odoosh_sync_now(self):
|
|
"""Sync the selected users straight away, without waiting for the queue."""
|
|
queue = self.env['odoosh.sync.log'].sudo()
|
|
if not self._odoosh_configured():
|
|
return self._odoosh_notify(
|
|
_("Not configured"),
|
|
_("Set the platform URL and access token in Settings > OdooshCN Lab Environments first."),
|
|
'warning')
|
|
targets = self._odoosh_filter_in_scope()
|
|
if not targets:
|
|
ctx = self._odoosh_scope_ctx()
|
|
reasons = []
|
|
for u in self.sudo():
|
|
if u.id in ctx['system_ids']:
|
|
reasons.append(_("%s: built-in Odoo account, never synced", u.login))
|
|
elif u.share:
|
|
reasons.append(_("%s: portal user, never synced", u.login))
|
|
elif not u.odoosh_sync_enabled:
|
|
reasons.append(_("%s: sync switch is off", u.login))
|
|
return self._odoosh_notify(_("Nothing to sync"),
|
|
'\n'.join(reasons) or _("None of the selected users is in scope."),
|
|
'warning')
|
|
logs = queue.enqueue(targets, 'upsert', force=True)
|
|
ok = sum(1 for log in logs if log._process_one())
|
|
return self._odoosh_notify(
|
|
_("Sync finished"),
|
|
_("%(ok)s of %(total)s succeeded. Open the sync queue to see why the others failed.",
|
|
ok=ok, total=len(logs)),
|
|
'success' if ok == len(logs) else 'warning')
|
|
|
|
def action_odoosh_enable_sync(self):
|
|
self.write({'odoosh_sync_enabled': True})
|
|
return self._odoosh_notify(_("Sync enabled"),
|
|
_("%s users will be pushed within a few seconds.", len(self)), 'success')
|
|
|
|
def action_odoosh_disable_sync(self):
|
|
self.with_context(odoosh_no_sync=True).write({'odoosh_sync_enabled': False})
|
|
return self._odoosh_notify(_("Sync disabled"),
|
|
_("%s users will no longer be pushed; their platform accounts stay as they are.",
|
|
len(self)), 'info')
|
|
|
|
def action_odoosh_envs(self):
|
|
self.ensure_one()
|
|
return self.env['odoosh.client'].list_envs(str(self.id)).get('envs', [])
|
|
|
|
@staticmethod
|
|
def _odoosh_notify(title, message, kind='info'):
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {'title': title, 'message': message, 'type': kind, 'sticky': False},
|
|
}
|