Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_qweb_fields
from . import ir_http
from . import ir_model
from . import ir_ui_menu
from . import models
from . import base_document_layout

View file

@ -0,0 +1,329 @@
# -*- coding: utf-8 -*-
import markupsafe
import os
from markupsafe import Markup
from odoo import api, fields, models, tools
from odoo.addons.base.models.ir_qweb_fields import nl2br
from odoo.modules import get_resource_path
from odoo.tools import file_path, html2plaintext, is_html_empty
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
try:
from PIL.Image import Resampling
except ImportError:
from PIL import Image as Resampling
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'
@api.model
def _default_report_footer(self):
company = self.env.company
footer_fields = [field for field in [company.phone, company.email, company.website, company.vat] if isinstance(field, str) and len(field) > 0]
return Markup(' ').join(footer_fields)
@api.model
def _default_company_details(self):
company = self.env.company
address_format, company_data = company.partner_id._prepare_display_address()
address_format = self._clean_address_format(address_format, company_data)
# company_name may *still* be missing from prepared address in case commercial_company_name is falsy
if 'company_name' not in address_format:
address_format = '%(company_name)s\n' + address_format
company_data['company_name'] = company_data['company_name'] or company.name
return Markup(nl2br(address_format)) % company_data
def _clean_address_format(self, address_format, company_data):
missing_company_data = [k for k, v in company_data.items() if not v]
for key in missing_company_data:
if key in address_format:
address_format = address_format.replace(f'%({key})s\n', '')
return address_format
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.Html(related='company_id.report_header', readonly=False)
report_footer = fields.Html(related='company_id.report_footer', readonly=False, default=_default_report_footer)
company_details = fields.Html(related='company_id.company_details', readonly=False, default=_default_company_details)
is_company_details_empty = fields.Boolean(compute='_compute_empty_company_details')
# 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")
layout_background = fields.Selection(related='company_id.layout_background', readonly=False)
layout_background_image = fields.Binary(related='company_id.layout_background_image', readonly=False)
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)
# 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.extract_image_primary_secondary_colors(wizard_for_image.logo)
@api.depends('report_layout_id', 'logo', 'font', 'primary_color', 'secondary_color', 'report_header', 'report_footer', 'layout_background', 'layout_background_image', 'company_details')
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:
# guarantees that bin_size is always set to False,
# so the logo always contains the bin data instead of the binary size
if wizard.env.context.get('bin_size'):
wizard_with_logo = wizard.with_context(bin_size=False)
else:
wizard_with_logo = wizard
preview_css = markupsafe.Markup(self._get_css_for_preview(styles, wizard_with_logo.id))
ir_ui_view = wizard_with_logo.env['ir.ui.view']
wizard.preview = ir_ui_view._render_template('web.report_invoice_wizard_preview', {
'company': wizard_with_logo,
'preview_css': preview_css,
'is_html_empty': is_html_empty,
})
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
# company_details and report_footer can store empty strings (set by the user) or false (meaning the user didn't set a value). Since both are falsy values, we use isinstance of string to differentiate them
wizard.report_footer = wizard.company_id.report_footer if isinstance(wizard.company_id.report_footer, str) else wizard.report_footer
wizard.company_details = wizard.company_id.company_details if isinstance(wizard.company_id.company_details, str) else wizard.company_details
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
@api.model
def extract_image_primary_secondary_colors(self, logo, white_threshold=225, mitigate=175):
"""
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: logo to process
:param white_threshold: arbitrary value defining the maximum value a color can reach
:param mitigate: arbitrary value defining the maximum value a band can reach
:return colors: hex values of primary and secondary colors
"""
if not logo:
return False, False
# The "===" gives different base64 encoding a correct padding
logo += b'===' if isinstance(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=Resampling.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, mitigate=mitigate)
secondary = tools.average_dominant_color(remaining, mitigate=mitigate)[0] if remaining 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:
"""
company_styles = self.env['ir.qweb']._render('web.styles_company_report', {
'company_ids': self,
}, raise_if_not_found=False)
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
"""
def scss_importer(path, *args):
*parent_path, file = os.path.split(path)
try:
parent_path = file_path(os.path.join(*parent_path))
except FileNotFoundError:
parent_path = file_path(os.path.join(bootstrap_path, *parent_path))
return [(os.path.join(parent_path, file),)]
# 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,
],
importers=[(0, scss_importer)],
output_style=output_style,
precision=precision,
)
except libsass.CompileError as e:
raise libsass.CompileError(e.args[0])
@api.depends('company_details')
def _compute_empty_company_details(self):
# In recent change when an html field is empty a <p> balise remains with a <br> in it,
# but when company details is empty we want to put the info of the company
for record in self:
record.is_company_details_empty = not html2plaintext(record.company_details or '')

View file

@ -0,0 +1,179 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import json
import logging
import odoo
from odoo import api, http, models
from odoo.http import request
from odoo.tools import file_open, image_process, ustr
from odoo.tools.misc import str2bool
_logger = logging.getLogger(__name__)
"""
Debug mode is stored in session and should always be a string.
It can be activated with an URL query string `debug=<mode>` where mode
is either:
- 'tests' to load tests assets
- 'assets' to load assets non minified
- any other truthy value to enable simple debug mode (to show some
technical feature, to show complete traceback in frontend error..)
- any falsy value to disable debug mode
You can use any truthy/falsy value from `str2bool` (eg: 'on', 'f'..)
Multiple debug modes can be activated simultaneously, separated with a
comma (eg: 'tests, assets').
"""
ALLOWED_DEBUG_MODES = ['', '1', 'assets', 'tests', 'disable-t-cache']
class Http(models.AbstractModel):
_inherit = 'ir.http'
bots = ["bot", "crawl", "slurp", "spider", "curl", "wget", "facebookexternalhit", "whatsapp", "trendsmapresolver", "pinterest", "instagram", "google-pagerenderer", "preview"]
@classmethod
def is_a_bot(cls):
user_agent = request.httprequest.user_agent.string.lower()
# We don't use regexp and ustr voluntarily
# timeit has been done to check the optimum method
return any(bot in user_agent for bot in cls.bots)
@classmethod
def _handle_debug(cls):
debug = request.httprequest.args.get('debug')
if debug is not None:
request.session.debug = ','.join(
mode if mode in ALLOWED_DEBUG_MODES
else '1' if str2bool(mode, mode)
else ''
for mode in (debug or '').split(',')
)
@classmethod
def _pre_dispatch(cls, rule, args):
super()._pre_dispatch(rule, args)
cls._handle_debug()
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 = self.env.user
session_uid = request.session.uid
version_info = odoo.service.common.exp_version()
if session_uid:
user_context = dict(self.env['res.users'].context_get())
if user_context != request.session.context:
request.session.context = user_context
else:
user_context = {}
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
))
mods = odoo.conf.server_wide_modules or []
if request.db:
mods = list(request.registry._init_modules) + mods
session_info = {
"uid": session_uid,
"is_system": user._is_system() if session_uid else False,
"is_admin": user._is_admin() if session_uid else False,
"user_context": user_context,
"db": self.env.cr.dbname,
"server_version": version_info.get('server_version'),
"server_version_info": version_info.get('server_version_info'),
"support_url": "https://www.odoo.com/buy",
"name": user.name,
"username": user.login,
"partner_display_name": user.partner_id.display_name,
"company_id": user.company_id.id if session_uid else None, # YTI TODO: Remove this from the user context
"partner_id": user.partner_id.id if 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')),
'profile_session': request.session.profile_session,
'profile_collectors': request.session.profile_collectors,
'profile_params': request.session.profile_params,
"max_file_upload_size": max_file_upload_size,
"home_action_id": user.action_id.id,
"cache_hashes": {
"translations": self.env['ir.http'].sudo().get_web_translations_hash(
mods, request.session.context['lang']
) if session_uid else None,
},
"currencies": self.sudo().get_currencies(),
'bundle_params': {
'lang': request.session.context['lang'],
},
}
if request.session.debug:
session_info['bundle_params']['debug'] = request.session.debug
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)
menus = self.env['ir.ui.menu'].with_context(lang=request.session.context['lang']).load_menus(request.session.debug)
ordered_menus = {str(k): v for k, v in menus.items()}
menu_json_utf8 = json.dumps(ordered_menus, default=ustr, sort_keys=True).encode()
session_info['cache_hashes'].update({
"load_menus": hashlib.sha512(menu_json_utf8).hexdigest()[:64], # sha512/256
})
session_info.update({
# current_company should be default_company
"user_companies": {
'current_company': user.company_id.id,
'allowed_companies': {
comp.id: {
'id': comp.id,
'name': comp.name,
'sequence': comp.sequence,
} for comp in user.company_ids
},
},
"show_effect": True,
"display_switch_company_menu": user.has_group('base.group_multi_company') and len(user.company_ids) > 1,
})
return session_info
@api.model
def get_frontend_session_info(self):
user = self.env.user
session_uid = request.session.uid
session_info = {
'is_admin': user._is_admin() if session_uid else False,
'is_system': user._is_system() if session_uid else False,
'is_website_user': user._is_public() if session_uid else False,
'user_id': user.id if session_uid else False,
'is_frontend': True,
'profile_session': request.session.profile_session,
'profile_collectors': request.session.profile_collectors,
'profile_params': request.session.profile_params,
'show_effect': bool(request.env['ir.config_parameter'].sudo().get_param('base_setup.show_effect')),
'bundle_params': {
'lang': request.session.context['lang'],
},
}
if request.session.debug:
session_info['bundle_params']['debug'] = request.session.debug
if 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 = self.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}

View file

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, api
class IrModel(models.Model):
_inherit = "ir.model"
@api.model
def display_name_for(self, models):
"""
Returns the display names from provided models which the current user can access.
The result is the same whether someone tries to access an inexistent model or a model they cannot access.
:models list(str): list of technical model names to lookup (e.g. `["res.partner"]`)
:return: list of dicts of the form `{ "model", "display_name" }` (e.g. `{ "model": "res_partner", "display_name": "Contact"}`)
"""
# Store accessible models in a temporary list in order to execute only one SQL query
accessible_models = []
not_accessible_models = []
for model in models:
if self._check_model_access(model):
accessible_models.append(model)
else:
not_accessible_models.append({"display_name": model, "model": model})
return self._display_name_for(accessible_models) + not_accessible_models
@api.model
def _display_name_for(self, models):
records = self.sudo().search_read([("model", "in", models)], ["name", "model"])
return [{
"display_name": model["name"],
"model": model["model"],
} for model in records]
@api.model
def _check_model_access(self, model):
return (self.env.user._is_internal() and model in self.env
and self.env[model].check_access_rights("read", raise_exception=False))
@api.model
def get_available_models(self):
"""
Return the list of models the current user has access to, with their
corresponding display name.
"""
accessible_models = [model for model in self.pool.keys() if self._check_model_access(model)]
return self._display_name_for(accessible_models)

View file

@ -0,0 +1,123 @@
# -*- 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 markupsafe import Markup
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 options['filename-field'] in record and record[options['filename-field']]:
filename = record[options['filename-field']]
elif options.get('filename'):
filename = options['filename']
else:
filename = record.display_name
filename = (filename or 'name').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."
src = src_zoom = None
if options.get('qweb_img_raw_data', False):
value = record[field_name]
if value is False:
return False
src = self._get_src_data_b64(value, options)
else:
src, src_zoom = self._get_src_urls(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))
if options.get('alt-field') and options['alt-field'] in record and record[options['alt-field']]:
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["width"] = options.get('width')
atts["height"] = options.get('height')
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)
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 Markup(''.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)

View file

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrUiMenu(models.Model):
_inherit = "ir.ui.menu"
def load_web_menus(self, debug):
""" Loads all menu items (all applications and their sub-menus) and
processes them to be used by the webclient. Mainly, it associates with
each application (top level menu) the action of its first child menu
that is associated with an action (recursively), i.e. with the action
to execute when the opening the app.
:return: the menus (including the images in Base64)
"""
menus = self.load_menus(debug)
web_menus = {}
for menu in menus.values():
if not menu['id']:
# special root menu case
web_menus['root'] = {
"id": 'root',
"name": menu['name'],
"children": menu['children'],
"appID": False,
"xmlid": "",
"actionID": False,
"actionModel": False,
"webIcon": None,
"webIconData": None,
"backgroundImage": menu.get('backgroundImage'),
}
else:
action = menu['action']
if menu['id'] == menu['app_id']:
# if it's an app take action of first (sub)child having one defined
child = menu
while child and not action:
action = child['action']
child = menus[child['children'][0]] if child['children'] else False
action_model, action_id = action.split(',') if action else (False, False)
action_id = int(action_id) if action_id else False
web_menus[menu['id']] = {
"id": menu['id'],
"name": menu['name'],
"children": menu['children'],
"appID": menu['app_id'],
"xmlid": menu['xmlid'],
"actionID": action_id,
"actionModel": action_model,
"webIcon": menu['web_icon'],
"webIconData": menu['web_icon_data'],
}
return web_menus

View file

@ -0,0 +1,817 @@
# -*- 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, OrderedSet
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, count_limit=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': []
}
current_length = len(records) + offset
limit_reached = len(records) == limit
force_search_count = self._context.get('force_search_count')
count_limit_reached = count_limit and count_limit <= current_length
if limit and ((limit_reached and not count_limit_reached) or force_search_count):
length = self.search_count(domain, limit=count_limit)
else:
length = current_length
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 in ('many2one', 'many2many'):
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':
if not expand:
if field.base_field.groupable:
domain_image = self._search_panel_domain_image(field_name, model_domain, limit=limit)
image_element_ids = list(domain_image.keys())
else:
model_records = self.search_read(model_domain, [field_name])
image_element_ids = OrderedSet()
for rec in model_records:
if rec[field_name]:
image_element_ids.update(rec[field_name])
image_element_ids = list(image_element_ids)
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)}
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:
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
])
values['__count'] = self.search_count(search_count_domain)
field_range.append(values)
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})