""" This module contains classes and logic to handle operations on records
"""
from ..utils import (wpartial,
normalizeSField,
preprocess_args,
ustr,
DirMixIn)
from .object import Object
from .cache import (empty_cache,
Cache)
import six
import abc
import numbers
import functools
import collections
from extend_me import (ExtensibleType,
ExtensibleByHashType)
__all__ = (
'Record',
'RecordRelations',
'ObjectRecords',
'RecordList',
'get_record',
'get_record_list',
)
RecordMeta = ExtensibleByHashType._('Record', hashattr='object_name')
[docs]def get_record(obj, rid, cache=None, context=None):
""" Creates new Record instance
Use this method to create new records, because of standard
object creation bypasses extension's magic.
:param Object obj: instance of Object this record is related to
:param int rid: ID of database record to fetch data from
:param cache: Cache instance. (usualy generated by
function empty_cache()
:type cache: Cache
:param dict context: if specified, then cache's context
will be updated
:return: created Record instance
:rtype: Record
"""
cls = RecordMeta.get_class(obj.name, default=True)
return cls(obj, rid, cache=cache, context=context)
@six.python_2_unicode_compatible
[docs]class Record(six.with_metaclass(RecordMeta, DirMixIn)):
""" Base class for all Records
Do not use it to create record instances manualy.
Use ``get_record`` function instead.
It implements all extensions mangic
But class should be used for ``isinstance`` checks.
It is posible to create extensions of this class that will be binded
only to specific Odoo objects
For example, if You need to extend all recrods of products,
do something like this::
class MyProductRecord(Record):
class Meta:
object_name = 'product.product'
def __init__(self, *args, **kwargs):
super(MyProductRecord, self).__init__(*args, **kwargs)
# to avoid double read, save once read value to record
# instance
self._sale_orders = None
@property
def sale_orders(self):
''' Sale orders related to curent product
'''
if self._sale_orders is None:
so = self._client['sale.order']
domain = [('order_line.product_id', '=', self.id)]
self._sale_orders = so.search_records(
domain, cache=self._cache)
return self._sale_orders
And atfter this, next code is valid::
products = client['product.product'].search_records([])
products_so = products.filter(lambda p: bool(p.sale_orders))
products_so_gt_10 = products.filter(
lambda p: len(p.sale_orders) > 10)
for product in products_so_gt_10:
print("Product: %s" % product.default_code)
for pso in product.sale_orders:
print("\t%s" % pso.name)
:param Object obj: instance of object this record is related to
:param int rid: ID of database record to fetch data from
:param cache: Cache instance.
(usualy generated by function empty_cache())
:type cache: Cache
:param dict context: if specified, then cache's context
will be updated
Note, to create instance of cache call *empty_cache*
"""
__slots__ = ['_object', '_cache', '_lcache', '_id', '_related_objects']
def __init__(self, obj, rid, cache=None, context=None):
assert isinstance(obj, Object), "obj should be Object"
assert isinstance(rid, numbers.Integral), "rid must be int"
self._id = rid
self._object = obj
self._cache = empty_cache(obj.client) if cache is None else cache
self._lcache = self._cache[obj.name]
self._related_objects = {}
self._lcache[self._id] # ensure that ID of this record is in cache.
if context is not None:
self._lcache.update_context(context)
def __dir__(self):
res = super(Record, self).__dir__()
res.extend(self._columns_info.keys())
res.extend(self._object.stdcall_methods)
return list(set(res))
@property
def id(self):
""" Record ID
:rtype: int
"""
return self._id
@property
def _data(self):
""" Data dictionary for this record.
(Just a client to cache)
:rtype: dict
"""
return self._lcache[self._id]
@property
def context(self):
""" Returns context to be used for thist record
"""
return self._lcache.context
@property
def _service(self):
""" Returns instance of related Object service instance
"""
return self._object.service
@property
def _client(self):
""" Returns instance of related Client object
:rtype: openerp_proxy.core.Client
"""
return self._object.client
@property
def _columns_info(self):
""" Returns dictionary with information about columns of related object
"""
return self._object.columns_info
@property
def as_dict(self):
""" Provides dictionary with record's data in raw form
:rtype: dict
"""
return self._data.copy()
@property
def _name(self):
""" Returns result of ``name_get`` for this record
:rtype: str
"""
if self._data.get('__name_get_result', None) is None:
lcache = self._lcache
data = self._object.name_get(list(lcache), context=self.context)
for _id, name in data:
lcache[_id]['__name_get_result'] = name
return self._data.get('__name_get_result', u'ERROR')
def __str__(self):
return u"R(%s, %s)[%s]" % (self._object.name,
self.id,
ustr(self._name))
def __repr__(self):
return str(self)
def __int__(self):
return self._id
def __hash__(self):
return hash((self._object.name, self._id))
def __eq__(self, other):
if isinstance(other, Record):
return other.id == self._id
if isinstance(other, numbers.Integral):
return self._id == other
return False
def __ne__(self, other):
return not self.__eq__(other)
def _get_many2one_rel_obj(self, name, rel_data, cached=True):
""" Method used to fetch related object by name of field
that points to it
"""
if name not in self._related_objects or not cached:
if rel_data:
# Do not forged about relations in form [id, name]
rel_id = (rel_data[0]
if isinstance(rel_data, collections.Iterable)
else rel_data)
rel_obj = self._service.get_obj(
self._columns_info[name]['relation'])
self._related_objects[name] = get_record(rel_obj,
rel_id,
cache=self._cache,
context=self.context)
else:
self._related_objects[name] = False
return self._related_objects[name]
def _get_one2many_rel_obj(self, name, rel_ids, cached=True, limit=None):
""" Method used to fetch related objects by name of field
that points to them using one2many relation
"""
if name not in self._related_objects or not cached:
rel_obj = self._service.get_obj(
self._columns_info[name]['relation'])
self._related_objects[name] = get_record_list(rel_obj,
rel_ids,
cache=self._cache,
context=self.context)
return self._related_objects[name]
def _get_field(self, ftype, name):
""" Returns value for field 'name' of type 'type'
:param str ftype: type of field to det value for
:param str name: name of field to read
Should be overridden by extensions to provide better hadling
for diferent field values
"""
if name not in self._data:
# save 'cache_field' function before for loop
cache_field = self._lcache.cache_field
# get list of ids in cache, that have not read requested field
for data in self._object.read(self._lcache.get_ids_to_read(name),
[name],
context=self.context):
# write each row of data to cache
cache_field(data['id'], ftype, name, data[name])
# relational fields
if ftype == 'many2one':
return self._get_many2one_rel_obj(name, self._data[name])
if ftype in ('one2many', 'many2many'):
return self._get_one2many_rel_obj(name, self._data[name])
return self._data[name]
# Allow dictionary access to data fields
def __getitem__(self, name):
if name == 'id':
return self.id
field = self._columns_info.get(name, None)
if field is None:
raise KeyError("No such field %s in object %s, %s"
"" % (name, self._object.name, self.id))
ftype = field and field['type']
# TODO: refactore to be able to pass field instead of only field type
return self._get_field(ftype, name)
# Allow to access data as attributes and call object's methods
# directly from record object
def __getattr__(self, name):
try:
res = self[name] # Try to get data field
except KeyError:
method = getattr(self._object, name)
res = wpartial(method, [self.id])
setattr(self, name, res)
return res
[docs] def refresh(self):
"""Reread data and clean-up the caches
:returns: self
:rtype: Record
"""
self._data.clear()
self._data['id'] = self._id
# Update related objects cache
rel_objects = self._related_objects
self._related_objects = {} # cleanup related_objects cache
# recursively cleanup related records
for rel in rel_objects.values():
if isinstance(rel, (Record, RecordList)):
# both, Record and RecordList objects have *refresh* method
rel.refresh()
return self
[docs] def read(self, fields=None, context=None, multi=False):
""" Rereads data for this record (or for al records in whole cache)
:param list fields: list of fields to be read (optional)
:param dict context: context to be passed to read (optional)
does not midify record's context
:param bool multi: if set to True, that data will be read for
all records of this object in current
cache (query).
:return: dict with data had been read
:rtype: dict
"""
ctx = {} if self.context is None else self.context.copy()
if context is not None:
ctx.update(context)
ids = list(self._lcache) if multi else [self.id]
args, kwargs = preprocess_args(ids, fields, context=ctx or None)
res = {}
for rdata in self._object.read(*args, **kwargs):
self._lcache[rdata['id']].update(rdata)
if rdata['id'] == self.id:
res = rdata
return res
[docs] def copy(self, default=None, context=None):
""" copy this record.
:param dict default: dictionary default values for new record
(optional)
:param dict context: dictionary with context used to copy
this record. (optional)
:return: Record instance for created record
:rtype: Record
Note about context: by default cache's context will be used,
and if some context will be passed to this method, new dict,
which is combination of default context and passed context,
will be passed to server.
"""
ctx = {} if self.context is None else self.context.copy()
if context is not None:
ctx.update(context)
# None values should not be passed via xml-rpc
args, kwargs = preprocess_args(self.id,
default=default,
context=ctx or None)
new_id = self._object.copy(*args, **kwargs)
return get_record(self._object,
new_id,
cache=self._cache,
context=self.context)
[docs] def get(self, field_name, default=None):
""" Try to get field *field_name*, if if field name is not available
return *default* value for it
if *default* is None and it is not possible to get field value,
then raises *KeyErro*
:param str field_name: name of field to get value for
:param default: default value for case when no such field
:return: field value
:raises KeyError: if cannot get field value
Note: This may be useful for code that expected to be working for
different Odoo versions which have different database schemes.
"""
try:
res = self[field_name]
except KeyError:
if default is None:
raise
else:
res = default
return res
RecordListMeta = ExtensibleType._('RecordList', with_meta=abc.ABCMeta)
[docs]def get_record_list(obj, ids=None, fields=None, cache=None, context=None):
""" Returns new instance of RecordList object.
:param obj: instance of Object to make this list related to
:type obj: Object
:param ids: list of IDs of objects to read data from
:type ids: list of int
:param fields: list of field names to read by default (not used now)
:type fields: list of strings (not used now)
:param cache: Cache instance.
(usualy generated by function empty_cache()
:type cache: Cache
:param context: context to be passed automatically to methods
called from this list (not used yet)
:type context: dict
"""
return RecordListMeta.get_object(obj,
ids,
fields=fields,
cache=cache,
context=context)
# TODO: impelment additional operators
# - operator: +
# - operator: +=
#
# TODO: implement correct bechavior of cache when adding new records to record
# list with diferent cache
@six.python_2_unicode_compatible
[docs]class RecordList(six.with_metaclass(RecordListMeta,
collections.MutableSequence,
DirMixIn)):
"""Class to hold list of records with some extra functionality
:param obj: instance of Object to make this list related to
:type obj: Object
:param ids: list of IDs of objects to read data from
:type ids: list of int
:param fields: list of field names to read by default
:type fields: list of strings
:param cache: Cache instance.
(usualy generated by function empty_cache()
:type cache: Cache
:param context: context to be passed automatically to methods
called from this list (not used yet)
:type context: dict
"""
__slots__ = ('_object', '_cache', '_lcache', '_records')
def __init__(self, obj, ids=None, fields=None, cache=None, context=None):
"""
"""
self._object = obj
self._cache = empty_cache(obj.client) if cache is None else cache
self._lcache = self._cache[obj.name]
if context is not None:
self._lcache.update_context(context)
ids = [] if ids is None else ids
# We need to add these ids to cache to make prefetching and data
# reading work correctly. if some of ids will not be present in cache,
# then, on access to field of record with such id, data will not be
# read from database.
# Look into *Record._get_field* method for more info
self._lcache.update_keys(ids)
_cache = self._cache # before loop, save cache in separate variable
self._records = [get_record(obj, id_, cache=_cache)
for id_ in ids]
# if there some fields prefetching was requested, do it
if fields is not None:
self.prefetch(*fields)
def __dir__(self):
res = super(RecordList, self).__dir__()
res.extend(self._object.stdcall_methods)
return list(set(res))
@property
def object(self):
""" Object this record is related to
"""
return self._object
@property
def context(self):
""" Returns context to be used for this list
"""
return self._lcache.context
@property
def ids(self):
""" IDs of records present in this RecordList
"""
return [r.id for r in self._records]
@property
def records(self):
""" Returns list (class 'list') of records
"""
return self._records
@property
def length(self):
""" Returns length of this record list
"""
return len(self._records)
def _new_context(self, new_context=None):
""" Create new context which is combination of *self.context*
and passed context argument.
mostly for internal usage
:param dict new_context: new context. default is None
:return: new context dict which is combination of
*self.context* and *new_context* or *None*
:rtype: dict|None
"""
if new_context is None:
return self.context
ctx = {} if self.context is None else self.context.copy()
ctx.update(new_context)
return ctx
# Container related methods
def __getitem__(self, index):
if isinstance(index, slice):
# Note no context passed, because it is stored in cache
return get_record_list(self.object,
ids=[r.id for r in self._records[index]],
cache=self._cache)
return self._records[index]
def __setitem__(self, index, value):
if isinstance(value, Record):
self._records[index] = value
else:
raise ValueError("In 'RecordList[index] = value' operation, "
"value must be instance of Record")
def __delitem__(self, index):
del self._records[index]
def __iter__(self):
return iter(self._records)
def __len__(self):
return self.length
def __contains__(self, item):
if isinstance(item, numbers.Integral):
return item in self.ids
if isinstance(item, Record):
return item in self._records
return False
[docs] def insert(self, index, item):
""" Insert record to list
:param item: Record instance to be inserted into list.
if int passed, it considered to be ID of record
:type item: Record|int
:param int index: position where to place new element
:return: self
:rtype: RecordList
"""
assert isinstance(item, (Record, numbers.Integral)), \
"Only Record or int instances could be added to list"
if isinstance(item, Record):
self._records.insert(index, item)
else:
self._records.insert(index, self._object.read_records(
item, cache=self._cache))
return self
# Overridden to make ability to call methods of object on list of IDs
# present in this RecordList
def __getattr__(self, name):
method = getattr(self.object, name)
kwargs = {} if self.context is None else {'context': self.context}
res = wpartial(method, self.ids, **kwargs)
return res
def __str__(self):
return u"RecordList(%s): length=%s" % (self.object.name, self.length)
def __repr__(self):
return str(self)
[docs] def refresh(self):
""" Cleanup data caches. next try to get data will cause rereading of it
:returns: self
:rtype: instance of RecordList
"""
for record in self.records:
record.refresh()
return self
[docs] def sort(self, key=None, reverse=False):
""" sort(key=None, reverse=False) -- inplace sort
anyfield.SField instances may be safely passed as 'key' arguments.
no need to convert them to function explicitly
:return: self
"""
if callable(key):
key = normalizeSField(key)
self._records.sort(key=key, reverse=reverse)
return self
[docs] def group_by(self, grouper):
""" Groups all records in list by specifed grouper.
:param grouper: field name or callable to group results by.
if callable is passed, it should receive only
one argument - record instance, and result of
calling grouper will be used as key
to group records by.
:type grouper: string|callable(record)|anyfield.SField
:return: dictionary
for example we have list of sale orders and
want to group it by state
.. code-block:: python
# so_list - variable that contains list of sale orders selected
# by some criterias. so to group it by state we will do:
group = so_list.group_by('state')
# Iterate over resulting dictionary
for state, rlist in group.iteritems():
# Print state and amount of items with such state
print state, rlist.length
or imagine that we would like to group records
by last letter of sale order number
.. code-block:: python
# so_list - variable that contains list of sale orders selected
# by some criterias. so to group it by last letter of sale
# order name we will do:
group = so_list.group_by(lambda so: so.name[-1])
# Iterate over resulting dictionary
for letter, rlist in group.iteritems():
# Print state and amount of items with such state
print letter, rlist.length
"""
if callable(grouper):
grouper = normalizeSField(grouper)
cls_init = functools.partial(get_record_list,
self.object,
ids=[],
cache=self._cache)
res = collections.defaultdict(cls_init)
for record in self.records:
if isinstance(grouper, six.string_types):
key = record[grouper]
elif callable(grouper):
key = grouper(record)
res[key].append(record)
return res
[docs] def filter(self, func):
""" Filters items using *func*.
:param func: callable to check if record should be included
in result.
:type func: callable(record)->bool|anyfield.SField
:return: RecordList which contains records that matches results
:rtype: RecordList
"""
func = normalizeSField(func)
return get_record_list(self.object,
ids=[r.id for r in self.records if func(r)],
cache=self._cache)
[docs] def mapped(self, field):
""" **Experimental**, Provides similar functionality
to Odoo's mapped() method, but supports only dot-separated
field name as argument, no callables yet.
Returns list of values of field of each record in this recordlist.
If value of field is RecordList or Record instance,
than RecordList instance will be returned
Thus folowing code will work
.. code:: python
# returns a list of names
records.mapped('name')
# returns a recordset of partners
record.mapped('partner_id')
# returns the union of all partner banks,
# with duplicates removed
record.mapped('partner_id.bank_ids')
:param str field: returns list of values of 'field'
for each record in this RecordList
:rtype: list or RecordList
"""
def get_field(rec):
fields = field.split('.')
val = rec
while fields and val:
f = fields.pop(0)
val = val[f]
return val
# Choose type of result
(res_model,
res_field,
res_rel_model) = self._object.resolve_field_path(field)[-1]
if res_rel_model:
res_obj = self._object.client[res_rel_model]
res = get_record_list(res_obj,
[],
cache=self._cache,
context=self.context)
else:
res = []
for record in self.records:
val = get_field(record)
if not val:
continue
if isinstance(val, RecordList):
res.extend(val)
elif val not in res:
res.append(val)
return res
[docs] def copy(self, context=None, new_cache=False):
""" Returns copy of this list, possibly with modified context
and new empty cache.
:param dict context: new context values to be used on new list
:param bool new_cache: if set to True, then new cache instance
will be created for resulting recordlist
if set to Cache instance, than it will be
used for resulting recordlist
:return: copy of this record list.
:rtype: RecordList
:raises ValueError: when incorrect value passed to new_cache
"""
if isinstance(new_cache, Cache):
cache = new_cache
elif not new_cache:
cache = self._cache
elif new_cache is True:
cache = empty_cache(self.object.client)
else:
raise ValueError("Wrong value for parametr 'new_cache': %r"
"" % (new_cache,))
return get_record_list(self.object,
ids=self.ids,
cache=cache,
context=context)
[docs] def existing(self, uniqify=True):
""" Filters this list with only existing items
:parm bool uniqify: if set to True, then all dublicates
will be removed. Default: True
:return: new RecordList instance
:rtype: RecordList
"""
existing_ids = self.exists()
new_ids = []
for id_ in self.ids:
if id_ not in existing_ids:
continue
if uniqify and id_ in new_ids:
continue
new_ids.append(id_)
return get_record_list(self.object,
ids=new_ids,
cache=self._cache)
[docs] def prefetch(self, *fields):
""" Prefetches specified fields into cache
if no fields passed, then all 'simple_fields' will be prefetched
By default field read performed only when that field is requested,
thus when You need to read more then one field, few rpc requests
will be performed. to avoid multiple unneccessary rpc calls this
method is implemented.
:return: self, which allows chaining of operations
:rtype: RecordList
"""
fields = fields if fields else self.object.simple_fields
self._lcache.prefetch_fields(fields)
return self
# remote method overrides
[docs] def search(self, domain, *args, **kwargs):
""" Performs normal search, but adds ``('id', 'in', self.ids)``
to search domain
:returns: list of IDs found
:rtype: list of integers
"""
ctx = self._new_context(kwargs.get('context', None))
if ctx is not None:
kwargs['context'] = ctx
return self.object.search([('id', 'in', self.ids)] + domain,
*args,
**kwargs)
[docs] def search_records(self, domain, *args, **kwargs):
""" Performs normal search_records, but adds
``('id', 'in', self.ids)`` to domain
:returns: RecordList of records found
:rtype: RecordList instance
"""
ctx = self._new_context(kwargs.get('context', None))
if ctx is not None:
kwargs['context'] = ctx
return self.object.search_records([('id', 'in', self.ids)] + domain,
*args,
**kwargs)
[docs] def read(self, fields=None, context=None):
""" Read wrapper. Takes care about adding RecordList's context to
object's read method.
**Warning**: does not update cache by data been read
"""
ctx = self._new_context(context)
args, kwargs = preprocess_args(fields, context=ctx)
return self.object.read(self.ids, *args, **kwargs)
# For backward compatability
RecordRelations = Record
[docs]class ObjectRecords(Object):
""" Adds support to use records from Object classes
"""
def __init__(self, *args, **kwargs):
super(ObjectRecords, self).__init__(*args, **kwargs)
self._model = None
@property
def model(self):
""" Returns Record instance of model related to this object.
Useful to get additional info on object.
"""
if self._model is None:
model_obj = self.client.get_obj('ir.model')
res = model_obj.search_records([('model', '=', self.name)],
limit=2)
assert res.length == 1, \
"There must be only one model for this name"
self._model = res[0]
return self._model
@property
def model_name(self):
""" Result of name_get called on object's model
"""
return self.model._name
@property
def simple_fields(self):
""" List of simple fields which could be fetched fast enough
This list contains all fields that are not function nor binary
:type: list of strings
"""
return [f for f, d in six.iteritems(self.columns_info)
if d['type'] != 'binary' and not d.get('function', False)]
[docs] def search_records(self, *args, **kwargs):
""" Return instance or list of instances of Record class,
making available to work with data simpler
:param domain: list of tuples, specifying search domain
:param int offset: (optional) number of results to skip
in the returned values
(default:0)
:param limit: optional max number of records in result
(default: False)
:type limit: int|False
:param order: optional columns to sort
:type order: str
:param dict context: optional context to pass to *search* method
:param count: if set to True, then only amount of recrods found
will be returned.
(default: False)
:param read_fields: optional. specifies list of fields to read.
:type read_fields: list of strings
:param Cache cache: cache to be used for records and recordlists
:return: RecordList contains records found, or integer
that represents amount of records found (if count=True)
:rtype: RecordList|int
For example:
.. code:: python
>>> so_obj = db['sale.order']
>>> data = so_obj.search_records([('date','>=','2013-01-01')])
>>> for order in data:
... order.write({'note': 'order date is %s'%order.date})
"""
# TODO: use search_read for odoo versions >= 8.0
read_fields = kwargs.pop('read_fields', None)
cache = kwargs.pop('cache', None)
context = kwargs.get('context', None)
if kwargs.get('count', False):
return self.search(*args, **kwargs)
res = self.search(*args, **kwargs)
if not res:
return get_record_list(self,
ids=[],
fields=read_fields,
context=context,
cache=cache)
if read_fields:
return self.read_records(res,
read_fields,
context=context,
cache=cache)
return self.read_records(res, context=context, cache=cache)
[docs] def read_records(self, ids, fields=None, context=None, cache=None):
""" Return instance or RecordList class,
making available to work with data simpler
:param ids: ID or list of IDS to read data for
:type ids: int|list of int
:param list fields: list of fields to read (*optional*)
:param dict context: context to be passed to read. default=None
:param Cache cache: cache to use for records and record lists.
Pass None to create new cache. default=None.
:return: Record instance if *ids* is int or RecordList instance
if *ids* is list of ints
:rtype: Record|RecordList
For example:
.. code:: python
>>> so_obj = db['sale.order']
>>> data = so_obj.read_records([1,2,3,4,5])
>>> for order in data:
order.write({'note': 'order data is %s'%order.data})
"""
if isinstance(ids, numbers.Integral):
record = get_record(self, ids, context=context)
if fields is not None:
record.read(fields) # read specified fields
return record
if isinstance(ids, collections.Iterable):
return get_record_list(self, ids, fields=fields, context=context)
raise ValueError("Wrong type for ids argument: %s" % type(ids))
[docs] def browse(self, *args, **kwargs):
""" Aliase to *read_records* method.
In most cases same as serverside *browse*
(i mean server version 7.0)
"""
return self.read_records(*args, **kwargs)