Files
shcool/odoosh_connector/models/res_users.py
T
2026-09-11 16:45:58 +08:00

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},
}