summaryrefslogtreecommitdiff
path: root/addons/web/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/models')
-rw-r--r--addons/web/models/__init__.py7
-rw-r--r--addons/web/models/base_document_layout.py278
-rw-r--r--addons/web/models/ir_http.py97
-rw-r--r--addons/web/models/ir_qweb.py116
-rw-r--r--addons/web/models/models.py818
5 files changed, 1316 insertions, 0 deletions
diff --git a/addons/web/models/__init__.py b/addons/web/models/__init__.py
new file mode 100644
index 00000000..79eef980
--- /dev/null
+++ b/addons/web/models/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import ir_qweb
+from . import ir_http
+from . import models
+from . import base_document_layout
diff --git a/addons/web/models/base_document_layout.py b/addons/web/models/base_document_layout.py
new file mode 100644
index 00000000..3396e0db
--- /dev/null
+++ b/addons/web/models/base_document_layout.py
@@ -0,0 +1,278 @@
+# -*- coding: utf-8 -*-
+from PIL import Image
+
+from odoo import api, fields, models, tools
+
+from odoo.modules import get_resource_path
+
+try:
+ import sass as libsass
+except ImportError:
+ # If the `sass` python library isn't found, we fallback on the
+ # `sassc` executable in the path.
+ libsass = None
+
+DEFAULT_PRIMARY = '#000000'
+DEFAULT_SECONDARY = '#000000'
+
+
+class BaseDocumentLayout(models.TransientModel):
+ """
+ Customise the company document layout and display a live preview
+ """
+
+ _name = 'base.document.layout'
+ _description = 'Company Document Layout'
+
+ company_id = fields.Many2one(
+ 'res.company', default=lambda self: self.env.company, required=True)
+
+ logo = fields.Binary(related='company_id.logo', readonly=False)
+ preview_logo = fields.Binary(related='logo', string="Preview logo")
+ report_header = fields.Text(related='company_id.report_header', readonly=False)
+ report_footer = fields.Text(related='company_id.report_footer', readonly=False)
+
+ # The paper format changes won't be reflected in the preview.
+ paperformat_id = fields.Many2one(related='company_id.paperformat_id', readonly=False)
+
+ external_report_layout_id = fields.Many2one(related='company_id.external_report_layout_id', readonly=False)
+
+ font = fields.Selection(related='company_id.font', readonly=False)
+ primary_color = fields.Char(related='company_id.primary_color', readonly=False)
+ secondary_color = fields.Char(related='company_id.secondary_color', readonly=False)
+
+ custom_colors = fields.Boolean(compute="_compute_custom_colors", readonly=False)
+ logo_primary_color = fields.Char(compute="_compute_logo_colors")
+ logo_secondary_color = fields.Char(compute="_compute_logo_colors")
+
+ report_layout_id = fields.Many2one('report.layout')
+
+ # All the sanitization get disabled as we want true raw html to be passed to an iframe.
+ preview = fields.Html(compute='_compute_preview',
+ sanitize=False,
+ sanitize_tags=False,
+ sanitize_attributes=False,
+ sanitize_style=False,
+ sanitize_form=False,
+ strip_style=False,
+ strip_classes=False)
+
+ # Those following fields are required as a company to create invoice report
+ partner_id = fields.Many2one(related='company_id.partner_id', readonly=True)
+ phone = fields.Char(related='company_id.phone', readonly=True)
+ email = fields.Char(related='company_id.email', readonly=True)
+ website = fields.Char(related='company_id.website', readonly=True)
+ vat = fields.Char(related='company_id.vat', readonly=True)
+ name = fields.Char(related='company_id.name', readonly=True)
+ country_id = fields.Many2one(related="company_id.country_id", readonly=True)
+
+ @api.depends('logo_primary_color', 'logo_secondary_color', 'primary_color', 'secondary_color',)
+ def _compute_custom_colors(self):
+ for wizard in self:
+ logo_primary = wizard.logo_primary_color or ''
+ logo_secondary = wizard.logo_secondary_color or ''
+ # Force lower case on color to ensure that FF01AA == ff01aa
+ wizard.custom_colors = (
+ wizard.logo and wizard.primary_color and wizard.secondary_color
+ and not(
+ wizard.primary_color.lower() == logo_primary.lower()
+ and wizard.secondary_color.lower() == logo_secondary.lower()
+ )
+ )
+
+ @api.depends('logo')
+ def _compute_logo_colors(self):
+ for wizard in self:
+ if wizard._context.get('bin_size'):
+ wizard_for_image = wizard.with_context(bin_size=False)
+ else:
+ wizard_for_image = wizard
+ wizard.logo_primary_color, wizard.logo_secondary_color = wizard_for_image._parse_logo_colors()
+
+ @api.depends('report_layout_id', 'logo', 'font', 'primary_color', 'secondary_color', 'report_header', 'report_footer')
+ def _compute_preview(self):
+ """ compute a qweb based preview to display on the wizard """
+
+ styles = self._get_asset_style()
+
+ for wizard in self:
+ if wizard.report_layout_id:
+ preview_css = self._get_css_for_preview(styles, wizard.id)
+ ir_ui_view = wizard.env['ir.ui.view']
+ wizard.preview = ir_ui_view._render_template('web.report_invoice_wizard_preview', {'company': wizard, 'preview_css': preview_css})
+ else:
+ wizard.preview = False
+
+ @api.onchange('company_id')
+ def _onchange_company_id(self):
+ for wizard in self:
+ wizard.logo = wizard.company_id.logo
+ wizard.report_header = wizard.company_id.report_header
+ wizard.report_footer = wizard.company_id.report_footer
+ wizard.paperformat_id = wizard.company_id.paperformat_id
+ wizard.external_report_layout_id = wizard.company_id.external_report_layout_id
+ wizard.font = wizard.company_id.font
+ wizard.primary_color = wizard.company_id.primary_color
+ wizard.secondary_color = wizard.company_id.secondary_color
+ wizard_layout = wizard.env["report.layout"].search([
+ ('view_id.key', '=', wizard.company_id.external_report_layout_id.key)
+ ])
+ wizard.report_layout_id = wizard_layout or wizard_layout.search([], limit=1)
+
+ if not wizard.primary_color:
+ wizard.primary_color = wizard.logo_primary_color or DEFAULT_PRIMARY
+ if not wizard.secondary_color:
+ wizard.secondary_color = wizard.logo_secondary_color or DEFAULT_SECONDARY
+
+ @api.onchange('custom_colors')
+ def _onchange_custom_colors(self):
+ for wizard in self:
+ if wizard.logo and not wizard.custom_colors:
+ wizard.primary_color = wizard.logo_primary_color or DEFAULT_PRIMARY
+ wizard.secondary_color = wizard.logo_secondary_color or DEFAULT_SECONDARY
+
+ @api.onchange('report_layout_id')
+ def _onchange_report_layout_id(self):
+ for wizard in self:
+ wizard.external_report_layout_id = wizard.report_layout_id.view_id
+
+ @api.onchange('logo')
+ def _onchange_logo(self):
+ for wizard in self:
+ # It is admitted that if the user puts the original image back, it won't change colors
+ company = wizard.company_id
+ # at that point wizard.logo has been assigned the value present in DB
+ if wizard.logo == company.logo and company.primary_color and company.secondary_color:
+ continue
+
+ if wizard.logo_primary_color:
+ wizard.primary_color = wizard.logo_primary_color
+ if wizard.logo_secondary_color:
+ wizard.secondary_color = wizard.logo_secondary_color
+
+ def _parse_logo_colors(self, logo=None, white_threshold=225):
+ """
+ Identifies dominant colors
+
+ First resizes the original image to improve performance, then discards
+ transparent colors and white-ish colors, then calls the averaging
+ method twice to evaluate both primary and secondary colors.
+
+ :param logo: alternate logo to process
+ :param white_threshold: arbitrary value defining the maximum value a color can reach
+
+ :return colors: hex values of primary and secondary colors
+ """
+ self.ensure_one()
+ logo = logo or self.logo
+ if not logo:
+ return False, False
+
+ # The "===" gives different base64 encoding a correct padding
+ logo += b'===' if type(logo) == bytes else '==='
+ try:
+ # Catches exceptions caused by logo not being an image
+ image = tools.image_fix_orientation(tools.base64_to_image(logo))
+ except Exception:
+ return False, False
+
+ base_w, base_h = image.size
+ w = int(50 * base_w / base_h)
+ h = 50
+
+ # Converts to RGBA (if already RGBA, this is a noop)
+ image_converted = image.convert('RGBA')
+ image_resized = image_converted.resize((w, h), resample=Image.NEAREST)
+
+ colors = []
+ for color in image_resized.getcolors(w * h):
+ if not(color[1][0] > white_threshold and
+ color[1][1] > white_threshold and
+ color[1][2] > white_threshold) and color[1][3] > 0:
+ colors.append(color)
+
+ if not colors: # May happen when the whole image is white
+ return False, False
+ primary, remaining = tools.average_dominant_color(colors)
+ secondary = tools.average_dominant_color(
+ remaining)[0] if len(remaining) > 0 else primary
+
+ # Lightness and saturation are calculated here.
+ # - If both colors have a similar lightness, the most colorful becomes primary
+ # - When the difference in lightness is too great, the brightest color becomes primary
+ l_primary = tools.get_lightness(primary)
+ l_secondary = tools.get_lightness(secondary)
+ if (l_primary < 0.2 and l_secondary < 0.2) or (l_primary >= 0.2 and l_secondary >= 0.2):
+ s_primary = tools.get_saturation(primary)
+ s_secondary = tools.get_saturation(secondary)
+ if s_primary < s_secondary:
+ primary, secondary = secondary, primary
+ elif l_secondary > l_primary:
+ primary, secondary = secondary, primary
+
+ return tools.rgb_to_hex(primary), tools.rgb_to_hex(secondary)
+
+ @api.model
+ def action_open_base_document_layout(self, action_ref=None):
+ if not action_ref:
+ action_ref = 'web.action_base_document_layout_configurator'
+ res = self.env["ir.actions.actions"]._for_xml_id(action_ref)
+ self.env[res["res_model"]].check_access_rights('write')
+ return res
+
+ def document_layout_save(self):
+ # meant to be overridden
+ return self.env.context.get('report_action') or {'type': 'ir.actions.act_window_close'}
+
+ def _get_asset_style(self):
+ """
+ Compile the style template. It is a qweb template expecting company ids to generate all the code in one batch.
+ We give a useless company_ids arg, but provide the PREVIEW_ID arg that will prepare the template for
+ '_get_css_for_preview' processing later.
+ :return:
+ """
+ template_style = self.env.ref('web.styles_company_report', raise_if_not_found=False)
+ if not template_style:
+ return b''
+
+ company_styles = template_style._render({
+ 'company_ids': self,
+ })
+
+ return company_styles
+
+ @api.model
+ def _get_css_for_preview(self, scss, new_id):
+ """
+ Compile the scss into css.
+ """
+ css_code = self._compile_scss(scss)
+ return css_code
+
+ @api.model
+ def _compile_scss(self, scss_source):
+ """
+ This code will compile valid scss into css.
+ Parameters are the same from odoo/addons/base/models/assetsbundle.py
+ Simply copied and adapted slightly
+ """
+
+ # No scss ? still valid, returns empty css
+ if not scss_source.strip():
+ return ""
+
+ precision = 8
+ output_style = 'expanded'
+ bootstrap_path = get_resource_path('web', 'static', 'lib', 'bootstrap', 'scss')
+
+ try:
+ return libsass.compile(
+ string=scss_source,
+ include_paths=[
+ bootstrap_path,
+ ],
+ output_style=output_style,
+ precision=precision,
+ )
+ except libsass.CompileError as e:
+ raise libsass.CompileError(e.args[0])
diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py
new file mode 100644
index 00000000..bb00603e
--- /dev/null
+++ b/addons/web/models/ir_http.py
@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import hashlib
+import json
+
+from odoo import api, models
+from odoo.http import request
+from odoo.tools import ustr
+
+from odoo.addons.web.controllers.main import module_boot, HomeStaticTemplateHelpers
+
+import odoo
+
+
+class Http(models.AbstractModel):
+ _inherit = 'ir.http'
+
+ def webclient_rendering_context(self):
+ return {
+ 'menu_data': request.env['ir.ui.menu'].load_menus(request.session.debug),
+ 'session_info': self.session_info(),
+ }
+
+ def session_info(self):
+ user = request.env.user
+ version_info = odoo.service.common.exp_version()
+
+ user_context = request.session.get_context() if request.session.uid else {}
+ IrConfigSudo = self.env['ir.config_parameter'].sudo()
+ max_file_upload_size = int(IrConfigSudo.get_param(
+ 'web.max_file_upload_size',
+ default=128 * 1024 * 1024, # 128MiB
+ ))
+
+ session_info = {
+ "uid": request.session.uid,
+ "is_system": user._is_system() if request.session.uid else False,
+ "is_admin": user._is_admin() if request.session.uid else False,
+ "user_context": request.session.get_context() if request.session.uid else {},
+ "db": request.session.db,
+ "server_version": version_info.get('server_version'),
+ "server_version_info": version_info.get('server_version_info'),
+ "name": user.name,
+ "username": user.login,
+ "partner_display_name": user.partner_id.display_name,
+ "company_id": user.company_id.id if request.session.uid else None, # YTI TODO: Remove this from the user context
+ "partner_id": user.partner_id.id if request.session.uid and user.partner_id else None,
+ "web.base.url": IrConfigSudo.get_param('web.base.url', default=''),
+ "active_ids_limit": int(IrConfigSudo.get_param('web.active_ids_limit', default='20000')),
+ "max_file_upload_size": max_file_upload_size,
+ }
+ if self.env.user.has_group('base.group_user'):
+ # the following is only useful in the context of a webclient bootstrapping
+ # but is still included in some other calls (e.g. '/web/session/authenticate')
+ # to avoid access errors and unnecessary information, it is only included for users
+ # with access to the backend ('internal'-type users)
+ mods = module_boot()
+ qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(addons=mods, debug=request.session.debug)
+ lang = user_context.get("lang")
+ translation_hash = request.env['ir.translation'].get_web_translations_hash(mods, lang)
+ menu_json_utf8 = json.dumps(request.env['ir.ui.menu'].load_menus(request.session.debug), default=ustr, sort_keys=True).encode()
+ cache_hashes = {
+ "load_menus": hashlib.sha512(menu_json_utf8).hexdigest()[:64], # sha512/256
+ "qweb": qweb_checksum,
+ "translations": translation_hash,
+ }
+ session_info.update({
+ # current_company should be default_company
+ "user_companies": {'current_company': (user.company_id.id, user.company_id.name), 'allowed_companies': [(comp.id, comp.name) for comp in user.company_ids]},
+ "currencies": self.get_currencies(),
+ "show_effect": True,
+ "display_switch_company_menu": user.has_group('base.group_multi_company') and len(user.company_ids) > 1,
+ "cache_hashes": cache_hashes,
+ })
+ return session_info
+
+ @api.model
+ def get_frontend_session_info(self):
+ session_info = {
+ 'is_admin': request.session.uid and self.env.user._is_admin() or False,
+ 'is_system': request.session.uid and self.env.user._is_system() or False,
+ 'is_website_user': request.session.uid and self.env.user._is_public() or False,
+ 'user_id': request.session.uid and self.env.user.id or False,
+ 'is_frontend': True,
+ }
+ if request.session.uid:
+ version_info = odoo.service.common.exp_version()
+ session_info.update({
+ 'server_version': version_info.get('server_version'),
+ 'server_version_info': version_info.get('server_version_info')
+ })
+ return session_info
+
+ def get_currencies(self):
+ Currency = request.env['res.currency']
+ currencies = Currency.search([]).read(['symbol', 'position', 'decimal_places'])
+ return {c['id']: {'symbol': c['symbol'], 'position': c['position'], 'digits': [69,c['decimal_places']]} for c in currencies}
diff --git a/addons/web/models/ir_qweb.py b/addons/web/models/ir_qweb.py
new file mode 100644
index 00000000..51694a3b
--- /dev/null
+++ b/addons/web/models/ir_qweb.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import hashlib
+from collections import OrderedDict
+from werkzeug.urls import url_quote
+
+from odoo import api, models
+from odoo.tools import pycompat
+from odoo.tools import html_escape as escape
+
+
+class Image(models.AbstractModel):
+ """
+ Widget options:
+
+ ``class``
+ set as attribute on the generated <img> tag
+ """
+ _name = 'ir.qweb.field.image'
+ _description = 'Qweb Field Image'
+ _inherit = 'ir.qweb.field.image'
+
+ def _get_src_urls(self, record, field_name, options):
+ """Considering the rendering options, returns the src and data-zoom-image urls.
+
+ :return: src, src_zoom urls
+ :rtype: tuple
+ """
+ max_size = None
+ if options.get('resize'):
+ max_size = options.get('resize')
+ else:
+ max_width, max_height = options.get('max_width', 0), options.get('max_height', 0)
+ if max_width or max_height:
+ max_size = '%sx%s' % (max_width, max_height)
+
+ sha = hashlib.sha512(str(getattr(record, '__last_update')).encode('utf-8')).hexdigest()[:7]
+ max_size = '' if max_size is None else '/%s' % max_size
+
+ if options.get('filename-field') and getattr(record, options['filename-field'], None):
+ filename = record[options['filename-field']]
+ elif options.get('filename'):
+ filename = options['filename']
+ else:
+ filename = record.display_name
+ filename = filename.replace('/', '-').replace('\\', '-').replace('..', '--')
+
+ src = '/web/image/%s/%s/%s%s/%s?unique=%s' % (record._name, record.id, options.get('preview_image', field_name), max_size, url_quote(filename), sha)
+
+ src_zoom = None
+ if options.get('zoom') and getattr(record, options['zoom'], None):
+ src_zoom = '/web/image/%s/%s/%s%s/%s?unique=%s' % (record._name, record.id, options['zoom'], max_size, url_quote(filename), sha)
+ elif options.get('zoom'):
+ src_zoom = options['zoom']
+
+ return src, src_zoom
+
+ @api.model
+ def record_to_html(self, record, field_name, options):
+ assert options['tagName'] != 'img',\
+ "Oddly enough, the root tag of an image field can not be img. " \
+ "That is because the image goes into the tag, or it gets the " \
+ "hose again."
+
+ if options.get('qweb_img_raw_data', False):
+ return super(Image, self).record_to_html(record, field_name, options)
+
+ aclasses = ['img', 'img-fluid'] if options.get('qweb_img_responsive', True) else ['img']
+ aclasses += options.get('class', '').split()
+ classes = ' '.join(map(escape, aclasses))
+
+ src, src_zoom = self._get_src_urls(record, field_name, options)
+
+ if options.get('alt-field') and getattr(record, options['alt-field'], None):
+ alt = escape(record[options['alt-field']])
+ elif options.get('alt'):
+ alt = options['alt']
+ else:
+ alt = escape(record.display_name)
+
+ itemprop = None
+ if options.get('itemprop'):
+ itemprop = options['itemprop']
+
+ atts = OrderedDict()
+ atts["src"] = src
+ atts["itemprop"] = itemprop
+ atts["class"] = classes
+ atts["style"] = options.get('style')
+ atts["alt"] = alt
+ atts["data-zoom"] = src_zoom and u'1' or None
+ atts["data-zoom-image"] = src_zoom
+ atts["data-no-post-process"] = options.get('data-no-post-process')
+
+ atts = self.env['ir.qweb']._post_processing_att('img', atts, options.get('template_options'))
+
+ img = ['<img']
+ for name, value in atts.items():
+ if value:
+ img.append(' ')
+ img.append(escape(pycompat.to_text(name)))
+ img.append('="')
+ img.append(escape(pycompat.to_text(value)))
+ img.append('"')
+ img.append('/>')
+
+ return u''.join(img)
+
+class ImageUrlConverter(models.AbstractModel):
+ _description = 'Qweb Field Image'
+ _inherit = 'ir.qweb.field.image_url'
+
+ def _get_src_urls(self, record, field_name, options):
+ image_url = record[options.get('preview_image', field_name)]
+ return image_url, options.get("zoom", None)
diff --git a/addons/web/models/models.py b/addons/web/models/models.py
new file mode 100644
index 00000000..a882df71
--- /dev/null
+++ b/addons/web/models/models.py
@@ -0,0 +1,818 @@
+# -*- 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 type(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)
+
+ 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, {
+ **self.env['ir.ui.view']._prepare_qcontext(),
+ **self._qweb_prepare_qcontext(view_id, domain),
+ })
+
+ def _qweb_prepare_qcontext(self, view_id, domain):
+ """
+ Base qcontext for rendering qweb views bound to this model
+ """
+ return {
+ '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 fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
+ r = super().fields_view_get(view_id, view_type, toolbar, submenu)
+ # 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 r['type'] == 'qweb':
+ root = etree.fromstring(r['arch'])
+ r['arch'] = etree.tostring(etree.Element('qweb', root.attrib))
+ return r
+
+ @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
+ def create(self, values):
+ res = super().create(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 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):
+ template_style = self.env.ref('web.styles_company_report', raise_if_not_found=False)
+ if not template_style:
+ return b''
+ # One bundle for everyone, so this method
+ # necessarily updates the style for every company at once
+ company_ids = self.sudo().search([])
+ company_styles = template_style._render({
+ 'company_ids': company_ids,
+ })
+ return base64.b64encode((company_styles))
+
+ 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})