diff options
| author | Raphael Collet <rco@openerp.com> | 2016-09-02 15:58:39 +0200 |
|---|---|---|
| committer | Raphael Collet <rco@openerp.com> | 2016-09-02 17:28:12 +0200 |
| commit | 9e64f9f9514194d74e166b032add8511182f2677 (patch) | |
| tree | 5086710c04c6b923be18a6d1d97b3f0f3a2a9c4a /odoo/fields.py | |
| parent | 11812b0b9e82b2b1b9041d8250ce01e7b4a5f7ee (diff) | |
[REF] openerp: move `openerp` to `odoo`
Diffstat (limited to 'odoo/fields.py')
| -rw-r--r-- | odoo/fields.py | 2398 |
1 files changed, 2398 insertions, 0 deletions
diff --git a/odoo/fields.py b/odoo/fields.py new file mode 100644 index 00000000000..0b9a481320e --- /dev/null +++ b/odoo/fields.py @@ -0,0 +1,2398 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +""" High-level objects for fields. """ + +from collections import OrderedDict, defaultdict +from datetime import date, datetime +from functools import partial +from operator import attrgetter +from types import NoneType +import json +import logging +import pytz +import xmlrpclib + +import psycopg2 + +from openerp.sql_db import LazyCursor +from openerp.tools import float_precision, float_repr, float_round, frozendict, \ + html_sanitize, human_size, pg_varchar, ustr, OrderedSet +from openerp.tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT +from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT +from openerp.tools.translate import html_translate, _ + +DATE_LENGTH = len(date.today().strftime(DATE_FORMAT)) +DATETIME_LENGTH = len(datetime.now().strftime(DATETIME_FORMAT)) +EMPTY_DICT = frozendict() + +_logger = logging.getLogger(__name__) +_schema = logging.getLogger(__name__[:-7] + '.schema') + +Default = object() # default value for __init__() methods + +class SpecialValue(object): + """ Encapsulates a value in the cache in place of a normal value. """ + def __init__(self, value): + self.value = value + def get(self): + return self.value + +class FailedValue(SpecialValue): + """ Special value that encapsulates an exception instead of a value. """ + def __init__(self, exception): + self.exception = exception + def get(self): + raise self.exception + +def _check_value(value): + """ Return ``value``, or call its getter if ``value`` is a :class:`SpecialValue`. """ + return value.get() if isinstance(value, SpecialValue) else value + +def copy_cache(records, env): + """ Recursively copy the cache of ``records`` to the environment ``env``. """ + todo, done = set(records), set() + while todo: + record = todo.pop() + if record not in done: + done.add(record) + target = record.with_env(env) + for name in record._cache: + field = record._fields[name] + value = record[name] + if isinstance(value, BaseModel): + todo.update(value) + target._cache[name] = field.convert_to_cache(value, target, validate=False) + + +def resolve_mro(model, name, predicate): + """ Return the list of successively overridden values of attribute ``name`` + in mro order on ``model`` that satisfy ``predicate``. + """ + result = [] + for cls in type(model).__mro__: + if name in cls.__dict__: + value = cls.__dict__[name] + if not predicate(value): + break + result.append(value) + return result + + +class MetaField(type): + """ Metaclass for field classes. """ + by_type = {} + + def __new__(meta, name, bases, attrs): + """ Combine the ``_slots`` dict from parent classes, and determine + ``__slots__`` for them on the new class. + """ + base_slots = {} + for base in reversed(bases): + base_slots.update(getattr(base, '_slots', ())) + + slots = dict(base_slots) + slots.update(attrs.get('_slots', ())) + + attrs['__slots__'] = set(slots) - set(base_slots) + attrs['_slots'] = slots + return type.__new__(meta, name, bases, attrs) + + def __init__(cls, name, bases, attrs): + super(MetaField, cls).__init__(name, bases, attrs) + if cls.type and cls.type not in MetaField.by_type: + MetaField.by_type[cls.type] = cls + + # compute class attributes to avoid calling dir() on fields + cls.related_attrs = [] + cls.description_attrs = [] + for attr in dir(cls): + if attr.startswith('_related_'): + cls.related_attrs.append((attr[9:], attr)) + elif attr.startswith('_description_'): + cls.description_attrs.append((attr[13:], attr)) + + +class Field(object): + """ The field descriptor contains the field definition, and manages accesses + and assignments of the corresponding field on records. The following + attributes may be provided when instanciating a field: + + :param string: the label of the field seen by users (string); if not + set, the ORM takes the field name in the class (capitalized). + + :param help: the tooltip of the field seen by users (string) + + :param readonly: whether the field is readonly (boolean, by default ``False``) + + :param required: whether the value of the field is required (boolean, by + default ``False``) + + :param index: whether the field is indexed in database (boolean, by + default ``False``) + + :param default: the default value for the field; this is either a static + value, or a function taking a recordset and returning a value; use + ``default=None`` to discard default values for the field + + :param states: a dictionary mapping state values to lists of UI attribute-value + pairs; possible attributes are: 'readonly', 'required', 'invisible'. + Note: Any state-based condition requires the ``state`` field value to be + available on the client-side UI. This is typically done by including it in + the relevant views, possibly made invisible if not relevant for the + end-user. + + :param groups: comma-separated list of group xml ids (string); this + restricts the field access to the users of the given groups only + + :param bool copy: whether the field value should be copied when the record + is duplicated (default: ``True`` for normal fields, ``False`` for + ``one2many`` and computed fields, including property fields and + related fields) + + :param string oldname: the previous name of this field, so that ORM can rename + it automatically at migration + + .. _field-computed: + + .. rubric:: Computed fields + + One can define a field whose value is computed instead of simply being + read from the database. The attributes that are specific to computed + fields are given below. To define such a field, simply provide a value + for the attribute ``compute``. + + :param compute: name of a method that computes the field + + :param inverse: name of a method that inverses the field (optional) + + :param search: name of a method that implement search on the field (optional) + + :param store: whether the field is stored in database (boolean, by + default ``False`` on computed fields) + + :param compute_sudo: whether the field should be recomputed as superuser + to bypass access rights (boolean, by default ``False``) + + The methods given for ``compute``, ``inverse`` and ``search`` are model + methods. Their signature is shown in the following example:: + + upper = fields.Char(compute='_compute_upper', + inverse='_inverse_upper', + search='_search_upper') + + @api.depends('name') + def _compute_upper(self): + for rec in self: + rec.upper = rec.name.upper() if rec.name else False + + def _inverse_upper(self): + for rec in self: + rec.name = rec.upper.lower() if rec.upper else False + + def _search_upper(self, operator, value): + if operator == 'like': + operator = 'ilike' + return [('name', operator, value)] + + The compute method has to assign the field on all records of the invoked + recordset. The decorator :meth:`openerp.api.depends` must be applied on + the compute method to specify the field dependencies; those dependencies + are used to determine when to recompute the field; recomputation is + automatic and guarantees cache/database consistency. Note that the same + method can be used for several fields, you simply have to assign all the + given fields in the method; the method will be invoked once for all + those fields. + + By default, a computed field is not stored to the database, and is + computed on-the-fly. Adding the attribute ``store=True`` will store the + field's values in the database. The advantage of a stored field is that + searching on that field is done by the database itself. The disadvantage + is that it requires database updates when the field must be recomputed. + + The inverse method, as its name says, does the inverse of the compute + method: the invoked records have a value for the field, and you must + apply the necessary changes on the field dependencies such that the + computation gives the expected value. Note that a computed field without + an inverse method is readonly by default. + + The search method is invoked when processing domains before doing an + actual search on the model. It must return a domain equivalent to the + condition: ``field operator value``. + + .. _field-related: + + .. rubric:: Related fields + + The value of a related field is given by following a sequence of + relational fields and reading a field on the reached model. The complete + sequence of fields to traverse is specified by the attribute + + :param related: sequence of field names + + Some field attributes are automatically copied from the source field if + they are not redefined: ``string``, ``help``, ``readonly``, ``required`` (only + if all fields in the sequence are required), ``groups``, ``digits``, ``size``, + ``translate``, ``sanitize``, ``selection``, ``comodel_name``, ``domain``, + ``context``. All semantic-free attributes are copied from the source + field. + + By default, the values of related fields are not stored to the database. + Add the attribute ``store=True`` to make it stored, just like computed + fields. Related fields are automatically recomputed when their + dependencies are modified. + + .. _field-company-dependent: + + .. rubric:: Company-dependent fields + + Formerly known as 'property' fields, the value of those fields depends + on the company. In other words, users that belong to different companies + may see different values for the field on a given record. + + :param company_dependent: whether the field is company-dependent (boolean) + + .. _field-sparse: + + .. rubric:: Sparse fields + + Sparse fields have a very small probability of being not null. Therefore + many such fields can be serialized compactly into a common location, the + latter being a so-called "serialized" field. + + :param sparse: the name of the field where the value of this field must + be stored. + + .. _field-incremental-definition: + + .. rubric:: Incremental definition + + A field is defined as class attribute on a model class. If the model + is extended (see :class:`~openerp.models.Model`), one can also extend + the field definition by redefining a field with the same name and same + type on the subclass. In that case, the attributes of the field are + taken from the parent class and overridden by the ones given in + subclasses. + + For instance, the second class below only adds a tooltip on the field + ``state``:: + + class First(models.Model): + _name = 'foo' + state = fields.Selection([...], required=True) + + class Second(models.Model): + _inherit = 'foo' + state = fields.Selection(help="Blah blah blah") + + """ + __metaclass__ = MetaField + + type = None # type of the field (string) + relational = False # whether the field is a relational one + translate = False # whether the field is translated + + column_type = None # database column type (ident, spec) + column_format = '%s' # placeholder for value in queries + + _slots = { + 'args': EMPTY_DICT, # the parameters given to __init__() + '_attrs': EMPTY_DICT, # the field's non-slot attributes + '_module': None, # the field's module name + 'setup_full_done': False, # whether the field has been fully setup + + 'automatic': False, # whether the field is automatically created ("magic" field) + 'inherited': False, # whether the field is inherited (_inherits) + + 'name': None, # name of the field + 'model_name': None, # name of the model of this field + 'comodel_name': None, # name of the model of values (if relational) + + 'store': True, # whether the field is stored in database + 'index': False, # whether the field is indexed in database + 'manual': False, # whether the field is a custom field + 'copy': True, # whether the field is copied over by BaseModel.copy() + 'depends': (), # collection of field dependencies + 'recursive': False, # whether self depends on itself + 'compute': None, # compute(recs) computes field on recs + 'compute_sudo': False, # whether field should be recomputed as admin + 'inverse': None, # inverse(recs) inverses field on recs + 'search': None, # search(recs, operator, value) searches on self + 'related': None, # sequence of field names, for related fields + 'related_sudo': True, # whether related fields should be read as admin + 'company_dependent': False, # whether ``self`` is company-dependent (property field) + 'sparse': None, # the name of the corresponding serialized field, if any + 'default': None, # default(recs) returns the default value + + 'string': None, # field label + 'help': None, # field tooltip + 'readonly': False, # whether the field is readonly + 'required': False, # whether the field is required + 'states': None, # set readonly and required depending on state + 'groups': None, # csv list of group xml ids + 'change_default': False, # whether the field may trigger a "user-onchange" + 'deprecated': None, # whether the field is deprecated + + 'related_field': None, # corresponding related field + 'group_operator': None, # operator for aggregating values + 'prefetch': True, # whether the field is prefetched + } + + def __init__(self, string=Default, **kwargs): + kwargs['string'] = string + args = {key: val for key, val in kwargs.iteritems() if val is not Default} + self.args = args or EMPTY_DICT + self.setup_full_done = False + + def new(self, **kwargs): + """ Return a field of the same type as ``self``, with its own parameters. """ + return type(self)(**kwargs) + + def __getattr__(self, name): + """ Access non-slot field attribute. """ + try: + return self._attrs[name] + except KeyError: + raise AttributeError(name) + + def __setattr__(self, name, value): + """ Set slot or non-slot field attribute. """ + try: + object.__setattr__(self, name, value) + except AttributeError: + if self._attrs: + self._attrs[name] = value + else: + self._attrs = {name: value} # replace EMPTY_DICT + + def set_all_attrs(self, attrs): + """ Set all field attributes at once (with slot defaults). """ + # optimization: we assign slots only + assign = object.__setattr__ + for key, val in self._slots.iteritems(): + assign(self, key, attrs.pop(key, val)) + if attrs: + assign(self, '_attrs', attrs) + + def __delattr__(self, name): + """ Remove non-slot field attribute. """ + try: + del self._attrs[name] + except KeyError: + raise AttributeError(name) + + def __str__(self): + return "%s.%s" % (self.model_name, self.name) + + def __repr__(self): + return "%s.%s" % (self.model_name, self.name) + + ############################################################################ + # + # Base field setup: things that do not depend on other models/fields + # + + def setup_base(self, model, name): + """ Base setup: things that do not depend on other models/fields. """ + if self.setup_full_done and not self.related: + # optimization for regular fields: keep the base setup + self.setup_full_done = False + else: + # do the base setup from scratch + self._setup_attrs(model, name) + if not self.related: + self._setup_regular_base(model) + + # + # Setup field parameter attributes + # + + def _can_setup_from(self, field): + """ Return whether ``self`` can retrieve parameters from ``field``. """ + return isinstance(field, type(self)) + + def _setup_attrs(self, model, name): + """ Determine field parameter attributes. """ + # determine all inherited field attributes + attrs = {} + if not (self.args.get('automatic') or self.args.get('manual')): + # magic and custom fields do not inherit from parent classes + for field in reversed(resolve_mro(model, name, self._can_setup_from)): + attrs.update(field.args) + attrs.update(self.args) # necessary in case self is not in class + + attrs['args'] = self.args + attrs['model_name'] = model._name + attrs['name'] = name + + # initialize ``self`` with ``attrs`` + if attrs.get('compute'): + # by default, computed fields are not stored, not copied and readonly + attrs['store'] = attrs.get('store', False) + attrs['copy'] = attrs.get('copy', False) + attrs['readonly'] = attrs.get('readonly', not attrs.get('inverse')) + if attrs.get('related'): + # by default, related fields are not stored and not copied + attrs['store'] = attrs.get('store', False) + attrs['copy'] = attrs.get('copy', False) + if attrs.get('company_dependent'): + # by default, company-dependent fields are not stored and not copied + attrs['store'] = False + attrs['copy'] = attrs.get('copy', False) + attrs['default'] = self._default_company_dependent + attrs['compute'] = self._compute_company_dependent + if not attrs.get('readonly'): + attrs['inverse'] = self._inverse_company_dependent + attrs['search'] = self._search_company_dependent + if attrs.get('sparse'): + # by default, sparse fields are not stored and not copied + attrs['store'] = False + attrs['copy'] = attrs.get('copy', False) + attrs['compute'] = self._compute_sparse + if not attrs.get('readonly'): + attrs['inverse'] = self._inverse_sparse + + self.set_all_attrs(attrs) + + # prefetch only stored, column, non-manual and non-deprecated fields + if not (self.store and self.column_type) or self.manual or self.deprecated: + self.prefetch = False + + if not self.string and not self.related: + # related fields get their string from their parent field + self.string = name.replace('_', ' ').capitalize() + + # self.default must be a callable + if self.default is not None: + value = self.default + self.default = value if callable(value) else lambda model: value + + ############################################################################ + # + # Full field setup: everything else, except recomputation triggers + # + + def setup_full(self, model): + """ Full setup: everything else, except recomputation triggers. """ + if not self.setup_full_done: + if not self.related: + self._setup_regular_full(model) + else: + self._setup_related_full(model) + self.setup_full_done = True + + # + # Setup of non-related fields + # + + def _setup_regular_base(self, model): + """ Setup the attributes of a non-related field. """ + def make_depends(deps): + return tuple(deps(model) if callable(deps) else deps) + + if isinstance(self.compute, basestring): + # if the compute method has been overridden, concatenate all their _depends + self.depends = () + for method in resolve_mro(model, self.compute, callable): + self.depends += make_depends(getattr(method, '_depends', ())) + else: + self.depends = make_depends(getattr(self.compute, '_depends', ())) + + def _setup_regular_full(self, model): + """ Setup the inverse field(s) of ``self``. """ + pass + + # + # Setup of related fields + # + + def _setup_related_full(self, model): + """ Setup the attributes of a related field. """ + # fix the type of self.related if necessary + if isinstance(self.related, basestring): + self.related = tuple(self.related.split('.')) + + # determine the chain of fields, and make sure they are all set up + target = model + for name in self.related: + field = target._fields[name] + field.setup_full(target) + target = target[name] + + self.related_field = field + + # check type consistency + if self.type != field.type: + raise TypeError("Type of related field %s is inconsistent with %s" % (self, field)) + + # determine dependencies, compute, inverse, and search + self.depends = ('.'.join(self.related),) + self.compute = self._compute_related + if not (self.readonly or field.readonly): + self.inverse = self._inverse_related + if field._description_searchable: + # allow searching on self only if the related field is searchable + self.search = self._search_related + + # copy attributes from field to self (string, help, etc.) + for attr, prop in self.related_attrs: + if not getattr(self, attr): + setattr(self, attr, getattr(field, prop)) + + for attr, value in field._attrs.iteritems(): + if attr not in self._attrs: + setattr(self, attr, value) + + # special case for states: copy it only for inherited fields + if not self.states and self.inherited: + self.states = field.states + + # special case for inherited required fields + if self.inherited and field.required: + self.required = True + + def traverse_related(self, record): + """ Traverse the fields of the related field `self` except for the last + one, and return it as a pair `(last_record, last_field)`. """ + for name in self.related[:-1]: + record = record[name][:1] + return record, self.related_field + + def _compute_related(self, records): + """ Compute the related field ``self`` on ``records``. """ + # when related_sudo, bypass access rights checks when reading values + others = records.sudo() if self.related_sudo else records + for record, other in zip(records, others): + if not record.id and record.env != other.env: + # draft records: copy record's cache to other's cache first + copy_cache(record, other.env) + other, field = self.traverse_related(other) + record[self.name] = other[field.name] + + def _inverse_related(self, records): + """ Inverse the related field ``self`` on ``records``. """ + # store record values, otherwise they may be lost by cache invalidation! + record_value = {record: record[self.name] for record in records} + for record in records: + other, field = self.traverse_related(record) + if other: + other[field.name] = record_value[record] + + def _search_related(self, records, operator, value): + """ Determine the domain to search on field ``self``. """ + return [('.'.join(self.related), operator, value)] + + # properties used by _setup_related_full() to copy values from related field + _related_comodel_name = property(attrgetter('comodel_name')) + _related_string = property(attrgetter('string')) + _related_help = property(attrgetter('help')) + _related_readonly = property(attrgetter('readonly')) + _related_groups = property(attrgetter('groups')) + _related_group_operator = property(attrgetter('group_operator')) + + @property + def base_field(self): + """ Return the base field of an inherited field, or ``self``. """ + return self.related_field.base_field if self.inherited else self + + # + # Company-dependent fields + # + + def _default_company_dependent(self, model): + return model.env['ir.property'].get(self.name, self.model_name) + + def _compute_company_dependent(self, records): + Property = records.env['ir.property'] + values = Property.get_multi(self.name, self.model_name, records.ids) + for record in records: + record[self.name] = values.get(record.id) + + def _inverse_company_dependent(self, records): + Property = records.env['ir.property'] + values = { + record.id: self.convert_to_write(record[self.name], record) + for record in records + } + Property.set_multi(self.name, self.model_name, values) + + def _search_company_dependent(self, records, operator, value): + Property = records.env['ir.property'] + return Property.search_multi(self.name, self.model_name, operator, value) + + # + # Sparse fields + # + + def _compute_sparse(self, records): + for record in records: + values = record[self.sparse] + record[self.name] = values.get(self.name) + if self.relational: + for record in records: + record[self.name] = record[self.name].exists() + + def _inverse_sparse(self, records): + for record in records: + values = record[self.sparse] + value = self.convert_to_read(record[self.name], record, use_name_get=False) + if value: + if values.get(self.name) != value: + values[self.name] = value + record[self.sparse] = values + else: + if self.name in values: + values.pop(self.name) + record[self.sparse] = values + + # + # Setup of field triggers + # + # The triggers of ``self`` are a collection of pairs ``(field, path)`` of + # fields that depend on ``self``. When ``self`` is modified, it invalidates + # the cache of each ``field``, and determines the records to recompute based + # on ``path``. See method ``modified`` below for details. + # + + def resolve_deps(self, model): + """ Return the dependencies of ``self`` as tuples ``(model, field, path)``, + where ``path`` is an optional list of field names. + """ + model0 = model + result = [] + + # add self's own dependencies + for dotnames in self.depends: + if dotnames == self.name: + _logger.warning("Field %s depends on itself; please fix its decorator @api.depends().", self) + model, path = model0, dotnames.split('.') + for i, fname in enumerate(path): + field = model._fields[fname] + result.append((model, field, path[:i])) + model = model0.env.get(field.comodel_name) + + # add self's model dependencies + for mname, fnames in model0._depends.iteritems(): + model = model0.env[mname] + for fname in fnames: + field = model._fields[fname] + result.append((model, field, None)) + + # add indirect dependencies from the dependencies found above + for model, field, path in list(result): + for inv_field in model._field_inverses[field]: + inv_model = model0.env[inv_field.model_name] + inv_path = None if path is None else path + [field.name] + result.append((inv_model, inv_field, inv_path)) + + return result + + def setup_triggers(self, model): + """ Add the necessary triggers to invalidate/recompute ``self``. """ + for model, field, path in self.resolve_deps(model): + if field is not self: + path_str = None if path is None else ('.'.join(path) or 'id') + model._field_triggers.add(field, (self, path_str)) + elif path: + self.recursive = True + + ############################################################################ + # + # Field description + # + + def get_description(self, env): + """ Return a dictionary that describes the field ``self``. """ + desc = {'type': self.type} + for attr, prop in self.description_attrs: + value = getattr(self, prop) + if callable(value): + value = value(env) + if value is not None: + desc[attr] = value + + return desc + + # properties used by get_description() + _description_store = property(attrgetter('store')) + _description_manual = property(attrgetter('manual')) + _description_depends = property(attrgetter('depends')) + _description_related = property(attrgetter('related')) + _description_company_dependent = property(attrgetter('company_dependent')) + _description_readonly = property(attrgetter('readonly')) + _description_required = property(attrgetter('required')) + _description_states = property(attrgetter('states')) + _description_groups = property(attrgetter('groups')) + _description_change_default = property(attrgetter('change_default')) + _description_deprecated = property(attrgetter('deprecated')) + + @property + def _description_searchable(self): + return bool(self.store or self.search) + + @property + def _description_sortable(self): + return self.store or (self.inherited and self.related_field._description_sortable) + + def _description_string(self, env): + if self.string and env.lang: + model_name = self.base_field.model_name + field_string = env['ir.translation'].get_field_string(model_name) + return field_string.get(self.name) or self.string + return self.string + + def _description_help(self, env): + if self.help and env.lang: + model_name = self.base_field.model_name + field_help = env['ir.translation'].get_field_help(model_name) + return field_help.get(self.name) or self.help + return self.help + + ############################################################################ + # + # Conversion of values + # + + def null(self, record): + """ Return the null value for this field in the record format. """ + return False + + def convert_to_column(self, value, record): + """ Convert ``value`` from the ``write`` format to the SQL format. """ + if value is None or value == False: + return None + if isinstance(value, unicode): + return value.encode('utf8') + return str(value) + + def convert_to_cache(self, value, record, validate=True): + """ Convert ``value`` to the cache format; ``value`` may come from an + assignment, or have the format of methods :meth:`BaseModel.read` or + :meth:`BaseModel.write`. If the value represents a recordset, it should + be added for prefetching on ``record``. + + :param bool validate: when True, field-specific validation of ``value`` + will be performed + """ + return value + + def convert_to_record(self, value, record): + """ Convert ``value`` from the cache format to the record format. + If the value represents a recordset, it should share the prefetching of + ``record``. + """ + return value + + def convert_to_read(self, value, record, use_name_get=True): + """ Convert ``value`` from the record format to the format returned by + method :meth:`BaseModel.read`. + + :param bool use_name_get: when True, the value's display name will be + computed using :meth:`BaseModel.name_get`, if relevant for the field + """ + return False if value is None else value + + def convert_to_write(self, value, record): + """ Convert ``value`` from the record format to the format of method + :meth:`BaseModel.write`. + """ + return self.convert_to_read(value, record) + + def convert_to_onchange(self, value, record, fnames=()): + """ Convert ``value`` from the record format to the format returned by + method :meth:`BaseModel.onchange`. + + :param fnames: an optional collection of field names to convert + (for relational fields only) + """ + return self.convert_to_read(value, record) + + def convert_to_export(self, value, record): + """ Convert ``value`` from the record format to the export format. """ + if not value: + return '' + return value if record._context.get('export_raw_data') else ustr(value) + + def convert_to_display_name(self, value, record): + """ Convert ``value`` from the record format to a suitable display name. """ + return ustr(value) + + ############################################################################ + # + # Read from/write to database + # + + def check_schema(self, model): + """ Make sure the database contains everything for this field. Return + ``True`` if the schema was altered and the field must be recomputed. + """ + pass + + def read(self, records): + """ Read the value of ``self`` on ``records``, and store it in cache. """ + return NotImplementedError("Method read() undefined on %s" % self) + + def write(self, records, value): + """ Write the value of ``self`` on ``records``. The ``value`` must be in + the format of method :meth:`BaseModel.write`. + """ + return NotImplementedError("Method write() undefined on %s" % self) + + ############################################################################ + # + # Descriptor methods + # + + def __get__(self, record, owner): + """ return the value of field ``self`` on ``record`` """ + if record is None: + return self # the field is accessed through the owner class + + if record: + # only a single record may be accessed + record.ensure_one() + try: + value = record._cache[self] + except KeyError: + # cache miss, determine value and retrieve it + if record.id: + self.determine_value(record) + else: + self.determine_draft_value(record) + value = record._cache[self] + else: + # null record -> return the null value for this field + value = self.convert_to_cache(False, record, validate=False) + + return self.convert_to_record(value, record) + + def __set__(self, record, value): + """ set the value of field ``self`` on ``record`` """ + env = record.env + + # only a single record may be updated + record.ensure_one() + + # adapt value to the cache level + value = self.convert_to_cache(value, record) + + if env.in_draft or not record.id: + # determine dependent fields + spec = self.modified_draft(record) + + # set value in cache, inverse field, and mark record as dirty + record._cache[self] = value + if env.in_onchange: + for invf in record._field_inverses[self]: + invf._update(record[self.name], record) + record._set_dirty(self.name) + + # determine more dependent fields, and invalidate them + if self.relational: + spec += self.modified_draft(record) + env.invalidate(spec) + + else: + # simply write to the database, and update cache + write_value = self.convert_to_write(self.convert_to_record(value, record), record) + record.write({self.name: write_value}) + record._cache[self] = value + + ############################################################################ + # + # Computation of field values + # + + def _compute_value(self, records): + """ Invoke the compute method on ``records``. """ + # initialize the fields to their corresponding null value in cache + fields = records._field_computed[self] + for field in fields: + for record in records: + record._cache[field] = field.convert_to_cache(False, record, validate=False) + with records.env.protecting(fields, records): + if isinstance(self.compute, basestring): + getattr(records, self.compute)() + else: + self.compute(records) + + def compute_value(self, records): + """ Invoke the compute method on ``records``; the results are in cache. """ + with records.env.do_in_draft(): + try: + self._compute_value(records) + except (AccessError, MissingError): + # some record is forbidden or missing, retry record by record + for record in records: + try: + self._compute_value(record) + except Exception as exc: + record._cache[self.name] = FailedValue(exc) + + def determine_value(self, record): + """ Determine the value of ``self`` for ``record``. """ + env = record.env + + if self.store and not (self.compute and env.in_onchange): + # this is a stored field or an old-style function field + if self.compute: + # this is a stored computed field, check for recomputation + recs = record._recompute_check(self) + if recs: + # recompute the value (only in cache) + self.compute_value(recs) + # HACK: if result is in the wrong cache, copy values + if recs.env != env: + computed = record._field_computed[self] + for source, target in zip(recs, recs.with_env(env)): + try: + values = target._convert_to_cache({ + f.name: source[f.name] for f in computed + }, validate=False) + except MissingError as e: + values = FailedValue(e) + target._cache.update(values) + # the result is saved to database by BaseModel.recompute() + return + + # read the field from database + record._prefetch_field(self) + + elif self.compute: + # this is either a non-stored computed field, or a stored computed + # field in onchange mode + if self.recursive: + self.compute_value(record) + else: + recs = record._in_cache_without(self) + self.compute_value(recs) + + else: + # this is a non-stored non-computed field + record._cache[self] = self.convert_to_cache(False, record, validate=False) + + def determine_draft_value(self, record): + """ Determine the value of ``self`` for the given draft ``record``. """ + if self.compute: + self._compute_value(record) + else: + null = self.convert_to_cache(False, record, validate=False) + record._cache[self] = SpecialValue(null) + + def determine_inverse(self, records): + """ Given the value of ``self`` on ``records``, inverse the computation. """ + if isinstance(self.inverse, basestring): + getattr(records, self.inverse)() + else: + self.inverse(records) + + def determine_domain(self, records, operator, value): + """ Return a domain representing a condition on ``self``. """ + if isinstance(self.search, basestring): + return getattr(records, self.search)(operator, value) + else: + return self.search(records, operator, value) + + ############################################################################ + # + # Notification when fields are modified + # + + def modified(self, records): + """ Notify that field ``self`` has been modified on ``records``: prepare the + fields/records to recompute, and return a spec indicating what to + invalidate. + """ + # invalidate the fields that depend on self, and prepare recomputation + spec = [(self, records._ids)] + + # group triggers by model and path to reduce the number of calls to search() + bymodel = defaultdict(lambda: defaultdict(list)) + for field, path in records._field_triggers[self]: + bymodel[field.model_name][path].append(field) + + for model_name, bypath in bymodel.iteritems(): + for path, fields in bypath.iteritems(): + if path and any(field.compute and field.store for field in fields): + # process stored fields + stored = set(field for field in fields if field.compute and field.store) + fields = set(fields) - stored + if path == 'id': + target0 = records + else: + # don't move this line to function top, see log + env = records.env(user=SUPERUSER_ID, context={'active_test': False}) + target0 = env[model_name].search([(path, 'in', records.ids)]) + if target0: + for field in stored: + # discard records to not recompute for field + target = target0 - records.env.protected(field) + if not target: + continue + spec.append((field, target._ids)) + # recompute field on target in the environment of + # records, and as user admin if required + if field.compute_sudo: + target = target.with_env(records.env(user=SUPERUSER_ID)) + else: + target = target.with_env(records.env) + target._recompute_todo(field) + # process non-stored fields + for field in fields: + spec.append((field, None)) + + return spec + + def modified_draft(self, records): + """ Same as :meth:`modified`, but in draft mode. """ + env = records.env + + # invalidate the fields on the records in cache that depend on + # ``records``, except fields currently being computed + spec = [] + for field, path in records._field_triggers[self]: + if not field.compute: + # Note: do not invalidate non-computed fields. Such fields may + # require invalidation in general (like *2many fields with + # domains) but should not be invalidated in this case, because + # we would simply lose their values during an onchange! + continue + + target = env[field.model_name] + protected = env.protected(field) + if path == 'id' and field.model_name == records._name: + target = records - protected + elif path and env.in_onchange: + target = (target.browse(env.cache[field]) - protected).filtered( + lambda rec: rec if path == 'id' else rec._mapped_cache(path) & records + ) + else: + target = target.browse(env.cache[field]) - protected + + if target: + spec.append((field, target._ids)) + + return spec + + +class Boolean(Field): + type = 'boolean' + column_type = ('bool', 'bool') + + def convert_to_column(self, value, record): + return bool(value) + + def convert_to_cache(self, value, record, validate=True): + return bool(value) + + def convert_to_export(self, value, record): + if record._context.get('export_raw_data'): + return value + return ustr(value) + + +class Integer(Field): + type = 'integer' + column_type = ('int4', 'int4') + _slots = { + 'group_operator': 'sum', + } + + def convert_to_column(self, value, record): + return int(value or 0) + + def convert_to_cache(self, value, record, validate=True): + if isinstance(value, dict): + # special case, when an integer field is used as inverse for a one2many + return value.get('id', False) + return int(value or 0) + + def convert_to_read(self, value, record, use_name_get=True): + # Integer values greater than 2^31-1 are not supported in pure XMLRPC, + # so we have to pass them as floats :-( + if value and value > xmlrpclib.MAXINT: + return float(value) + return value + + def _update(self, records, value): + # special case, when an integer field is used as inverse for a one2many + records._cache[self] = value.id or 0 + + def convert_to_export(self, value, record): + if value or value == 0: + return value if record._context.get('export_raw_data') else ustr(value) + return '' + + +class Float(Field): + """ The precision digits are given by the attribute + + :param digits: a pair (total, decimal), or a function taking a database + cursor and returning a pair (total, decimal) + """ + type = 'float' + _slots = { + '_digits': None, # digits argument passed to class initializer + 'group_operator': 'sum', + } + + def __init__(self, string=Default, digits=Default, **kwargs): + super(Float, self).__init__(string=string, _digits=digits, **kwargs) + + @property + def column_type(self): + # Explicit support for "falsy" digits (0, False) to indicate a NUMERIC + # field with no fixed precision. The values are saved in the database + # with all significant digits. + # FLOAT8 type is still the default when there is no precision because it + # is faster for most operations (sums, etc.) + return ('numeric', 'numeric') if self.digits is not None else \ + ('float8', 'double precision') + + @property + def digits(self): + if callable(self._digits): + with LazyCursor() as cr: + return self._digits(cr) + else: + return self._digits + + _related__digits = property(attrgetter('_digits')) + _description_digits = property(attrgetter('digits')) + + def convert_to_column(self, value, record): + result = float(value or 0.0) + digits = self.digits + if digits: + precision, scale = digits + result = float_repr(float_round(result, precision_digits=scale), precision_digits=scale) + return result + + def convert_to_cache(self, value, record, validate=True): + # apply rounding here, otherwise value in cache may be wrong! + value = float(value or 0.0) + if not validate: + return value + digits = self.digits + return float_round(value, precision_digits=digits[1]) if digits else value + + def convert_to_export(self, value, record): + if value or value == 0.0: + return value if record._context.get('export_raw_data') else ustr(value) + return '' + + +class Monetary(Field): + """ The decimal precision and currency symbol are taken from the attribute + + :param currency_field: name of the field holding the currency this monetary + field is expressed in (default: `currency_id`) + """ + type = 'monetary' + column_type = ('numeric', 'numeric') + _slots = { + 'currency_field': None, + 'group_operator': 'sum', + } + + def __init__(self, string=Default, currency_field=Default, **kwargs): + super(Monetary, self).__init__(string=string, currency_field=currency_field, **kwargs) + + _related_currency_field = property(attrgetter('currency_field')) + _description_currency_field = property(attrgetter('currency_field')) + + def _setup_regular_base(self, model): + super(Monetary, self)._setup_regular_base(model) + if not self.currency_field: + self.currency_field = 'currency_id' + + def _setup_regular_full(self, model): + super(Monetary, self)._setup_regular_full(model) + assert self.currency_field in model._fields, \ + "Field %s with unknown currency_field %r" % (self, self.currency_field) + + def convert_to_column(self, value, record): + try: + return value.float_repr() # see float_precision.float_repr() + except Exception: + return float(value or 0.0) + + def convert_to_cache(self, value, record, validate=True): + if validate: + currency = record[self.currency_field] + # FIXME @rco-odoo: currency may not be already initialized if it is + # a function or related field! + if currency: + value = currency.round(float(value or 0.0)) + return float_precision(value, currency.decimal_places) + return float(value or 0.0) + + +class _String(Field): + """ Abstract class for string fields. """ + _slots = { + 'translate': False, # whether the field is translated + } + + def __init__(self, string=Default, **kwargs): + # translate is either True, False, or a callable + if 'translate' in kwargs and not callable(kwargs['translate']): + kwargs['translate'] = bool(kwargs['translate']) + super(_String, self).__init__(string=string, **kwargs) + + _related_translate = property(attrgetter('translate')) + + def _description_translate(self, env): + return bool(self.translate) + + def get_trans_terms(self, value): + """ Return the sequence of terms to translate found in `value`. """ + if not callable(self.translate): + return [value] if value else [] + terms = [] + self.translate(terms.append, value) + return terms + + def get_trans_func(self, records): + """ Return a translation function `translate` for `self` on the given + records; the function call `translate(record_id, value)` translates the + field value to the language given by the environment of `records`. + """ + if callable(self.translate): + rec_src_trans = records.env['ir.translation']._get_terms_translations(self, records) + + def translate(record_id, value): + src_trans = rec_src_trans[record_id] + return self.translate(src_trans.get, value) + + else: + rec_trans = records.env['ir.translation']._get_ids( + '%s,%s' % (self.model_name, self.name), 'model', records.env.lang, records.ids) + + def translate(record_id, value): + return rec_trans.get(record_id) or value + + return translate + + def check_trans_value(self, value): + """ Check and possibly sanitize the translated term `value`. """ + if callable(self.translate): + # do a "no-translation" to sanitize the value + callback = lambda term: None + return self.translate(callback, value) + else: + return value + + +class Char(_String): + """ Basic string field, can be length-limited, usually displayed as a + single-line string in clients. + + :param int size: the maximum size of values stored for that field + + :param translate: enable the translation of the field's values; use + ``translate=True`` to translate field values as a whole; ``translate`` + may also be a callable such that ``translate(callback, value)`` + translates ``value`` by using ``callback(term)`` to retrieve the + translation of terms. + """ + type = 'char' + _slots = { + 'size': None, # maximum size of values (deprecated) + } + + @property + def column_type(self): + return ('varchar', pg_varchar(self.size)) + + _related_size = property(attrgetter('size')) + _description_size = property(attrgetter('size')) + + def _setup_regular_base(self, model): + super(Char, self)._setup_regular_base(model) + assert isinstance(self.size, (NoneType, int)), \ + "Char field %s with non-integer size %r" % (self, self.size) + + def convert_to_column(self, value, record): + #TODO: + # * we need to remove the "value==False" from the next line BUT + # for now too many things rely on this broken behavior + # * the value==None test should be common to all data types + if value is None or value == False: + return None + # we need to convert the string to a unicode object to be able + # to evaluate its length (and possibly truncate it) reliably + return ustr(value)[:self.size].encode('utf8') + + def convert_to_cache(self, value, record, validate=True): + if value is None or value is False: + return False + return ustr(value)[:self.size] + + +class Text(_String): + """ Very similar to :class:`~.Char` but used for longer contents, does not + have a size and usually displayed as a multiline text box. + + :param translate: enable the translation of the field's values; use + ``translate=True`` to translate field values as a whole; ``translate`` + may also be a callable such that ``translate(callback, value)`` + translates ``value`` by using ``callback(term)`` to retrieve the + translation of terms. + """ + type = 'text' + column_type = ('text', 'text') + + def convert_to_cache(self, value, record, validate=True): + if value is None or value is False: + return False + return ustr(value) + + +class Html(_String): + type = 'html' + column_type = ('text', 'text') + _slots = { + 'sanitize': True, # whether value must be sanitized + 'sanitize_tags': True, # whether to sanitize tags (only a white list of attributes is accepted) + 'sanitize_attributes': True, # whether to sanitize attributes (only a white list of attributes is accepted) + 'sanitize_style': False, # whether to sanitize style attributes + 'strip_style': False, # whether to strip style attributes (removed and therefore not sanitized) + 'strip_classes': False, # whether to strip classes attributes + } + + def _setup_attrs(self, model, name): + super(Html, self)._setup_attrs(model, name) + # Translated sanitized html fields must use html_translate or a callable. + if self.translate is True and self.sanitize: + self.translate = html_translate + + _related_sanitize = property(attrgetter('sanitize')) + _related_sanitize_tags = property(attrgetter('sanitize_tags')) + _related_sanitize_attributes = property(attrgetter('sanitize_attributes')) + _related_sanitize_style = property(attrgetter('sanitize_style')) + _related_strip_style = property(attrgetter('strip_style')) + _related_strip_classes = property(attrgetter('strip_classes')) + + _description_sanitize = property(attrgetter('sanitize')) + _description_sanitize_tags = property(attrgetter('sanitize_tags')) + _description_sanitize_attributes = property(attrgetter('sanitize_attributes')) + _description_sanitize_style = property(attrgetter('sanitize_style')) + _description_strip_style = property(attrgetter('strip_style')) + _description_strip_classes = property(attrgetter('strip_classes')) + + def convert_to_column(self, value, record): + if value is None or value is False: + return None + if self.sanitize: + return html_sanitize( + value, silent=True, + sanitize_tags=self.sanitize_tags, + sanitize_attributes=self.sanitize_attributes, + sanitize_style=self.sanitize_style, + strip_style=self.strip_style, + strip_classes=self.strip_classes) + return value + + def convert_to_cache(self, value, record, validate=True): + if value is None or value is False: + return False + if validate and self.sanitize: + return html_sanitize( + value, silent=True, + sanitize_tags=self.sanitize_tags, + sanitize_attributes=self.sanitize_attributes, + sanitize_style=self.sanitize_style, + strip_style=self.strip_style, + strip_classes=self.strip_classes) + return value + + +class Date(Field): + type = 'date' + column_type = ('date', 'date') + + @staticmethod + def today(*args): + """ Return the current day in the format expected by the ORM. + This function may be used to compute default values. + """ + return date.today().strftime(DATE_FORMAT) + + @staticmethod + def context_today(record, timestamp=None): + """ Return the current date as seen in the client's timezone in a format + fit for date fields. This method may be used to compute default + values. + + :param datetime timestamp: optional datetime value to use instead of + the current date and time (must be a datetime, regular dates + can't be converted between timezones.) + :rtype: str + """ + today = timestamp or datetime.now() + context_today = None + tz_name = record._context.get('tz') or record.env.user.tz + if tz_name: + try: + today_utc = pytz.timezone('UTC').localize(today, is_dst=False) # UTC = no DST + context_today = today_utc.astimezone(pytz.timezone(tz_name)) + except Exception: + _logger.debug("failed to compute context/client-specific today date, using UTC value for `today`", + exc_info=True) + return (context_today or today).strftime(DATE_FORMAT) + + @staticmethod + def from_string(value): + """ Convert an ORM ``value`` into a :class:`date` value. """ + if not value: + return None + value = value[:DATE_LENGTH] + return datetime.strptime(value, DATE_FORMAT).date() + + @staticmethod + def to_string(value): + """ Convert a :class:`date` value into the format expected by the ORM. """ + return value.strftime(DATE_FORMAT) if value else False + + def convert_to_cache(self, value, record, validate=True): + if not value: + return False + if isinstance(value, basestring): + if validate: + # force parsing for validation + self.from_string(value) + return value[:DATE_LENGTH] + return self.to_string(value) + + def convert_to_export(self, value, record): + if not value: + return '' + return self.from_string(value) if record._context.get('export_raw_data') else ustr(value) + + +class Datetime(Field): + type = 'datetime' + column_type = ('timestamp', 'timestamp') + + @staticmethod + def now(*args): + """ Return the current day and time in the format expected by the ORM. + This function may be used to compute default values. + """ + return datetime.now().strftime(DATETIME_FORMAT) + + @staticmethod + def context_timestamp(record, timestamp): + """Returns the given timestamp converted to the client's timezone. + This method is *not* meant for use as a default initializer, + because datetime fields are automatically converted upon + display on client side. For default values :meth:`fields.datetime.now` + should be used instead. + + :param datetime timestamp: naive datetime value (expressed in UTC) + to be converted to the client timezone + :rtype: datetime + :return: timestamp converted to timezone-aware datetime in context + timezone + """ + assert isinstance(timestamp, datetime), 'Datetime instance expected' + tz_name = record._context.get('tz') or record.env.user.tz + utc_timestamp = pytz.utc.localize(timestamp, is_dst=False) # UTC = no DST + if tz_name: + try: + context_tz = pytz.timezone(tz_name) + return utc_timestamp.astimezone(context_tz) + except Exception: + _logger.debug("failed to compute context/client-specific timestamp, " + "using the UTC value", + exc_info=True) + return utc_timestamp + + @staticmethod + def from_string(value): + """ Convert an ORM ``value`` into a :class:`datetime` value. """ + if not value: + return None + value = value[:DATETIME_LENGTH] + if len(value) == DATE_LENGTH: + value += " 00:00:00" + return datetime.strptime(value, DATETIME_FORMAT) + + @staticmethod + def to_string(value): + """ Convert a :class:`datetime` value into the format expected by the ORM. """ + return value.strftime(DATETIME_FORMAT) if value else False + + def convert_to_cache(self, value, record, validate=True): + if not value: + return False + if isinstance(value, basestring): + if validate: + # force parsing for validation + self.from_string(value) + value = value[:DATETIME_LENGTH] + if len(value) == DATE_LENGTH: + value += " 00:00:00" + return value + return self.to_string(value) + + def convert_to_export(self, value, record): + if not value: + return '' + return self.from_string(value) if record._context.get('export_raw_data') else ustr(value) + + def convert_to_display_name(self, value, record): + assert record, 'Record expected' + return Datetime.to_string(Datetime.context_timestamp(record, Datetime.from_string(value))) + + +class Binary(Field): + type = 'binary' + _slots = { + 'attachment': False, # whether value is stored in attachment + } + + @property + def column_type(self): + return None if self.attachment else ('bytea', 'bytea') + + _description_attachment = property(attrgetter('attachment')) + + def convert_to_column(self, value, record): + # Binary values may be byte strings (python 2.6 byte array), but + # the legacy OpenERP convention is to transfer and store binaries + # as base64-encoded strings. The base64 string may be provided as a + # unicode in some circumstances, hence the str() cast here. + # This str() coercion will only work for pure ASCII unicode strings, + # on purpose - non base64 data must be passed as a 8bit byte strings. + return psycopg2.Binary(str(value)) if value else None + + def convert_to_cache(self, value, record, validate=True): + if isinstance(value, buffer): + return str(value) + if isinstance(value, (int, long)) and \ + (record._context.get('bin_size') or + record._context.get('bin_size_' + self.name)): + # If the client requests only the size of the field, we return that + # instead of the content. Presumably a separate request will be done + # to read the actual content, if necessary. + return human_size(value) + return value + + def read(self, records): + # values are stored in attachments, retrieve them + assert self.attachment + domain = [ + ('res_model', '=', records._name), + ('res_field', '=', self.name), + ('res_id', 'in', records.ids), + ] + # Note: the 'bin_size' flag is handled by the field 'datas' itself + data = {att.res_id: att.datas + for att in records.env['ir.attachment'].sudo().search(domain)} + for record in records: + record._cache[self.name] = data.get(record.id, False) + + def write(self, records, value): + # retrieve the attachments that stores the value, and adapt them + assert self.attachment + domain = [ + ('res_model', '=', records._name), + ('res_field', '=', self.name), + ('res_id', 'in', records.ids), + ] + atts = records.env['ir.attachment'].sudo().search(domain) + with records.env.norecompute(): + if value: + # update the existing attachments + atts.write({'datas': value}) + # create the missing attachments + for record in (records - records.browse(atts.mapped('res_id'))): + atts.create({ + 'name': self.name, + 'res_model': record._name, + 'res_field': self.name, + 'res_id': record.id, + 'type': 'binary', + 'datas': value, + }) + else: + atts.unlink() + + +class Selection(Field): + """ + :param selection: specifies the possible values for this field. + It is given as either a list of pairs (``value``, ``string``), or a + model method, or a method name. + :param selection_add: provides an extension of the selection in the case + of an overridden field. It is a list of pairs (``value``, ``string``). + + The attribute ``selection`` is mandatory except in the case of + :ref:`related fields <field-related>` or :ref:`field extensions + <field-incremental-definition>`. + """ + type = 'selection' + _slots = { + 'selection': None, # [(value, string), ...], function or method name + } + + def __init__(self, selection=Default, string=Default, **kwargs): + super(Selection, self).__init__(selection=selection, string=string, **kwargs) + + @property + def column_type(self): + if (self.selection and + isinstance(self.selection, list) and + isinstance(self.selection[0][0], int)): + return ('int4', 'integer') + else: + return ('varchar', pg_varchar()) + + def _setup_regular_base(self, model): + super(Selection, self)._setup_regular_base(model) + assert self.selection is not None, "Field %s without selection" % self + + def _setup_related_full(self, model): + super(Selection, self)._setup_related_full(model) + # selection must be computed on related field + field = self.related_field + self.selection = lambda model: field._description_selection(model.env) + + def _setup_attrs(self, model, name): + super(Selection, self)._setup_attrs(model, name) + # determine selection (applying 'selection_add' extensions) + for field in reversed(resolve_mro(model, name, self._can_setup_from)): + # We cannot use field.selection or field.selection_add here + # because those attributes are overridden by ``_setup_attrs``. + if 'selection' in field.args: + self.selection = field.args['selection'] + if 'selection_add' in field.args: + # use an OrderedDict to update existing values + selection_add = field.args['selection_add'] + self.selection = OrderedDict(self.selection + selection_add).items() + + def _description_selection(self, env): + """ return the selection list (pairs (value, label)); labels are + translated according to context language + """ + selection = self.selection + if isinstance(selection, basestring): + return getattr(env[self.model_name], selection)() + if callable(selection): + return selection(env[self.model_name]) + + # translate selection labels + if env.lang: + name = "%s,%s" % (self.model_name, self.name) + translate = partial( + env['ir.translation']._get_source, name, 'selection', env.lang) + return [(value, translate(label) if label else label) for value, label in selection] + else: + return selection + + def get_values(self, env): + """ return a list of the possible values """ + selection = self.selection + if isinstance(selection, basestring): + selection = getattr(env[self.model_name], selection)() + elif callable(selection): + selection = selection(env[self.model_name]) + return [value for value, _ in selection] + + def convert_to_cache(self, value, record, validate=True): + if not validate: + return value or False + if value in self.get_values(record.env): + return value + elif not value: + return False + raise ValueError("Wrong value for %s: %r" % (self, value)) + + def convert_to_export(self, value, record): + if not isinstance(self.selection, list): + # FIXME: this reproduces an existing buggy behavior! + return value if value else '' + for item in self._description_selection(record.env): + if item[0] == value: + return item[1] + return False + + +class Reference(Selection): + type = 'reference' + + @property + def column_type(self): + return ('varchar', pg_varchar()) + + def convert_to_cache(self, value, record, validate=True): + # cache format: (res_model, res_id) or False + def process(res_model, res_id): + record._prefetch[res_model].add(res_id) + return (res_model, res_id) + + if isinstance(value, BaseModel): + if not validate or (value._name in self.get_values(record.env) and len(value) <= 1): + return process(value._name, value.id) if value else False + elif isinstance(value, basestring): + res_model, res_id = value.split(',') + if record.env[res_model].browse(int(res_id)).exists(): + return process(res_model, int(res_id)) + else: + return False + elif not value: + return False + raise ValueError("Wrong value for %s: %r" % (self, value)) + + def convert_to_record(self, value, record): + return value and record.env[value[0]].browse([value[1]], record._prefetch) + + def convert_to_read(self, value, record, use_name_get=True): + return "%s,%s" % (value._name, value.id) if value else False + + def convert_to_export(self, value, record): + return value.name_get()[0][1] if value else '' + + def convert_to_display_name(self, value, record): + return ustr(value and value.display_name) + + +class _Relational(Field): + """ Abstract class for relational fields. """ + relational = True + _slots = { + 'domain': [], # domain for searching values + 'context': {}, # context for searching values + } + + def _setup_regular_base(self, model): + super(_Relational, self)._setup_regular_base(model) + if self.comodel_name not in model.pool: + _logger.warning("Field %s with unknown comodel_name %r", self, self.comodel_name) + self.comodel_name = '_unknown' + + @property + def _related_domain(self): + if callable(self.domain): + # will be called with another model than self's + return lambda recs: self.domain(recs.env[self.model_name]) + else: + # maybe not correct if domain is a string... + return self.domain + + _related_context = property(attrgetter('context')) + + _description_relation = property(attrgetter('comodel_name')) + _description_context = property(attrgetter('context')) + + def _description_domain(self, env): + return self.domain(env[self.model_name]) if callable(self.domain) else self.domain + + def null(self, record): + return record.env[self.comodel_name] + + def modified(self, records): + # Invalidate cache for inverse fields, too. Note that the recomputation + # of fields that depend on inverse fields is already covered by the + # triggers. + spec = super(_Relational, self).modified(records) + for invf in records._field_inverses[self]: + spec.append((invf, None)) + return spec + + +class Many2one(_Relational): + """ The value of such a field is a recordset of size 0 (no + record) or 1 (a single record). + + :param comodel_name: name of the target model (string) + + :param domain: an optional domain to set on candidate values on the + client side (domain or string) + + :param context: an optional context to use on the client side when + handling that field (dictionary) + + :param ondelete: what to do when the referred record is deleted; + possible values are: ``'set null'``, ``'restrict'``, ``'cascade'`` + + :param auto_join: whether JOINs are generated upon search through that + field (boolean, by default ``False``) + + :param delegate: set it to ``True`` to make fields of the target model + accessible from the current model (corresponds to ``_inherits``) + + The attribute ``comodel_name`` is mandatory except in the case of related + fields or field extensions. + """ + type = 'many2one' + column_type = ('int4', 'int4') + _slots = { + 'ondelete': 'set null', # what to do when value is deleted + 'auto_join': False, # whether joins are generated upon search + 'delegate': False, # whether self implements delegation + } + + def __init__(self, comodel_name=Default, string=Default, **kwargs): + super(Many2one, self).__init__(comodel_name=comodel_name, string=string, **kwargs) + + def _setup_attrs(self, model, name): + super(Many2one, self)._setup_attrs(model, name) + # determine self.delegate + if not self.delegate: + self.delegate = name in model._inherits.values() + + def _update(self, records, value): + """ Update the cached value of ``self`` for ``records`` with ``value``. + This is used to reflect the assignment ``value[name] = records``, where + ``name`` is the inverse field of ``self``. + """ + records._cache[self] = self.convert_to_cache(value, records, validate=False) + + def convert_to_column(self, value, record): + return value or None + + def convert_to_cache(self, value, record, validate=True): + # cache format: tuple(ids) + def process(ids): + return record._prefetch[self.comodel_name].update(ids) or ids + + if type(value) in IdType: + return process((value,)) + elif isinstance(value, BaseModel): + if not validate or (value._name == self.comodel_name and len(value) <= 1): + return process(value._ids) + raise ValueError("Wrong value for %s: %r" % (self, value)) + elif isinstance(value, tuple): + return process((value[0],)) + elif isinstance(value, dict): + return process(record.env[self.comodel_name].new(value)._ids) + else: + return () + + def convert_to_record(self, value, record): + return record.env[self.comodel_name]._browse(value, record.env, record._prefetch) + + def convert_to_read(self, value, record, use_name_get=True): + if use_name_get and value: + # evaluate name_get() as superuser, because the visibility of a + # many2one field value (id and name) depends on the current record's + # access rights, and not the value's access rights. + try: + # performance: value.sudo() prefetches the same records as value + return value.sudo().name_get()[0] + except MissingError: + # Should not happen, unless the foreign key is missing. + return False + else: + return value.id + + def convert_to_write(self, value, record): + return value.id + + def convert_to_export(self, value, record): + return value.name_get()[0][1] if value else '' + + def convert_to_display_name(self, value, record): + return ustr(value.display_name) + + +class UnionUpdate(SpecialValue): + """ Placeholder for a value update; when this value is taken from the cache, + it returns ``record[field.name] | value`` and stores it in the cache. + """ + def __init__(self, field, record, value): + self.args = (field, record, value) + + def get(self): + field, record, value = self.args + # in order to read the current field's value, remove self from cache + del record._cache[field] + # read the current field's value, and update it in cache only + value = field.convert_to_cache(record[field.name] | value, record, validate=False) + record._cache[field] = value + return value + + +class _RelationalMulti(_Relational): + """ Abstract class for relational fields *2many. """ + + def _update(self, records, value): + """ Update the cached value of ``self`` for ``records`` with ``value``. """ + for record in records: + if self in record._cache: + value = self.convert_to_cache(record[self.name] | value, record, validate=False) + else: + value = UnionUpdate(self, record, value) + record._cache[self] = value + + def convert_to_cache(self, value, record, validate=True): + # cache format: tuple(ids) + def process(ids): + return record._prefetch[self.comodel_name].update(ids) or ids + + if isinstance(value, BaseModel): + if not validate or (value._name == self.comodel_name): + return process(value._ids) + elif isinstance(value, list): + # value is a list of record ids or commands + comodel = record.env[self.comodel_name] + # determine the value ids; by convention empty on new records + ids = OrderedSet(record[self.name].ids if record.id else ()) + # modify ids with the commands + for command in value: + if isinstance(command, (tuple, list)): + if command[0] == 0: + ids.add(comodel.new(command[2]).id) + elif command[0] == 1: + comodel.browse(command[1]).update(command[2]) + ids.add(command[1]) + elif command[0] == 2: + # note: the record will be deleted by write() + ids.discard(command[1]) + elif command[0] == 3: + ids.discard(command[1]) + elif command[0] == 4: + ids.add(command[1]) + elif command[0] == 5: + ids.clear() + elif command[0] == 6: + ids = OrderedSet(command[2]) + elif isinstance(command, dict): + ids.add(comodel.new(command).id) + else: + ids.add(command) + # return result as a tuple + return process(tuple(ids)) + elif not value: + return () + raise ValueError("Wrong value for %s: %s" % (self, value)) + + def convert_to_record(self, value, record): + return record.env[self.comodel_name]._browse(value, record.env, record._prefetch) + + def convert_to_read(self, value, record, use_name_get=True): + return value.ids + + def convert_to_write(self, value, record): + # make result with new and existing records + result = [(5,)] + for record in value: + if not record.id: + values = {name: record[name] for name in record._cache} + values = record._convert_to_write(values) + result.append((0, 0, values)) + elif record._is_dirty(): + values = {name: record[name] for name in record._get_dirty()} + values = record._convert_to_write(values) + result.append((1, record.id, values)) + else: + result.append((4, record.id)) + return result + + def convert_to_onchange(self, value, record, fnames=()): + # return the recordset value as a list of commands; the commands may + # give all fields values, the client is responsible for figuring out + # which fields are actually dirty + converters = [(name, value._fields[name].convert_to_onchange) + for name in fnames if name != 'id'] + result = [(5,)] + for record in value: + vals = {name: convert(record[name], record) for name, convert in converters} + if not record.id: + result.append((0, 0, vals)) + elif vals: + result.append((1, record.id, vals)) + else: + result.append((4, record.id)) + return result + + def convert_to_export(self, value, record): + return ','.join(name for id, name in value.name_get()) if value else '' + + def convert_to_display_name(self, value, record): + raise NotImplementedError() + + def _compute_related(self, records): + """ Compute the related field ``self`` on ``records``. """ + super(_RelationalMulti, self)._compute_related(records) + if self.related_sudo: + # determine which records in the relation are actually accessible + target = records.mapped(self.name) + target_ids = set(target.search([('id', 'in', target.ids)]).ids) + accessible = lambda target: target.id in target_ids + # filter values to keep the accessible records only + for record in records: + record[self.name] = record[self.name].filtered(accessible) + + def _setup_regular_base(self, model): + super(_RelationalMulti, self)._setup_regular_base(model) + if isinstance(self.domain, list): + self.depends += tuple( + self.name + '.' + arg[0] + for arg in self.domain + if isinstance(arg, (tuple, list)) and isinstance(arg[0], basestring) + ) + + +class One2many(_RelationalMulti): + """ One2many field; the value of such a field is the recordset of all the + records in ``comodel_name`` such that the field ``inverse_name`` is equal to + the current record. + + :param comodel_name: name of the target model (string) + + :param inverse_name: name of the inverse ``Many2one`` field in + ``comodel_name`` (string) + + :param domain: an optional domain to set on candidate values on the + client side (domain or string) + + :param context: an optional context to use on the client side when + handling that field (dictionary) + + :param auto_join: whether JOINs are generated upon search through that + field (boolean, by default ``False``) + + :param limit: optional limit to use upon read (integer) + + The attributes ``comodel_name`` and ``inverse_name`` are mandatory except in + the case of related fields or field extensions. + """ + type = 'one2many' + _slots = { + 'inverse_name': None, # name of the inverse field + 'auto_join': False, # whether joins are generated upon search + 'limit': None, # optional limit to use upon read + 'copy': False, # o2m are not copied by default + } + + def __init__(self, comodel_name=Default, inverse_name=Default, string=Default, **kwargs): + super(One2many, self).__init__( + comodel_name=comodel_name, + inverse_name=inverse_name, + string=string, + **kwargs + ) + + def _setup_regular_full(self, model): + super(One2many, self)._setup_regular_full(model) + if self.inverse_name: + # link self to its inverse field and vice-versa + comodel = model.env[self.comodel_name] + invf = comodel._fields[self.inverse_name] + # In some rare cases, a ``One2many`` field can link to ``Int`` field + # (res_model/res_id pattern). Only inverse the field if this is + # a ``Many2one`` field. + if isinstance(invf, Many2one): + model._field_inverses.add(self, invf) + comodel._field_inverses.add(invf, self) + + _description_relation_field = property(attrgetter('inverse_name')) + + def convert_to_onchange(self, value, record, fnames=()): + if fnames: + # do not serialize self's inverse field + fnames = [name for name in fnames if name != self.inverse_name] + return super(One2many, self).convert_to_onchange(value, record, fnames) + + def check_schema(self, model): + if self.comodel_name in model.env: + comodel = model.env[self.comodel_name] + if self.inverse_name not in comodel._fields: + raise UserError(_("No inverse field %r found for %r") % (self.inverse_name, self.comodel_name)) + + def read(self, records): + # retrieve the lines in the comodel + comodel = records.env[self.comodel_name].with_context(**self.context) + inverse = self.inverse_name + domain = self.domain(records) if callable(self.domain) else self.domain + domain = domain + [(inverse, 'in', records.ids)] + lines = comodel.search(domain, limit=self.limit) + + # group lines by inverse field (without prefetching other fields) + group = defaultdict(list) + for line in lines.with_context(prefetch_fields=False): + # line[inverse] may be a record or an integer + group[int(line[inverse])].append(line.id) + + # store result in cache + for record in records: + record._cache[self.name] = tuple(group[record.id]) + + def write(self, records, value): + comodel = records.env[self.comodel_name].with_context(**self.context) + inverse = self.inverse_name + + with records.env.norecompute(): + for act in (value or []): + if act[0] == 0: + for record in records: + act[2][inverse] = record.id + comodel.create(act[2]) + elif act[0] == 1: + comodel.browse(act[1]).write(act[2]) + elif act[0] == 2: + comodel.browse(act[1]).unlink() + elif act[0] == 3: + inverse_field = comodel._fields[inverse] + if inverse_field.ondelete == 'cascade': + comodel.browse(act[1]).unlink() + else: + comodel.browse(act[1]).write({inverse: False}) + elif act[0] == 4: + record = records[-1] + line = comodel.browse(act[1]) + line_sudo = line.sudo().with_context(prefetch_fields=False) + if int(line_sudo[inverse]) != record.id: + line.write({inverse: record.id}) + elif act[0] == 5: + domain = self.domain(records) if callable(self.domain) else self.domain + domain = domain + [(inverse, 'in', records.ids)] + inverse_field = comodel._fields[inverse] + if inverse_field.ondelete == 'cascade': + comodel.search(domain).unlink() + else: + comodel.search(domain).write({inverse: False}) + elif act[0] == 6: + record = records[-1] + comodel.browse(act[2]).write({inverse: record.id}) + query = "SELECT id FROM %s WHERE %s=%%s AND id <> ALL(%%s)" % (comodel._table, inverse) + comodel._cr.execute(query, (record.id, act[2] or [0])) + lines = comodel.browse([row[0] for row in comodel._cr.fetchall()]) + lines.write({inverse: False}) + + +class Many2many(_RelationalMulti): + """ Many2many field; the value of such a field is the recordset. + + :param comodel_name: name of the target model (string) + + The attribute ``comodel_name`` is mandatory except in the case of related + fields or field extensions. + + :param relation: optional name of the table that stores the relation in + the database (string) + + :param column1: optional name of the column referring to "these" records + in the table ``relation`` (string) + + :param column2: optional name of the column referring to "those" records + in the table ``relation`` (string) + + The attributes ``relation``, ``column1`` and ``column2`` are optional. If not + given, names are automatically generated from model names, provided + ``model_name`` and ``comodel_name`` are different! + + :param domain: an optional domain to set on candidate values on the + client side (domain or string) + + :param context: an optional context to use on the client side when + handling that field (dictionary) + + :param limit: optional limit to use upon read (integer) + + """ + type = 'many2many' + _slots = { + 'relation': None, # name of table + 'column1': None, # column of table referring to model + 'column2': None, # column of table referring to comodel + 'auto_join': False, # whether joins are generated upon search + 'limit': None, # optional limit to use upon read + } + + def __init__(self, comodel_name=Default, relation=Default, column1=Default, + column2=Default, string=Default, **kwargs): + super(Many2many, self).__init__( + comodel_name=comodel_name, + relation=relation, + column1=column1, + column2=column2, + string=string, + **kwargs + ) + + def _setup_regular_base(self, model): + super(Many2many, self)._setup_regular_base(model) + if self.store: + if not (self.relation and self.column1 and self.column2): + # table name is based on the stable alphabetical order of tables + comodel = model.env[self.comodel_name] + if not self.relation: + tables = sorted([model._table, comodel._table]) + assert tables[0] != tables[1], \ + "%s: Implicit/canonical naming of many2many relationship " \ + "table is not possible when source and destination models " \ + "are the same" % self + self.relation = '%s_%s_rel' % tuple(tables) + if not self.column1: + self.column1 = '%s_id' % model._table + if not self.column2: + self.column2 = '%s_id' % comodel._table + # check validity of table name + check_pg_name(self.relation) + + def _setup_regular_full(self, model): + super(Many2many, self)._setup_regular_full(model) + if self.relation: + m2m = model.pool._m2m + # if inverse field has already been setup, it is present in m2m + invf = m2m.get((self.relation, self.column2, self.column1)) + if invf: + comodel = model.env[self.comodel_name] + model._field_inverses.add(self, invf) + comodel._field_inverses.add(invf, self) + else: + # add self in m2m, so that its inverse field can find it + m2m[(self.relation, self.column1, self.column2)] = self + + def check_schema(self, model): + cr = model._cr + rel, id1, id2 = self.relation, self.column1, self.column2 + # do not create relations for custom fields as they do not belong to a module + # they will be automatically removed when dropping the corresponding ir.model.field + # table name for custom relation all starts with x_, see __init__ + if not rel.startswith('x_'): + model._save_relation_table(rel, self._module) + cr.execute("SELECT relname FROM pg_class WHERE relkind IN ('r','v') AND relname=%s", (rel,)) + if not cr.dictfetchall(): + if self.comodel_name not in model.env: + raise UserError(_('Many2many comodel does not exist: %r') % (self.comodel_name,)) + comodel = model.env[self.comodel_name] + cr.execute('CREATE TABLE "%s" ("%s" INTEGER NOT NULL, "%s" INTEGER NOT NULL, UNIQUE("%s","%s"))' % (rel, id1, id2, id1, id2)) + # create foreign key references with ondelete=cascade, unless the targets are SQL views + cr.execute("SELECT relkind FROM pg_class WHERE relkind IN ('v') AND relname=%s", (comodel._table,)) + if not cr.fetchall(): + model._m2o_add_foreign_key_unchecked(rel, id2, comodel, 'cascade', self._module) + cr.execute("SELECT relkind FROM pg_class WHERE relkind IN ('v') AND relname=%s", (model._table,)) + if not cr.fetchall(): + model._m2o_add_foreign_key_unchecked(rel, id1, model, 'cascade', self._module) + + cr.execute('CREATE INDEX ON "%s" ("%s")' % (rel, id1)) + cr.execute('CREATE INDEX ON "%s" ("%s")' % (rel, id2)) + cr.execute("COMMENT ON TABLE \"%s\" IS 'RELATION BETWEEN %s AND %s'" % (rel, model._table, comodel._table)) + cr.commit() + _schema.debug("Create table '%s': m2m relation between '%s' and '%s'", rel, model._table, comodel._table) + return True + + def read(self, records): + comodel = records.env[self.comodel_name] + + # String domains are supposed to be dynamic and evaluated on client-side + # only (thus ignored here). + domain = self.domain if isinstance(self.domain, list) else [] + + wquery = comodel._where_calc(domain) + comodel._apply_ir_rules(wquery, 'read') + order_by = comodel._generate_order_by(None, wquery) + from_c, where_c, where_params = wquery.get_sql() + query = """ SELECT {rel}.{id1}, {rel}.{id2} FROM {rel}, {from_c} + WHERE {where_c} AND {rel}.{id1} IN %s AND {rel}.{id2} = {tbl}.id + {order_by} {limit} OFFSET {offset} + """.format(rel=self.relation, id1=self.column1, id2=self.column2, + tbl=comodel._table, from_c=from_c, where_c=where_c or '1=1', + limit=(' LIMIT %d' % self.limit) if self.limit else '', + offset=0, order_by=order_by) + where_params.append(tuple(records.ids)) + + # retrieve lines and group them by record + group = defaultdict(list) + records._cr.execute(query, where_params) + for row in records._cr.fetchall(): + group[row[0]].append(row[1]) + + # store result in cache + for record in records: + record._cache[self.name] = tuple(group[record.id]) + + def write(self, records, value): + cr = records._cr + comodel = records.env[self.comodel_name] + parts = dict(rel=self.relation, id1=self.column1, id2=self.column2) + + def link(ids): + # beware of duplicates when inserting + query = """ INSERT INTO {rel} ({id1}, {id2}) + (SELECT a, b FROM unnest(%s) AS a, unnest(%s) AS b) + EXCEPT (SELECT {id1}, {id2} FROM {rel} WHERE {id1} IN %s) + """.format(**parts) + for sub_ids in cr.split_for_in_conditions(ids): + cr.execute(query, (records.ids, list(sub_ids), tuple(records.ids))) + + def unlink(ids): + query = """ DELETE FROM {rel} + WHERE {id1} IN %s AND {id2} IN %s + """.format(**parts) + cr.execute(query, (tuple(records.ids), tuple(ids))) + + def unlink_all(): + # remove all records for which user has access rights + clauses, params, tables = comodel.env['ir.rule'].domain_get(comodel._name) + cond = " AND ".join(clauses) if clauses else "1=1" + query = """ DELETE FROM {rel} USING {tables} + WHERE {rel}.{id1} IN %s AND {rel}.{id2}={table}.id AND {cond} + """.format(table=comodel._table, tables=','.join(tables), cond=cond, **parts) + cr.execute(query, [tuple(records.ids)] + params) + + for act in (value or []): + if not isinstance(act, (list, tuple)) or not act: + continue + if act[0] == 0: + lines = records.mapped(lambda record: comodel.create(act[2])) + link(lines.ids) + elif act[0] == 1: + comodel.browse(act[1]).write(act[2]) + elif act[0] == 2: + comodel.browse(act[1]).unlink() + elif act[0] == 3: + unlink([act[1]]) + elif act[0] == 4: + link([act[1]]) + elif act[0] == 5: + unlink_all() + elif act[0] == 6: + unlink_all() + link(act[2]) + + +class Serialized(Field): + """ Serialized fields provide the storage for sparse fields. """ + type = 'serialized' + column_type = ('text', 'text') + + def convert_to_column(self, value, record): + return json.dumps(value) + + def convert_to_cache(self, value, record, validate=True): + # cache format: dict + value = value or {} + return value if isinstance(value, dict) else json.loads(value) + + +class Id(Field): + """ Special case for field 'id'. """ + type = 'integer' + column_type = ('int4', 'int4') + _slots = { + 'string': 'ID', + 'store': True, + 'readonly': True, + } + + def __get__(self, record, owner): + if record is None: + return self # the field is accessed through the class owner + if not record: + return False + return record.ensure_one()._ids[0] + + def __set__(self, record, value): + raise TypeError("field 'id' cannot be assigned") + +# imported here to avoid dependency cycle issues +from openerp import SUPERUSER_ID +from .exceptions import AccessError, MissingError, UserError +from .models import check_pg_name, BaseModel, IdType |
