server-env/server_environment/models/server_env_mixin.py

407 lines
16 KiB
Python

# Copyright 2018 Camptocamp (https://www.camptocamp.com).
# License GPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from functools import partialmethod
from lxml import etree
from odoo import api, fields, models
from ..server_env import serv_config
_logger = logging.getLogger(__name__)
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
``<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 = fields.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(".", "_")
def _server_env_section_name(self):
"""Name of the section in the configuration files
Can be customized in your model
"""
self.ensure_one()
base = self._server_env_global_section_name()
return ".".join((base, self.name))
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:
_logger.exception(
"error trying to read field %s in section %s",
field_name,
section_name,
)
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 ``<field>_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 <field>_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 '%s_env_default' % (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 '%s_env_is_editable' % (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
)
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)