Files
odoo_source/addons/web/models/models.py
T
Denis Ledoux b03c227e88 [REF] models: refactor fields_view_get, load_views
Refactor the `load_views` API so it no longer sends multiple times the same
fields description.

e.g.
When `load_views` is called to get the kanban, tree and form views,
the list of fields of the model was sent 4 times:
- Once for each view, with only the fields used in the view,
  in `['fields_views']['kanban']['fields']` for instance
- Once globally, with all the fields of the model, in `['fields']`

The goal of this revision is to change that so it sends the list of all fields
only once.

In addition, if a view contains x2many fields,
the fields description of the comodel is also sent.
It was sent in the `views` key of the view fields dict.
e.g.
When calling `load_views` of `res.partner` to get the kanban,
tree and form views,
the `res.partner` fields description was actually sent 6 times:
- Once for each view
- Once globally
- Once for each view of the many2many field `child_ids` of the form view, in
  - `['fields_views']['form']['fields']['child_ids']['views']['kanban']['fields']`
  - `['fields_views']['form']['fields']['child_ids']['views']['form']['fields']`

The change suggested in this revision is to:
- Remove the fields description for each view in `['fields_views']`.
  As it no longer contains the fields,
  the key becomes `['views']` instead of `['fields_views']`.
- Replace the dict key `['fields']` by `['models']`,
  which is a dict with as key the model name and as values
  the model fields description. It contains the fields description
  for all models implied in the view:
  the model of the main view and the model of all one2many and many2many fields.

With this change, the fields description will only be sent once by model
implied in the view.

In addition, the web client was getting the information about the fields
sometimes in the global fields description list (e.g. `['fields']`),
sometimes in the fields description list of the view type
(e.g. `['fields_views']['form']['fields']`),
making it a pain to try to make changes / performance gain
in these field description dictionaries, because you never knew in which dict
the web client was getting its info.
Now, as there is only one place to get the fields description from,
it's clearer and cleaner.

- one2many and many2many fields views are passed directly in the main view
  architecture rather than being put in the `views` key
  of the field description.
  This is actually easier to treat by the web client,
  and this will allow in a future work to cache an entire view in one block
  of text rather than having to combine multiple cached blocks of text
  to return one view.
- one2many and many2many fields which do not have directly embedded views
  have their views directly injected in the architecture,
  so the web client doesn't have to do RPC calls to `load_views`
  for each one2many and many2many fields not having embedded views.
  For instance, this allow to reduce the number of RPC calls to `load_views`
  from 8 to 1 when loading the form of `product.product`.
  Currently, this behavior is limited to 1 level deep but we consider making it
  go all the way down in future works. We did not do it for the moment because
  in certain cases it rises the processing time and the size (bytes) too much.
  e.g. the sale.order view can be 5 levels deep,
  meaning you can reach 4 dialogs on top the main view.
  ```
  sale.order form > order_line > sale.order.line form > invoice_lines >
  account.move.line form > asset_ids > account.asset form >
  depreciation_move_ids > account.move form.
  ```
  This will also benefit in future works to cache an entire view in one block
  of text rather to having to combine multiple cached block of text
  to get one view.
- `fields_view_get` becomes `get_view`.
  As it no longer returns the fields description,
  keeping the `fields` in the name `fields_view_get` no longer makes sense.
  Hence removing `fields` from the method name, it becomes `view_get`.
  As it gets renamed anyway, we take the opportunity to rename it `get_view`,
  which is more in line with the general getter/setter guidelines
  in the model object world.
- `_fields_view_get` becomes `_get_view`. For the same reasons than above.
- `load_views` becomes `get_views`.
  This is not mandatory, there is no technical reason to rename `load_views` as
  it practically sends the same info as before,
  the view architectures and their fields description. Just in another way.
  We just take the opportunity of this pull request to suggest a cleaner API:
  `_get_view`, `get_view` and `get_views`.
- Arguments `toolbar=False, submenu=False` fo the methods
  `_fields_view_get` and `fields_view_get` are converted to a kwargs `**options`
  in `_get_view` and `get_view`.
  The rationale is that submenu was already no longer used (deprecated)
  and the mobile options is introduced.
  The mobile options is necessary to tell the server to send the mobile views
  for x2many fields (kanban instead of tree).
  Instead of adding a new argument each time we add a new option to
  `fields_view_get`, it seems wiser to have a kwargs `**options` to avoid
  to re-write all overrides each time a new option is introduced.
- `_fields_view_get` returned a dict containing the arch in text and some of the
  view information. Now, `get_view` returns a tuple with the view architecture
  as an `etree` node, and the view as a browse record. The rationale is that all
  overrides of `_fields_view_get` were about modifying the arch only
  (e.g. changing the address format/re-organizing the address related field
  nodes of the partner according to the company country).
  To do so, all these overrides were doing `etree.fromstring` to parse the arch
  which was sent in text to convert it to an `etree`,
  then operations were done on the `etree`,
  and then `etree.tostring` was called to convert back the arch to string.
  With this change of signature to send the arch as an `etree`,
  all these back and forth `etree.fromstring` -> `etree.tostring` are avoided,
  allowing some performance gain and less code in the end.
- A cleanup of the keys returned in the dict of `fields_view_get`
  has been performed in `get_view`:
  - `fields` is removed, as explained above,
  - `view_id` is renamed `id`,
  - `name` is removed, it was unused by the web client,
  - `type` is removed, it was unused by the web client,
  - `field_parent` is removed, it was unused by the web client,
  - `base_model` is removed, it was unused by the web client.
- `filters` is moved from the global dict returned by `load_views`
  (now `get_views`) to the dict returned by `fields_view_get` (now `get_view`)
  as it applies only to the `search` view type.
- Retro-compatible methods for the 3 methods
  `fields_view_get`, `_fields_view_get` and `load_views` are provided,
  with deprecation warnings in them.

- The web client could cache the model fields description
  (as it already caches the views),
  so it doesn't need to fetch them again if it asks for another view of a model
  for which he already has the fields description.
  If we do so, `get_views` could return only the list of models used by
  the views, without the fields description as of now,
  and the web client would then call `fields_get` independently only for
  the models for which it doesn't have yet the fields description.
  This would avoid the server to return the fields description
  and to call `fields_get`, which is costly, for each `get_views`,
  therefore gaining performances.
- Inject the views of the one2many and many2many fields all the way down,
  unlimited depth level, as explained above.
- Cache with `ormcache` the architecture of back-end views.
  This is already done for qweb views, it's not done for back-end views.
  Therefore the postprocessing of the views is performed for each `get_views`,
  which is costly, while the view architecture doesn't change for users
  belonging to the same groups, according to the groups implied by the view.

This pull request is co-authored by
Aaron Bohy (aab) for the web client part and
Denis Ledoux (dle) for the server part.

Part-of: odoo/odoo#87522
2022-04-29 09:57:44 +02:00

811 lines
34 KiB
Python

# -*- coding: utf-8 -*-
import babel.dates
import pytz
from lxml import etree
import base64
import json
from odoo import _, _lt, api, fields, models
from odoo.osv.expression import AND, TRUE_DOMAIN, normalize_domain
from odoo.tools import date_utils, lazy
from odoo.tools.misc import get_lang
from odoo.exceptions import UserError
from collections import defaultdict
SEARCH_PANEL_ERROR_MESSAGE = _lt("Too many items to display.")
def is_true_domain(domain):
return normalize_domain(domain) == TRUE_DOMAIN
class lazymapping(defaultdict):
def __missing__(self, key):
value = self.default_factory(key)
self[key] = value
return value
DISPLAY_DATE_FORMATS = {
'day': 'dd MMM yyyy',
'week': "'W'w YYYY",
'month': 'MMMM yyyy',
'quarter': 'QQQ yyyy',
'year': 'yyyy',
}
class IrActionsActWindowView(models.Model):
_inherit = 'ir.actions.act_window.view'
view_mode = fields.Selection(selection_add=[
('qweb', 'QWeb')
], ondelete={'qweb': 'cascade'})
class Base(models.AbstractModel):
_inherit = 'base'
@api.model
def web_search_read(self, domain=None, fields=None, offset=0, limit=None, order=None):
"""
Performs a search_read and a search_count.
:param domain: search domain
:param fields: list of fields to read
:param limit: maximum number of records to read
:param offset: number of records to skip
:param order: columns to sort results
:return: {
'records': array of read records (result of a call to 'search_read')
'length': number of records matching the domain (result of a call to 'search_count')
}
"""
records = self.search_read(domain, fields, offset=offset, limit=limit, order=order)
if not records:
return {
'length': 0,
'records': []
}
if limit and (len(records) == limit or self.env.context.get('force_search_count')):
length = self.search_count(domain)
else:
length = len(records) + offset
return {
'length': length,
'records': records
}
@api.model
def web_read_group(self, domain, fields, groupby, limit=None, offset=0, orderby=False,
lazy=True, expand=False, expand_limit=None, expand_orderby=False):
"""
Returns the result of a read_group (and optionally search for and read records inside each
group), and the total number of groups matching the search domain.
:param domain: search domain
:param fields: list of fields to read (see ``fields``` param of ``read_group``)
:param groupby: list of fields to group on (see ``groupby``` param of ``read_group``)
:param limit: see ``limit`` param of ``read_group``
:param offset: see ``offset`` param of ``read_group``
:param orderby: see ``orderby`` param of ``read_group``
:param lazy: see ``lazy`` param of ``read_group``
:param expand: if true, and groupby only contains one field, read records inside each group
:param expand_limit: maximum number of records to read in each group
:param expand_orderby: order to apply when reading records in each group
:return: {
'groups': array of read groups
'length': total number of groups
}
"""
groups = self._web_read_group(domain, fields, groupby, limit, offset, orderby, lazy, expand,
expand_limit, expand_orderby)
if not groups:
length = 0
elif limit and len(groups) == limit:
# We need to fetch all groups to know the total number
# this cannot be done all at once to avoid MemoryError
length = limit
chunk_size = 100000
while True:
more = len(self.read_group(domain, ['display_name'], groupby, offset=length, limit=chunk_size, lazy=True))
length += more
if more < chunk_size:
break
else:
length = len(groups) + offset
return {
'groups': groups,
'length': length
}
@api.model
def _web_read_group(self, domain, fields, groupby, limit=None, offset=0, orderby=False,
lazy=True, expand=False, expand_limit=None, expand_orderby=False):
"""
Performs a read_group and optionally a web_search_read for each group.
See ``web_read_group`` for params description.
:returns: array of groups
"""
groups = self.read_group(domain, fields, groupby, offset=offset, limit=limit,
orderby=orderby, lazy=lazy)
if expand and len(groupby) == 1:
for group in groups:
group['__data'] = self.web_search_read(domain=group['__domain'], fields=fields,
offset=0, limit=expand_limit,
order=expand_orderby)
return groups
@api.model
def read_progress_bar(self, domain, group_by, progress_bar):
"""
Gets the data needed for all the kanban column progressbars.
These are fetched alongside read_group operation.
:param domain - the domain used in the kanban view to filter records
:param group_by - the name of the field used to group records into
kanban columns
:param progress_bar - the <progressbar/> declaration attributes
(field, colors, sum)
:return a dictionnary mapping group_by values to dictionnaries mapping
progress bar field values to the related number of records
"""
group_by_fname = group_by.partition(':')[0]
field_type = self._fields[group_by_fname].type
if field_type == 'selection':
selection_labels = dict(self.fields_get()[group_by]['selection'])
def adapt(value):
if field_type == 'selection':
value = selection_labels.get(value, False)
if isinstance(value, tuple):
value = value[1] # FIXME should use technical value (0)
return value
result = {}
for group in self._read_progress_bar(domain, group_by, progress_bar):
group_by_value = str(adapt(group[group_by]))
field_value = group[progress_bar['field']]
if group_by_value not in result:
result[group_by_value] = dict.fromkeys(progress_bar['colors'], 0)
if field_value in result[group_by_value]:
result[group_by_value][field_value] += group['__count']
return result
def _read_progress_bar(self, domain, group_by, progress_bar):
""" Implementation of read_progress_bar() that returns results in the
format of read_group().
"""
try:
fname = progress_bar['field']
return self.read_group(domain, [fname], [group_by, fname], lazy=False)
except UserError:
# possibly failed because of grouping on or aggregating non-stored
# field; fallback on alternative implementation
pass
# Workaround to match read_group's infrastructure
# TO DO in master: harmonize this function and readgroup to allow factorization
group_by_name = group_by.partition(':')[0]
group_by_modifier = group_by.partition(':')[2] or 'month'
records_values = self.search_read(domain or [], [progress_bar['field'], group_by_name])
field_type = self._fields[group_by_name].type
for record_values in records_values:
group_by_value = record_values.pop(group_by_name)
# Again, imitating what _read_group_format_result and _read_group_prepare_data do
if group_by_value and field_type in ['date', 'datetime']:
locale = get_lang(self.env).code
group_by_value = date_utils.start_of(fields.Datetime.to_datetime(group_by_value), group_by_modifier)
group_by_value = pytz.timezone('UTC').localize(group_by_value)
tz_info = None
if field_type == 'datetime' and self._context.get('tz') in pytz.all_timezones:
tz_info = self._context.get('tz')
group_by_value = babel.dates.format_datetime(
group_by_value, format=DISPLAY_DATE_FORMATS[group_by_modifier],
tzinfo=tz_info, locale=locale)
else:
group_by_value = babel.dates.format_date(
group_by_value, format=DISPLAY_DATE_FORMATS[group_by_modifier],
locale=locale)
if field_type == 'many2many' and isinstance(group_by_value, list):
group_by_value = str(tuple(group_by_value)) or False
record_values[group_by] = group_by_value
record_values['__count'] = 1
return records_values
##### qweb view hooks #####
@api.model
def qweb_render_view(self, view_id, domain):
assert view_id
return self.env['ir.qweb']._render(
view_id,
{
'model': self,
'domain': domain,
# not necessarily necessary as env is already part of the
# non-minimal qcontext
'context': self.env.context,
'records': lazy(self.search, domain),
})
@api.model
def _get_view(self, view_id=None, view_type='form', **options):
arch, view = super()._get_view(view_id, view_type, **options)
# avoid leaking the raw (un-rendered) template, also avoids bloating
# the response payload for no reason. Only send the root node,
# to send attributes such as `js_class`.
if view_type == 'qweb':
root = arch
arch = etree.Element('qweb', root.attrib)
return arch, view
@api.model
def _search_panel_field_image(self, field_name, **kwargs):
"""
Return the values in the image of the provided domain by field_name.
:param model_domain: domain whose image is returned
:param extra_domain: extra domain to use when counting records associated with field values
:param field_name: the name of a field (type many2one or selection)
:param enable_counters: whether to set the key '__count' in image values
:param only_counters: whether to retrieve information on the model_domain image or only
counts based on model_domain and extra_domain. In the later case,
the counts are set whatever is enable_counters.
:param limit: integer, maximal number of values to fetch
:param set_limit: boolean, whether to use the provided limit (if any)
:return: a dict of the form
{
id: { 'id': id, 'display_name': display_name, ('__count': c,) },
...
}
"""
enable_counters = kwargs.get('enable_counters')
only_counters = kwargs.get('only_counters')
extra_domain = kwargs.get('extra_domain', [])
no_extra = is_true_domain(extra_domain)
model_domain = kwargs.get('model_domain', [])
count_domain = AND([model_domain, extra_domain])
limit = kwargs.get('limit')
set_limit = kwargs.get('set_limit')
if only_counters:
return self._search_panel_domain_image(field_name, count_domain, True)
model_domain_image = self._search_panel_domain_image(field_name, model_domain,
enable_counters and no_extra,
set_limit and limit,
)
if enable_counters and not no_extra:
count_domain_image = self._search_panel_domain_image(field_name, count_domain, True)
for id, values in model_domain_image.items():
element = count_domain_image.get(id)
values['__count'] = element['__count'] if element else 0
return model_domain_image
@api.model
def _search_panel_domain_image(self, field_name, domain, set_count=False, limit=False):
"""
Return the values in the image of the provided domain by field_name.
:param domain: domain whose image is returned
:param field_name: the name of a field (type many2one or selection)
:param set_count: whether to set the key '__count' in image values. Default is False.
:param limit: integer, maximal number of values to fetch. Default is False.
:return: a dict of the form
{
id: { 'id': id, 'display_name': display_name, ('__count': c,) },
...
}
"""
field = self._fields[field_name]
if field.type == 'many2one':
def group_id_name(value):
return value
else:
# field type is selection: see doc above
desc = self.fields_get([field_name])[field_name]
field_name_selection = dict(desc['selection'])
def group_id_name(value):
return value, field_name_selection[value]
domain = AND([
domain,
[(field_name, '!=', False)],
])
groups = self.read_group(domain, [field_name], [field_name], limit=limit)
domain_image = {}
for group in groups:
id, display_name = group_id_name(group[field_name])
values = {
'id': id,
'display_name': display_name,
}
if set_count:
values['__count'] = group[field_name + '_count']
domain_image[id] = values
return domain_image
@api.model
def _search_panel_global_counters(self, values_range, parent_name):
"""
Modify in place values_range to transform the (local) counts
into global counts (local count + children local counts)
in case a parent field parent_name has been set on the range values.
Note that we save the initial (local) counts into an auxiliary dict
before they could be changed in the for loop below.
:param values_range: dict of the form
{
id: { 'id': id, '__count': c, parent_name: parent_id, ... }
...
}
:param parent_name: string, indicates which key determines the parent
"""
local_counters = lazymapping(lambda id: values_range[id]['__count'])
for id in values_range:
values = values_range[id]
# here count is the initial value = local count set on values
count = local_counters[id]
if count:
parent_id = values[parent_name]
while parent_id:
values = values_range[parent_id]
local_counters[parent_id]
values['__count'] += count
parent_id = values[parent_name]
@api.model
def _search_panel_sanitized_parent_hierarchy(self, records, parent_name, ids):
"""
Filter the provided list of records to ensure the following properties of
the resulting sublist:
1) it is closed for the parent relation
2) every record in it is an ancestor of a record with id in ids
(if ids = records.ids, that condition is automatically satisfied)
3) it is maximal among other sublists with properties 1 and 2.
:param records, the list of records to filter, the records must have the form
{ 'id': id, parent_name: False or (id, display_name),... }
:param parent_name, string, indicates which key determines the parent
:param ids: list of record ids
:return: the sublist of records with the above properties
}
"""
def get_parent_id(record):
value = record[parent_name]
return value and value[0]
allowed_records = { record['id']: record for record in records }
records_to_keep = {}
for id in ids:
record_id = id
ancestor_chain = {}
chain_is_fully_included = True
while chain_is_fully_included and record_id:
known_status = records_to_keep.get(record_id)
if known_status != None:
# the record and its known ancestors have already been considered
chain_is_fully_included = known_status
break
record = allowed_records.get(record_id)
if record:
ancestor_chain[record_id] = record
record_id = get_parent_id(record)
else:
chain_is_fully_included = False
for id, record in ancestor_chain.items():
records_to_keep[id] = chain_is_fully_included
# we keep initial order
return [rec for rec in records if records_to_keep.get(rec['id'])]
@api.model
def _search_panel_selection_range(self, field_name, **kwargs):
"""
Return the values of a field of type selection possibly enriched
with counts of associated records in domain.
:param enable_counters: whether to set the key '__count' on values returned.
Default is False.
:param expand: whether to return the full range of values for the selection
field or only the field image values. Default is False.
:param field_name: the name of a field of type selection
:param model_domain: domain used to determine the field image values and counts.
Default is [].
:return: a list of dicts of the form
{ 'id': id, 'display_name': display_name, ('__count': c,) }
with key '__count' set if enable_counters is True
"""
enable_counters = kwargs.get('enable_counters')
expand = kwargs.get('expand')
if enable_counters or not expand:
domain_image = self._search_panel_field_image(field_name, only_counters=expand, **kwargs)
if not expand:
return list(domain_image.values())
selection = self.fields_get([field_name])[field_name]['selection']
selection_range = []
for value, label in selection:
values = {
'id': value,
'display_name': label,
}
if enable_counters:
image_element = domain_image.get(value)
values['__count'] = image_element['__count'] if image_element else 0
selection_range.append(values)
return selection_range
@api.model
def search_panel_select_range(self, field_name, **kwargs):
"""
Return possible values of the field field_name (case select="one"),
possibly with counters, and the parent field (if any and required)
used to hierarchize them.
:param field_name: the name of a field;
of type many2one or selection.
:param category_domain: domain generated by categories. Default is [].
:param comodel_domain: domain of field values (if relational). Default is [].
:param enable_counters: whether to count records by value. Default is False.
:param expand: whether to return the full range of field values in comodel_domain
or only the field image values (possibly filtered and/or completed
with parents if hierarchize is set). Default is False.
:param filter_domain: domain generated by filters. Default is [].
:param hierarchize: determines if the categories must be displayed hierarchically
(if possible). If set to true and _parent_name is set on the
comodel field, the information necessary for the hierarchization will
be returned. Default is True.
:param limit: integer, maximal number of values to fetch. Default is None.
:param search_domain: base domain of search. Default is [].
with parents if hierarchize is set)
:return: {
'parent_field': parent field on the comodel of field, or False
'values': array of dictionaries containing some info on the records
available on the comodel of the field 'field_name'.
The display name, the __count (how many records with that value)
and possibly parent_field are fetched.
}
or an object with an error message when limit is defined and is reached.
"""
field = self._fields[field_name]
supported_types = ['many2one', 'selection']
if field.type not in supported_types:
types = dict(self.env["ir.model.fields"]._fields["ttype"]._description_selection(self.env))
raise UserError(_(
'Only types %(supported_types)s are supported for category (found type %(field_type)s)',
supported_types=", ".join(types[t] for t in supported_types),
field_type=types[field.type],
))
model_domain = kwargs.get('search_domain', [])
extra_domain = AND([
kwargs.get('category_domain', []),
kwargs.get('filter_domain', []),
])
if field.type == 'selection':
return {
'parent_field': False,
'values': self._search_panel_selection_range(field_name, model_domain=model_domain,
extra_domain=extra_domain, **kwargs
),
}
Comodel = self.env[field.comodel_name].with_context(hierarchical_naming=False)
field_names = ['display_name']
hierarchize = kwargs.get('hierarchize', True)
parent_name = False
if hierarchize and Comodel._parent_name in Comodel._fields:
parent_name = Comodel._parent_name
field_names.append(parent_name)
def get_parent_id(record):
value = record[parent_name]
return value and value[0]
else:
hierarchize = False
comodel_domain = kwargs.get('comodel_domain', [])
enable_counters = kwargs.get('enable_counters')
expand = kwargs.get('expand')
limit = kwargs.get('limit')
if enable_counters or not expand:
domain_image = self._search_panel_field_image(field_name,
model_domain=model_domain, extra_domain=extra_domain,
only_counters=expand,
set_limit= limit and not (expand or hierarchize or comodel_domain), **kwargs
)
if not (expand or hierarchize or comodel_domain):
values = list(domain_image.values())
if limit and len(values) == limit:
return {'error_msg': str(SEARCH_PANEL_ERROR_MESSAGE)}
return {
'parent_field': parent_name,
'values': values,
}
if not expand:
image_element_ids = list(domain_image.keys())
if hierarchize:
condition = [('id', 'parent_of', image_element_ids)]
else:
condition = [('id', 'in', image_element_ids)]
comodel_domain = AND([comodel_domain, condition])
comodel_records = Comodel.search_read(comodel_domain, field_names, limit=limit)
if hierarchize:
ids = [rec['id'] for rec in comodel_records] if expand else image_element_ids
comodel_records = self._search_panel_sanitized_parent_hierarchy(comodel_records, parent_name, ids)
if limit and len(comodel_records) == limit:
return {'error_msg': str(SEARCH_PANEL_ERROR_MESSAGE)}
field_range = {}
for record in comodel_records:
record_id = record['id']
values = {
'id': record_id,
'display_name': record['display_name'],
}
if hierarchize:
values[parent_name] = get_parent_id(record)
if enable_counters:
image_element = domain_image.get(record_id)
values['__count'] = image_element['__count'] if image_element else 0
field_range[record_id] = values
if hierarchize and enable_counters:
self._search_panel_global_counters(field_range, parent_name)
return {
'parent_field': parent_name,
'values': list(field_range.values()),
}
@api.model
def search_panel_select_multi_range(self, field_name, **kwargs):
"""
Return possible values of the field field_name (case select="multi"),
possibly with counters and groups.
:param field_name: the name of a filter field;
possible types are many2one, many2many, selection.
:param category_domain: domain generated by categories. Default is [].
:param comodel_domain: domain of field values (if relational)
(this parameter is used in _search_panel_range). Default is [].
:param enable_counters: whether to count records by value. Default is False.
:param expand: whether to return the full range of field values in comodel_domain
or only the field image values. Default is False.
:param filter_domain: domain generated by filters. Default is [].
:param group_by: extra field to read on comodel, to group comodel records
:param group_domain: dict, one domain for each activated group
for the group_by (if any). Those domains are
used to fech accurate counters for values in each group.
Default is [] (many2one case) or None.
:param limit: integer, maximal number of values to fetch. Default is None.
:param search_domain: base domain of search. Default is [].
:return: {
'values': a list of possible values, each being a dict with keys
'id' (value),
'name' (value label),
'__count' (how many records with that value),
'group_id' (value of group), set if a group_by has been provided,
'group_name' (label of group), set if a group_by has been provided
}
or an object with an error message when limit is defined and reached.
"""
field = self._fields[field_name]
supported_types = ['many2one', 'many2many', 'selection']
if field.type not in supported_types:
raise UserError(_('Only types %(supported_types)s are supported for filter (found type %(field_type)s)',
supported_types=supported_types, field_type=field.type))
model_domain = kwargs.get('search_domain', [])
extra_domain = AND([
kwargs.get('category_domain', []),
kwargs.get('filter_domain', []),
])
if field.type == 'selection':
return {
'values': self._search_panel_selection_range(field_name, model_domain=model_domain,
extra_domain=extra_domain, **kwargs
)
}
Comodel = self.env.get(field.comodel_name).with_context(hierarchical_naming=False)
field_names = ['display_name']
group_by = kwargs.get('group_by')
limit = kwargs.get('limit')
if group_by:
group_by_field = Comodel._fields[group_by]
field_names.append(group_by)
if group_by_field.type == 'many2one':
def group_id_name(value):
return value or (False, _("Not Set"))
elif group_by_field.type == 'selection':
desc = Comodel.fields_get([group_by])[group_by]
group_by_selection = dict(desc['selection'])
group_by_selection[False] = _("Not Set")
def group_id_name(value):
return value, group_by_selection[value]
else:
def group_id_name(value):
return (value, value) if value else (False, _("Not Set"))
comodel_domain = kwargs.get('comodel_domain', [])
enable_counters = kwargs.get('enable_counters')
expand = kwargs.get('expand')
if field.type == 'many2many':
comodel_records = Comodel.search_read(comodel_domain, field_names, limit=limit)
if expand and limit and len(comodel_records) == limit:
return {'error_msg': str(SEARCH_PANEL_ERROR_MESSAGE)}
group_domain = kwargs.get('group_domain')
field_range = []
for record in comodel_records:
record_id = record['id']
values= {
'id': record_id,
'display_name': record['display_name'],
}
if group_by:
group_id, group_name = group_id_name(record[group_by])
values['group_id'] = group_id
values['group_name'] = group_name
if enable_counters or not expand:
search_domain = AND([
model_domain,
[(field_name, 'in', record_id)],
])
local_extra_domain = extra_domain
if group_by and group_domain:
local_extra_domain = AND([
local_extra_domain,
group_domain.get(json.dumps(group_id), []),
])
search_count_domain = AND([
search_domain,
local_extra_domain
])
if enable_counters:
count = self.search_count(search_count_domain)
if not expand:
if enable_counters and is_true_domain(local_extra_domain):
inImage = count
else:
inImage = self.search(search_domain, limit=1)
if expand or inImage:
if enable_counters:
values['__count'] = count
field_range.append(values)
if not expand and limit and len(field_range) == limit:
return {'error_msg': str(SEARCH_PANEL_ERROR_MESSAGE)}
return { 'values': field_range, }
if field.type == 'many2one':
if enable_counters or not expand:
extra_domain = AND([
extra_domain,
kwargs.get('group_domain', []),
])
domain_image = self._search_panel_field_image(field_name,
model_domain=model_domain, extra_domain=extra_domain,
only_counters=expand,
set_limit=limit and not (expand or group_by or comodel_domain), **kwargs
)
if not (expand or group_by or comodel_domain):
values = list(domain_image.values())
if limit and len(values) == limit:
return {'error_msg': str(SEARCH_PANEL_ERROR_MESSAGE)}
return {'values': values, }
if not expand:
image_element_ids = list(domain_image.keys())
comodel_domain = AND([
comodel_domain,
[('id', 'in', image_element_ids)],
])
comodel_records = Comodel.search_read(comodel_domain, field_names, limit=limit)
if limit and len(comodel_records) == limit:
return {'error_msg': str(SEARCH_PANEL_ERROR_MESSAGE)}
field_range = []
for record in comodel_records:
record_id = record['id']
values= {
'id': record_id,
'display_name': record['display_name'],
}
if group_by:
group_id, group_name = group_id_name(record[group_by])
values['group_id'] = group_id
values['group_name'] = group_name
if enable_counters:
image_element = domain_image.get(record_id)
values['__count'] = image_element['__count'] if image_element else 0
field_range.append(values)
return { 'values': field_range, }
class ResCompany(models.Model):
_inherit = 'res.company'
@api.model_create_multi
def create(self, vals_list):
companies = super().create(vals_list)
style_fields = {'external_report_layout_id', 'font', 'primary_color', 'secondary_color'}
if any(not style_fields.isdisjoint(values) for values in vals_list):
self._update_asset_style()
return companies
def write(self, values):
res = super().write(values)
style_fields = {'external_report_layout_id', 'font', 'primary_color', 'secondary_color'}
if not style_fields.isdisjoint(values):
self._update_asset_style()
return res
def _get_asset_style_b64(self):
# One bundle for everyone, so this method
# necessarily updates the style for every company at once
company_ids = self.sudo().search([])
company_styles = self.env['ir.qweb']._render('web.styles_company_report', {
'company_ids': company_ids,
}, raise_if_not_found=False)
return base64.b64encode(company_styles.encode())
def _update_asset_style(self):
asset_attachment = self.env.ref('web.asset_styles_company_report', raise_if_not_found=False)
if not asset_attachment:
return
asset_attachment = asset_attachment.sudo()
b64_val = self._get_asset_style_b64()
if b64_val != asset_attachment.datas:
asset_attachment.write({'datas': b64_val})