diff options
Diffstat (limited to 'openerp/fields.py')
| -rw-r--r-- | openerp/fields.py | 376 |
1 files changed, 230 insertions, 146 deletions
diff --git a/openerp/fields.py b/openerp/fields.py index 08270ae52f0..3c4744a89e0 100644 --- a/openerp/fields.py +++ b/openerp/fields.py @@ -40,7 +40,6 @@ DATETIME_LENGTH = len(datetime.now().strftime(DATETIME_FORMAT)) _logger = logging.getLogger(__name__) - class SpecialValue(object): """ Encapsulates a value in the cache in place of a normal value. """ def __init__(self, value): @@ -125,12 +124,21 @@ class Field(object): :param default: the default value for the field; this is either a static value, or a function taking a recordset and returning a value - :param states: a dictionary mapping state values to lists of attribute-value - pairs; possible attributes are: 'readonly', 'required', 'invisible' + :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) + .. _field-computed: .. rubric:: Computed fields @@ -228,11 +236,12 @@ class Field(object): .. rubric:: Incremental definition - A field is defined as class attribute on a model class. If the model is - extended (see :class:`BaseModel`), 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. + 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``:: @@ -259,10 +268,11 @@ class Field(object): relational = False # whether the field is a relational one model_name = None # name of the model of this field comodel_name = None # name of the model of values (if relational) - inverse_field = None # inverse field (object), if it exists + inverse_fields = None # list of inverse fields (objects) 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 copyable = True # whether the field is copied over by BaseModel.copy() depends = () # collection of field dependencies recursive = False # whether self depends on itself @@ -270,6 +280,7 @@ class Field(object): 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) default = None # default value @@ -279,6 +290,8 @@ class Field(object): required = False states = None groups = False # csv list of group xml ids + change_default = None # whether the field may trigger a "user-onchange" + deprecated = None # whether the field is ... deprecated def __init__(self, string=None, **kwargs): kwargs['string'] = string @@ -286,7 +299,9 @@ class Field(object): self._free_attrs = [] def copy(self, **kwargs): - """ make a copy of `self`, possibly modified with parameters `kwargs` """ + """ copy(item) -> test + + make a copy of `self`, possibly modified with parameters `kwargs` """ field = copy(self) field._attrs = {key: val for key, val in kwargs.iteritems() if val is not None} field._free_attrs = list(self._free_attrs) @@ -348,7 +363,7 @@ class Field(object): # invalidates the cache of each `field`, and registers the records to # recompute based on `path`. See method `modified` below for details. self._triggers = set() - self.inverse_field = None + self.inverse_fields = [] def setup(self, env): """ Complete the setup of `self` (dependencies, recomputation triggers, @@ -403,7 +418,8 @@ class Field(object): self.depends = ('.'.join(self.related),) self.compute = self._compute_related self.inverse = self._inverse_related - self.search = self._search_related + if field._description_searchable(env): + self.search = self._search_related # copy attributes from field to self (string, help, etc.) for attr, prop in self.related_attrs: @@ -412,13 +428,16 @@ class Field(object): def _compute_related(self, records): """ Compute the related field `self` on `records`. """ - for record in records: - # bypass access rights check when traversing the related path - value = record.sudo() if record.id else record - # traverse the intermediate fields, and keep at most one record + # 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: + # draft record, do not switch to another environment + other = record + # traverse the intermediate fields; follow the first record at each step for name in self.related[:-1]: - value = value[name][:1] - record[self.name] = value[self.related[-1]] + other = other[name][:1] + record[self.name] = other[self.related[-1]] def _inverse_related(self, records): """ Inverse the related field `self` on `records`. """ @@ -496,10 +515,10 @@ class Field(object): #_logger.debug("Add trigger on %s to recompute %s", field, self) field._triggers.add((self, '.'.join(path0 or ['id']))) - # add trigger on inverse field, too - if field.inverse_field: - #_logger.debug("Add trigger on %s to recompute %s", field.inverse_field, self) - field.inverse_field._triggers.add((self, '.'.join(path0 + [head]))) + # add trigger on inverse fields, too + for invf in field.inverse_fields: + #_logger.debug("Add trigger on %s to recompute %s", invf, self) + invf._triggers.add((self, '.'.join(path0 + [head]))) # recursively traverse the dependency if tail: @@ -519,23 +538,28 @@ class Field(object): def get_description(self, env): """ Return a dictionary that describes the field `self`. """ desc = {'type': self.type} - # determine 'store' - if self.store: - # if the corresponding column is a function field, check the column - column = env[self.model_name]._columns.get(self.name) - desc['store'] = bool(getattr(column, 'store', True)) - else: - desc['store'] = False - # determine other attributes for attr, prop in self.description_attrs: value = getattr(self, prop) if callable(value): value = value(env) - if value: + if value is not None: desc[attr] = value + return desc # properties used by get_description() + + def _description_store(self, env): + if self.store: + # if the corresponding column is a function field, check the column + column = env[self.model_name]._columns.get(self.name) + return bool(getattr(column, 'store', True)) + return False + + def _description_searchable(self, env): + return self._description_store(env) or bool(self.search) + + _description_manual = property(attrgetter('manual')) _description_depends = property(attrgetter('depends')) _description_related = property(attrgetter('related')) _description_company_dependent = property(attrgetter('company_dependent')) @@ -543,6 +567,8 @@ class Field(object): _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')) def _description_string(self, env): if self.string and env.lang: @@ -588,12 +614,15 @@ class Field(object): # properties used by to_column() to create a column instance _column_copy = property(attrgetter('copyable')) _column_select = property(attrgetter('index')) + _column_manual = property(attrgetter('manual')) _column_string = property(attrgetter('string')) _column_help = property(attrgetter('help')) _column_readonly = property(attrgetter('readonly')) _column_required = property(attrgetter('required')) _column_states = property(attrgetter('states')) _column_groups = property(attrgetter('groups')) + _column_change_default = property(attrgetter('change_default')) + _column_deprecated = property(attrgetter('deprecated')) ############################################################################ # @@ -604,11 +633,13 @@ class Field(object): """ return the null value for this field in the given environment """ return False - def convert_to_cache(self, value, env, validate=True): + def convert_to_cache(self, value, record, validate=True): """ convert `value` to the cache level in `env`; `value` may come from an assignment, or have the format of methods :meth:`BaseModel.read` or :meth:`BaseModel.write` + :param record: the target record for the assignment, or an empty recordset + :param bool validate: when True, field-specific validation of `value` will be performed """ @@ -693,7 +724,7 @@ class Field(object): record.ensure_one() # adapt value to the cache level - value = self.convert_to_cache(value, env) + value = self.convert_to_cache(value, record) if env.in_draft or not record.id: # determine dependent fields @@ -702,8 +733,8 @@ class Field(object): # set value in cache, inverse field, and mark record as dirty record._cache[self] = value if env.in_onchange: - if self.inverse_field: - self.inverse_field._update(value, record) + for invf in self.inverse_fields: + invf._update(value, record) record._dirty = True # determine more dependent fields, and invalidate them @@ -853,10 +884,9 @@ class Field(object): class Boolean(Field): - """ Boolean field. """ type = 'boolean' - def convert_to_cache(self, value, env, validate=True): + def convert_to_cache(self, value, record, validate=True): return bool(value) def convert_to_export(self, value, env): @@ -866,10 +896,9 @@ class Boolean(Field): class Integer(Field): - """ Integer field. """ type = 'integer' - def convert_to_cache(self, value, env, validate=True): + def convert_to_cache(self, value, record, validate=True): return int(value or 0) def convert_to_read(self, value, use_name_get=True): @@ -885,11 +914,10 @@ class Integer(Field): class Float(Field): - """ 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) + """ 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' _digits = None # digits argument passed to class initializer @@ -909,7 +937,7 @@ class Float(Field): _column_digits = property(lambda self: not callable(self._digits) and self._digits) _column_digits_compute = property(lambda self: callable(self._digits) and self._digits) - def convert_to_cache(self, value, env, validate=True): + def convert_to_cache(self, value, record, validate=True): # apply rounding here, otherwise value in cache may be wrong! if self.digits: return float_round(float(value or 0.0), precision_digits=self.digits[1]) @@ -927,14 +955,11 @@ class _String(Field): class Char(_String): - """ Char field. - - :param size: the maximum size of values stored for that field (integer, - optional) - - :param translate: whether the value of the field has translations - (boolean, by default ``False``) + """ 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 bool translate: whether the values of this field can be translated """ type = 'char' size = None @@ -943,34 +968,41 @@ class Char(_String): _related_size = property(attrgetter('size')) _description_size = property(attrgetter('size')) - def convert_to_cache(self, value, env, validate=True): - return bool(value) and ustr(value)[:self.size] - + 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): - """ Text field. Very similar to :class:`Char`, but typically for longer - contents. - - :param translate: whether the value of the field has translations - (boolean, by default ``False``) + """ Text field. Very similar to :class:`~.Char` but used for longer + contents and displayed as a multiline text box + :param translate: whether the value of this field can be translated """ type = 'text' - def convert_to_cache(self, value, env, validate=True): - return bool(value) and ustr(value) - + 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): - """ Html field. """ type = 'html' + sanitize = True # whether value must be sanitized - def convert_to_cache(self, value, env, validate=True): - return bool(value) and html_sanitize(value) + _column_sanitize = property(attrgetter('sanitize')) + _related_sanitize = property(attrgetter('sanitize')) + _description_sanitize = property(attrgetter('sanitize')) + + 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) + return value class Date(Field): - """ Date field. """ type = 'date' @staticmethod @@ -1014,12 +1046,15 @@ class Date(Field): """ Convert a :class:`date` value into the format expected by the ORM. """ return value.strftime(DATE_FORMAT) - def convert_to_cache(self, value, env, validate=True): + def convert_to_cache(self, value, record, validate=True): if not value: return False if isinstance(value, basestring): - value = self.from_string(value) - return value.strftime(DATE_FORMAT) + 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, env): if value and env.context.get('export_raw_data'): @@ -1028,7 +1063,6 @@ class Date(Field): class Datetime(Field): - """ Datetime field. """ type = 'datetime' @staticmethod @@ -1079,12 +1113,18 @@ class Datetime(Field): """ Convert a :class:`datetime` value into the format expected by the ORM. """ return value.strftime(DATETIME_FORMAT) - def convert_to_cache(self, value, env, validate=True): + def convert_to_cache(self, value, record, validate=True): if not value: return False if isinstance(value, basestring): - value = self.from_string(value) - return value.strftime(DATETIME_FORMAT) + 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, env): if value and env.context.get('export_raw_data'): @@ -1093,23 +1133,24 @@ class Datetime(Field): class Binary(Field): - """ Binary field. """ type = 'binary' class Selection(Field): - """ 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. - - The attribute `selection` is mandatory except in the case of related - fields (see :ref:`field-related`) or field extensions - (see :ref:`field-incremental-definition`). + """ + :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' - selection = None # [(value, string), ...], model method or method name + selection = None # [(value, string), ...], function or method name + selection_add = None # [(value, string), ...] def __init__(self, selection=None, string=None, **kwargs): if callable(selection): @@ -1123,6 +1164,23 @@ class Selection(Field): field = self.related_field self.selection = lambda model: field._description_selection(model.env) + def _setup_regular(self, env): + super(Selection, self)._setup_regular(env) + # determine selection (applying extensions) + cls = type(env[self.model_name]) + selection = None + for field in resolve_all_mro(cls, self.name, reverse=True): + if isinstance(field, type(self)): + # We cannot use field.selection or field.selection_add here + # because those attributes are overridden by `set_class_name`. + if 'selection' in field._attrs: + selection = field._attrs['selection'] + if 'selection_add' in field._attrs: + selection = selection + field._attrs['selection_add'] + else: + selection = None + self.selection = selection + def _description_selection(self, env): """ return the selection list (pairs (value, label)); labels are translated according to context language @@ -1159,10 +1217,10 @@ class Selection(Field): selection = selection(env[self.model_name]) return [value for value, _ in selection] - def convert_to_cache(self, value, env, validate=True): + def convert_to_cache(self, value, record, validate=True): if not validate: return value or False - if value in self.get_values(env): + if value in self.get_values(record.env): return value elif not value: return False @@ -1179,16 +1237,6 @@ class Selection(Field): class Reference(Selection): - """ Reference field. - - :param selection: specifies the possible model names for this field. - It is given as either a list of pairs (`value`, `string`), or a - model method, or a method name. - - The attribute `selection` is mandatory except in the case of related - fields (see :ref:`field-related`) or field extensions - (see :ref:`field-incremental-definition`). - """ type = 'reference' size = 128 @@ -1199,14 +1247,14 @@ class Reference(Selection): _column_size = property(attrgetter('size')) - def convert_to_cache(self, value, env, validate=True): + def convert_to_cache(self, value, record, validate=True): if isinstance(value, BaseModel): - if ((not validate or value._name in self.get_values(env)) + if ((not validate or value._name in self.get_values(record.env)) and len(value) <= 1): - return value.with_env(env) or False + return value.with_env(record.env) or False elif isinstance(value, basestring): res_model, res_id = value.split(',') - return env[res_model].browse(int(res_id)) + return record.env[res_model].browse(int(res_id)) elif not value: return False raise ValueError("Wrong value for %s: %r" % (self, value)) @@ -1241,38 +1289,38 @@ class _Relational(Field): return env[self.comodel_name] def modified(self, records): - # Invalidate cache for self.inverse_field, too. Note that recomputation - # of fields that depend on self.inverse_field is already covered by the + # Invalidate cache for self.inverse_fields, too. Note that recomputation + # of fields that depend on self.inverse_fields is already covered by the # triggers (see above). spec = super(_Relational, self).modified(records) - if self.inverse_field: - spec.append((self.inverse_field, None)) + for invf in self.inverse_fields: + spec.append((invf, None)) return spec class Many2one(_Relational): - """ Many2one field; the value of such a field is a recordset of size 0 (no - record) or 1 (a single record). + """ 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 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 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 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 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 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``) + :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. + The attribute `comodel_name` is mandatory except in the case of related + fields or field extensions. """ type = 'many2one' ondelete = 'set null' # what to do when value is deleted @@ -1285,7 +1333,7 @@ class Many2one(_Relational): def _setup_regular(self, env): super(Many2one, self)._setup_regular(env) - # self.inverse_field is determined by the corresponding One2many field + # self.inverse_fields is populated by the corresponding One2many field # determine self.delegate self.delegate = self.name in env[self.model_name]._inherits.values() @@ -1297,19 +1345,19 @@ class Many2one(_Relational): """ Update the cached value of `self` for `records` with `value`. """ records._cache[self] = value - def convert_to_cache(self, value, env, validate=True): + def convert_to_cache(self, value, record, validate=True): if isinstance(value, (NoneType, int)): - return env[self.comodel_name].browse(value) + return record.env[self.comodel_name].browse(value) if isinstance(value, BaseModel): if value._name == self.comodel_name and len(value) <= 1: - return value.with_env(env) + return value.with_env(record.env) raise ValueError("Wrong value for %s: %r" % (self, value)) elif isinstance(value, tuple): - return env[self.comodel_name].browse(value[0]) + return record.env[self.comodel_name].browse(value[0]) elif isinstance(value, dict): - return env[self.comodel_name].new(value) + return record.env[self.comodel_name].new(value) else: - return env[self.comodel_name].browse(value) + return record.env[self.comodel_name].browse(value) def convert_to_read(self, value, use_name_get=True): if use_name_get and value: @@ -1321,7 +1369,7 @@ class Many2one(_Relational): return value.id def convert_to_write(self, value, target=None, fnames=None): - return bool(value) and (value.id or value._convert_to_write(value._cache)) + return value.id def convert_to_onchange(self, value): return value.id @@ -1342,35 +1390,57 @@ class Many2one(_Relational): record[self.name] = record.env[self.comodel_name].new() +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 + record._cache[field] = new_value = record[field.name] | value + return new_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: - record._cache[self] = record[self.name] | value + if self in record._cache: + record._cache[self] = record[self.name] | value + else: + record._cache[self] = UnionUpdate(self, record, value) - def convert_to_cache(self, value, env, validate=True): + def convert_to_cache(self, value, record, validate=True): if isinstance(value, BaseModel): if value._name == self.comodel_name: - return value.with_env(env) + return value.with_env(record.env) elif isinstance(value, list): # value is a list of record ids or commands - result = env[self.comodel_name] + if not record.id: + record = record.browse() # new record has no value + result = record[self.name] + # modify result with the commands; + # beware to not introduce duplicates in result for command in value: if isinstance(command, (tuple, list)): if command[0] == 0: result += result.new(command[2]) elif command[0] == 1: - record = result.browse(command[1]) - record.update(command[2]) - result += record + result.browse(command[1]).update(command[2]) elif command[0] == 2: - pass + # note: the record will be deleted by write() + result -= result.browse(command[1]) elif command[0] == 3: - pass + result -= result.browse(command[1]) elif command[0] == 4: - result += result.browse(command[1]) + result += result.browse(command[1]) - result elif command[0] == 5: result = result.browse() elif command[0] == 6: @@ -1378,10 +1448,10 @@ class _RelationalMulti(_Relational): elif isinstance(command, dict): result += result.new(command) else: - result += result.browse(command) + result += result.browse(command) - result return result elif not value: - return self.null(env) + return self.null(record.env) raise ValueError("Wrong value for %s: %s" % (self, value)) def convert_to_read(self, value, use_name_get=True): @@ -1399,10 +1469,10 @@ class _RelationalMulti(_Relational): add_existing = lambda id: result.append((4, id)) if fnames is None: - # take all fields in cache, except the inverse of self + # take all fields in cache, except the inverses of self fnames = set(value._fields) - set(MAGIC_COLUMNS) - if self.inverse_field: - fnames.discard(self.inverse_field.name) + for invf in self.inverse_fields: + fnames.discard(invf.name) # add new and existing records for record in value: @@ -1424,6 +1494,15 @@ class _RelationalMulti(_Relational): def convert_to_display_name(self, value): raise NotImplementedError() + def _compute_related(self, records): + """ Compute the related field `self` on `records`. """ + for record in records: + value = record + # traverse the intermediate fields, and keep at most one record + for name in self.related[:-1]: + value = value[name][:1] + record[self.name] = value[self.related[-1]] + class One2many(_RelationalMulti): """ One2many field; the value of such a field is the recordset of all the @@ -1469,8 +1548,8 @@ class One2many(_RelationalMulti): if self.inverse_name: # link self to its inverse field and vice-versa invf = env[self.comodel_name]._fields[self.inverse_name] - self.inverse_field = invf - invf.inverse_field = self + self.inverse_fields.append(invf) + invf.inverse_fields.append(self) _description_relation_field = property(attrgetter('inverse_name')) @@ -1540,8 +1619,8 @@ class Many2many(_RelationalMulti): # if inverse field has already been setup, it is present in m2m invf = m2m.get((self.relation, self.column2, self.column1)) if invf: - self.inverse_field = invf - invf.inverse_field = self + self.inverse_fields.append(invf) + invf.inverse_fields.append(self) else: # add self in m2m, so that its inverse field can find it m2m[(self.relation, self.column1, self.column2)] = self @@ -1555,12 +1634,17 @@ class Many2many(_RelationalMulti): class Id(Field): """ Special case for field 'id'. """ store = True + #: Can't write this! readonly = True def __init__(self, string=None, **kwargs): super(Id, self).__init__(type='integer', string=string, **kwargs) def to_column(self): + """ to_column() -> fields._column + + Whatever + """ return fields.integer('ID') def __get__(self, record, owner): |
