# Copyright 2018 Camptocamp (https://www.camptocamp.com). # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html) import logging from functools import partialmethod from lxml import etree from odoo import api, fields, models from odoo.addons.base_sparse_field.models.fields import Serialized from ..server_env import serv_config _logger = logging.getLogger(__name__) class _partialmethod(partialmethod): """Custom implementation of partialmethod. ``odoo.fields.determine`` requires inverse methods to have ``__name__`` attribute. Unfortunately with ``partialmethod`` this attribute is not propagated even by using ``functools.update_wrapper``. Introduced by https://github.com/odoo/odoo/commit/36544651f2049bcf18777091dbf02c9631b33243 """ def __init__(self, func, *args, **keywords): self.__name__ = keywords.pop("__name__", None) super().__init__(func, *args, **keywords) def __get__(self, obj, cls=None): res = super().__get__(obj, cls=cls) if self.__name__ is not None: res.__name__ = self.__name__ return res class ServerEnvMixin(models.AbstractModel): """Mixin to add server environment in existing models Usage ----- :: class StorageBackend(models.Model): _name = "storage.backend" _inherit = ["storage.backend", "server.env.mixin"] @property def _server_env_fields(self): return {"directory_path": {}} With the snippet above, the "storage.backend" model now uses a server environment configuration for the field ``directory_path``. Under the hood, this mixin automatically replaces the original field by an env-computed field that reads from the configuration files. By default, it looks for the configuration in a section named ``[model_name.Record Name]`` where ``model_name`` is the ``_name`` of the model with ``.`` replaced by ``_``. Then in a global section which is only the name of the model. They can be customized by overriding the method :meth:`~_server_env_section_name` and :meth:`~_server_env_global_section_name`. For each field transformed to an env-computed field, a companion field ``_env_default`` is automatically created. When its value is set and the configuration files do not contain a key for that field, the env-computed field uses the default value stored in database. If there is a key for this field but it is empty, the env-computed field has an empty value. Env-computed fields are conditionally editable, based on the absence of their key in environment configuration files. When edited, their value is stored in the database. Integration with keychain ------------------------- The keychain addon is used account information, encrypting the password with a key per environment. The default behavior of server_environment is to store the default fields in a serialized field, so the password would lend there unencrypted. You can benefit from keychain by using custom compute/inverse methods to get/set the password field: :: class StorageBackend(models.Model): _name = 'storage.backend' _inherit = ['keychain.backend', 'collection.base'] @property def _server_env_fields(self): base_fields = super()._server_env_fields sftp_fields = { "sftp_server": {}, "sftp_port": {}, "sftp_login": {}, "sftp_password": { "no_default_field": True, "compute_default": "_compute_password", "inverse_default": "_inverse_password", }, } sftp_fields.update(base_fields) return sftp_fields * ``no_default_field`` means that no new (sparse) field need to be created, it already is provided by keychain * ``compute_default`` is the name of the compute method to get the default value when no key is set in the configuration files. ``_compute_password`` is implemented by ``keychain.backend``. * ``inverse_default`` is the name of the compute method to set the default value when it is editable. ``_inverse_password`` is implemented by ``keychain.backend``. """ _name = "server.env.mixin" _description = "Mixin to add server environment in existing models" server_env_defaults = Serialized() _server_env_getter_mapping = { "integer": "getint", "float": "getfloat", "monetary": "getfloat", "boolean": "getboolean", "char": "get", "selection": "get", "text": "get", } @property def _server_env_fields(self): """Dict of fields to replace by fields computed from env To override in models. The dictionary is: {'name_of_the_field': options} Where ``options`` is a dictionary:: options = { "getter": "getint", "no_default_field": True, "compute_default": "_compute_password", "inverse_default": "_inverse_password", } * ``getter``: The configparser getter can be one of: get, getboolean, getint, getfloat. The getter is automatically inferred from the type of the field, so it shouldn't generally be needed to set it. * ``no_default_field``: disable creation of a field for storing the default value, must be used with ``compute_default`` and ``inverse_default`` * ``compute_default``: name of a compute method to get the default value when no key is present in configuration files * ``inverse_default``: name of an inverse method to set the default value when the value is editable Example:: @property def _server_env_fields(self): base_fields = super()._server_env_fields sftp_fields = { "sftp_server": {}, "sftp_port": {}, "sftp_login": {}, "sftp_password": {}, } sftp_fields.update(base_fields) return sftp_fields """ return {} @api.model def _server_env_global_section_name(self): """Name of the global section in the configuration files Can be customized in your model """ return self._name.replace(".", "_") _server_env_section_name_field = "name" def _server_env_section_name(self): """Name of the section in the configuration files Can be customized in your model """ self.ensure_one() val = self[self._server_env_section_name_field] if not val: # special case: we have onchanges relying on tech_name # and we are testing them using `tests.common.Form`. # when the for is initialized there's no value yet. return base = self._server_env_global_section_name() return ".".join((base, val)) def _server_env_read_from_config(self, field_name, config_getter): self.ensure_one() global_section_name = self._server_env_global_section_name() section_name = self._server_env_section_name() try: # at this point we should have checked that we have a key with # _server_env_has_key_defined so we are sure that the value is # either in the global or the record config getter = getattr(serv_config, config_getter) if section_name in serv_config and field_name in serv_config[section_name]: value = getter(section_name, field_name) else: value = getter(global_section_name, field_name) except Exception as e: _logger.error( "Unable to read field %s in section %s: %s", field_name, section_name, e ) return False return value def _server_env_has_key_defined(self, field_name): self.ensure_one() global_section_name = self._server_env_global_section_name() section_name = self._server_env_section_name() has_global_config = ( global_section_name in serv_config and field_name in serv_config[global_section_name] ) has_config = ( section_name in serv_config and field_name in serv_config[section_name] ) return has_global_config or has_config def _compute_server_env_from_config(self, field_name, options): getter_name = options.get("getter") if options else None if not getter_name: field_type = self._fields[field_name].type getter_name = self._server_env_getter_mapping.get(field_type) if not getter_name: # if you get this message and the field is working as expected, # you may want to add the type in _server_env_getter_mapping _logger.warning( "server.env.mixin is used on a field of type %s, " "which may not be supported properly" ) getter_name = "get" value = self._server_env_read_from_config(field_name, getter_name) self[field_name] = value def _compute_server_env_from_default(self, field_name, options): if options and options.get("compute_default"): getattr(self, options["compute_default"])() else: default_field = self._server_env_default_fieldname(field_name) if default_field: self[field_name] = self[default_field] else: self[field_name] = False def _compute_server_env(self): """Read values from environment configuration files If an env-computed field has no key in configuration files, read from the ``_env_default`` field from database. """ for record in self: for field_name, options in self._server_env_fields.items(): if record._server_env_has_key_defined(field_name): record._compute_server_env_from_config(field_name, options) else: record._compute_server_env_from_default(field_name, options) def _inverse_server_env(self, field_name): options = self._server_env_fields[field_name] default_field = self._server_env_default_fieldname(field_name) is_editable_field = self._server_env_is_editable_fieldname(field_name) for record in self: # when we write in an env-computed field, if it is editable # we update the default value in database if record[is_editable_field]: if options and options.get("inverse_default"): getattr(record, options["inverse_default"])() elif default_field: record[default_field] = record[field_name] def _compute_server_env_is_editable(self): """Compute _is_editable values We can edit an env-computed filed only if there is no key in any environment configuration file. If there is an empty key, it's an empty value so we can't edit the env-computed field. """ # we can't group it with _compute_server_env otherwise when called # in ``_inverse_server_env`` it would reset the value of the field for record in self: for field_name in self._server_env_fields: is_editable_field = self._server_env_is_editable_fieldname(field_name) is_editable = not record._server_env_has_key_defined(field_name) record[is_editable_field] = is_editable def _server_env_view_set_readonly(self, view_arch): field_xpath = './/field[@name="%s"]' for field in self._server_env_fields: is_editable_field = self._server_env_is_editable_fieldname(field) for elem in view_arch.findall(field_xpath % field): # set env-computed fields to readonly if the configuration # files have a key set for this field elem.set("attrs", str({"readonly": [(is_editable_field, "=", False)]})) if not view_arch.findall(field_xpath % is_editable_field): # add the _is_editable fields in the view for the 'attrs' # domain view_arch.append( etree.Element("field", name=is_editable_field, invisible="1") ) return view_arch def _fields_view_get( self, view_id=None, view_type="form", toolbar=False, submenu=False ): view_data = super()._fields_view_get( view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu ) view_arch = etree.fromstring(view_data["arch"].encode("utf-8")) view_arch = self._server_env_view_set_readonly(view_arch) view_data["arch"] = etree.tostring(view_arch, encoding="unicode") return view_data def _server_env_default_fieldname(self, base_field_name): """Return the name of the field with default value""" options = self._server_env_fields[base_field_name] if options and options.get("no_default_field"): return "" return "{}_env_default".format(base_field_name) def _server_env_is_editable_fieldname(self, base_field_name): """Return the name of the field for "is editable" This is the field used to tell if the env-computed field can be edited. """ return "{}_env_is_editable".format(base_field_name) def _server_env_transform_field_to_read_from_env(self, field): """Transform the original field in a computed field""" field.compute = "_compute_server_env" inverse_method_name = "_inverse_server_env_%s" % field.name inverse_method = _partialmethod( type(self)._inverse_server_env, field.name, __name__=inverse_method_name ) setattr(type(self), inverse_method_name, inverse_method) field.inverse = inverse_method_name field.store = False field.required = False field.copy = False field.sparse = None field.prefetch = False def _server_env_add_is_editable_field(self, base_field): """Add a field indicating if we can edit the env-computed fields It is used in the inverse function of the env-computed field and in the views to add 'readonly' on the fields. """ fieldname = self._server_env_is_editable_fieldname(base_field.name) # if the field is inherited, it's a related to its delegated model # (inherits), we want to override it with a new one if fieldname not in self._fields or self._fields[fieldname].inherited: field = fields.Boolean( compute="_compute_server_env_is_editable", automatic=True, # this is required to be able to edit fields # on new records default=True, ) self._add_field(fieldname, field) def _server_env_add_default_field(self, base_field): """Add a field storing the default value The default value is used when there is no key for an env-computed field in the configuration files. The field is a sparse field stored in the serialized (json) field ``server_env_defaults``. """ fieldname = self._server_env_default_fieldname(base_field.name) if not fieldname: return # if the field is inherited, it's a related to its delegated model # (inherits), we want to override it with a new one if fieldname not in self._fields or self._fields[fieldname].inherited: base_field_cls = base_field.__class__ field_args = base_field.args.copy() field_args.pop("_sequence", None) field_args.update({"sparse": "server_env_defaults", "automatic": True}) if hasattr(base_field, "selection"): field_args["selection"] = base_field.selection field = base_field_cls(**field_args) self._add_field(fieldname, field) @api.model def _setup_base(self): super()._setup_base() for fieldname in self._server_env_fields: field = self._fields[fieldname] self._server_env_add_default_field(field) self._server_env_transform_field_to_read_from_env(field) self._server_env_add_is_editable_field(field)