mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 00:52:03 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
9
odoo-bringout-oca-ocb-web/web/models/__init__.py
Normal file
9
odoo-bringout-oca-ocb-web/web/models/__init__.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
329
odoo-bringout-oca-ocb-web/web/models/base_document_layout.py
Normal file
329
odoo-bringout-oca-ocb-web/web/models/base_document_layout.py
Normal 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 '')
|
||||
179
odoo-bringout-oca-ocb-web/web/models/ir_http.py
Normal file
179
odoo-bringout-oca-ocb-web/web/models/ir_http.py
Normal 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}
|
||||
48
odoo-bringout-oca-ocb-web/web/models/ir_model.py
Normal file
48
odoo-bringout-oca-ocb-web/web/models/ir_model.py
Normal 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)
|
||||
123
odoo-bringout-oca-ocb-web/web/models/ir_qweb_fields.py
Normal file
123
odoo-bringout-oca-ocb-web/web/models/ir_qweb_fields.py
Normal 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)
|
||||
62
odoo-bringout-oca-ocb-web/web/models/ir_ui_menu.py
Normal file
62
odoo-bringout-oca-ocb-web/web/models/ir_ui_menu.py
Normal 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
|
||||
817
odoo-bringout-oca-ocb-web/web/models/models.py
Normal file
817
odoo-bringout-oca-ocb-web/web/models/models.py
Normal 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})
|
||||
Loading…
Add table
Add a link
Reference in a new issue