vanilla 19.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:49:46 +02:00
parent 991d2234ca
commit d1963a3c3a
3066 changed files with 1651266 additions and 922560 deletions

View file

@ -5,5 +5,12 @@ from . import ir_qweb_fields
from . import ir_http
from . import ir_model
from . import ir_ui_menu
from . import ir_ui_view
from . import models
from . import base_document_layout
from . import res_config_settings
from . import res_partner
from . import res_users_settings_embedded_action
from . import res_users_settings
from . import res_users
from . import properties_base_definition

View file

@ -1,20 +1,11 @@
# -*- coding: utf-8 -*-
import markupsafe
import os
from markupsafe import Markup
from math import ceil
from odoo import api, fields, models, tools
from odoo import api, fields, models
from odoo.addons.base.models.assetsbundle import ScssStylesheetAsset
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
from odoo.tools import html2plaintext, is_html_empty, image as tools
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:
@ -47,7 +38,7 @@ class BaseDocumentLayout(models.TransientModel):
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
return 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]
@ -113,7 +104,7 @@ class BaseDocumentLayout(models.TransientModel):
@api.depends('logo')
def _compute_logo_colors(self):
for wizard in self:
if wizard._context.get('bin_size'):
if wizard.env.context.get('bin_size'):
wizard_for_image = wizard.with_context(bin_size=False)
else:
wizard_for_image = wizard
@ -126,22 +117,29 @@ class BaseDocumentLayout(models.TransientModel):
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,
})
# guarantees that bin_size is always set to False,
# so the logo always contains the bin data instead of the binary size
wizard = wizard.with_context(bin_size=False)
wizard.preview = wizard.env['ir.ui.view']._render_template(
wizard._get_preview_template(),
wizard._get_render_information(styles),
)
else:
wizard.preview = False
def _get_preview_template(self):
return 'web.report_invoice_wizard_preview'
def _get_render_information(self, styles):
self.ensure_one()
preview_css = self._get_css_for_preview(styles, self.id)
return {
'company': self,
'preview_css': preview_css,
'is_html_empty': is_html_empty,
}
@api.onchange('company_id')
def _onchange_company_id(self):
for wizard in self:
@ -204,7 +202,7 @@ class BaseDocumentLayout(models.TransientModel):
: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
:return: a 2-value tuple with hex values of primary and secondary colors
"""
if not logo:
return False, False
@ -217,7 +215,7 @@ class BaseDocumentLayout(models.TransientModel):
return False, False
base_w, base_h = image.size
w = int(50 * base_w / base_h)
w = ceil(50 * base_w / base_h)
h = 50
# Converts to RGBA (if already RGBA, this is a noop)
@ -251,14 +249,6 @@ class BaseDocumentLayout(models.TransientModel):
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'}
@ -281,45 +271,11 @@ class BaseDocumentLayout(models.TransientModel):
"""
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():
if not scss.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])
asset = ScssStylesheetAsset(None, inline='// css_for_preview')
css_code = asset.compile(scss)
return Markup(css_code) if isinstance(scss, Markup) else css_code
@api.depends('company_details')
def _compute_empty_company_details(self):

View file

@ -1,18 +1,12 @@
# 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
from odoo import api, models, fields
from odoo.http import request, DEFAULT_MAX_CONTENT_LENGTH
from odoo.tools import config
from odoo.tools.misc import hmac, 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
@ -27,10 +21,10 @@ 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']
ALLOWED_DEBUG_MODES = ['', '1', 'assets', 'tests']
class Http(models.AbstractModel):
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
bots = ["bot", "crawl", "slurp", "spider", "curl", "wget", "facebookexternalhit", "whatsapp", "trendsmapresolver", "pinterest", "instagram", "google-pagerenderer", "preview"]
@ -42,6 +36,12 @@ class Http(models.AbstractModel):
# timeit has been done to check the optimum method
return any(bot in user_agent for bot in cls.bots)
@classmethod
def _sanitize_cookies(cls, cookies):
super()._sanitize_cookies(cookies)
if cids := cookies.get('cids'):
cookies['cids'] = '-'.join(cids.split(','))
@classmethod
def _handle_debug(cls):
debug = request.httprequest.args.get('debug')
@ -58,12 +58,24 @@ class Http(models.AbstractModel):
super()._pre_dispatch(rule, args)
cls._handle_debug()
@classmethod
def _post_logout(cls):
super()._post_logout()
request.future_response.set_cookie('cids', max_age=0)
def webclient_rendering_context(self):
return {
'menu_data': request.env['ir.ui.menu'].load_menus(request.session.debug),
'color_scheme': self.color_scheme(),
'session_info': self.session_info(),
}
def color_scheme(self):
return "light"
@api.model
def lazy_session_info(self):
return {}
def session_info(self):
user = self.env.user
session_uid = request.session.uid
@ -79,55 +91,53 @@ class Http(models.AbstractModel):
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
default=DEFAULT_MAX_CONTENT_LENGTH,
))
mods = odoo.conf.server_wide_modules or []
if request.db:
mods = list(request.registry._init_modules) + mods
is_internal_user = user._is_internal()
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,
"is_public": user._is_public(),
"is_internal_user": is_internal_user,
"user_context": user_context,
"db": self.env.cr.dbname,
"registry_hash": hmac(self.env(su=True), "webclient-cache", self.env.registry.registry_sequence),
"user_settings": self.env['res.users.settings']._find_or_create_for_user(user)._res_users_settings_format(),
"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,
"quick_login": str2bool(IrConfigSudo.get_param('web.quick_login', default=True), True),
"partner_write_date": fields.Datetime.to_string(user.partner_id.write_date),
"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,
'profile_session': request.session.get('profile_session'),
'profile_collectors': request.session.get('profile_collectors'),
'profile_params': request.session.get('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(),
"currencies": self.env['res.currency'].get_all_currencies(),
'bundle_params': {
'lang': request.session.context['lang'],
},
'test_mode': config['test_enable'],
'view_info': self.env['ir.ui.view'].get_view_info(),
'groups': {
'base.group_allow_export': user.has_group('base.group_allow_export') if session_uid else False,
},
}
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
})
if is_internal_user:
# We need sudo since a user may not have access to ancestor companies
# We use `_get_company_ids` because it is cached and we sudo it because env.user return a sudo user.
user_companies = self.env['res.company'].browse(user._get_company_ids()).sudo()
disallowed_ancestor_companies_sudo = user_companies.parent_ids - user_companies
all_companies_in_hierarchy_sudo = disallowed_ancestor_companies_sudo + user_companies
session_info.update({
# current_company should be default_company
"user_companies": {
@ -137,11 +147,22 @@ class Http(models.AbstractModel):
'id': comp.id,
'name': comp.name,
'sequence': comp.sequence,
} for comp in user.company_ids
'child_ids': (comp.child_ids & user_companies).ids,
'parent_id': comp.parent_id.id,
'currency_id': comp.currency_id.id,
} for comp in user_companies
},
'disallowed_ancestor_companies': {
comp.id: {
'id': comp.id,
'name': comp.name,
'sequence': comp.sequence,
'child_ids': (comp.child_ids & all_companies_in_hierarchy_sudo).ids,
'parent_id': comp.parent_id.id,
} for comp in disallowed_ancestor_companies_sudo
},
},
"show_effect": True,
"display_switch_company_menu": user.has_group('base.group_multi_company') and len(user.company_ids) > 1,
})
return session_info
@ -152,16 +173,22 @@ class Http(models.AbstractModel):
session_info = {
'is_admin': user._is_admin() if session_uid else False,
'is_system': user._is_system() if session_uid else False,
'is_public': user._is_public(),
"is_internal_user": user._is_internal(),
'is_website_user': user._is_public() if session_uid else False,
'user_id': user.id if session_uid else False,
'uid': session_uid,
"registry_hash": hmac(self.env(su=True), "webclient-cache", self.env.registry.registry_sequence),
'is_frontend': True,
'profile_session': request.session.profile_session,
'profile_collectors': request.session.profile_collectors,
'profile_params': request.session.profile_params,
'profile_session': request.session.get('profile_session'),
'profile_collectors': request.session.get('profile_collectors'),
'profile_params': request.session.get('profile_params'),
'show_effect': bool(request.env['ir.config_parameter'].sudo().get_param('base_setup.show_effect')),
'currencies': self.env['res.currency'].get_all_currencies(),
'quick_login': str2bool(request.env['ir.config_parameter'].sudo().get_param('web.quick_login', default=True), True),
'bundle_params': {
'lang': request.session.context['lang'],
},
'test_mode': config['test_enable'],
}
if request.session.debug:
session_info['bundle_params']['debug'] = request.session.debug
@ -173,7 +200,6 @@ class Http(models.AbstractModel):
})
return session_info
@api.deprecated("Deprecated since 19.0, use get_all_currencies on 'res.currency'")
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}
return self.env['res.currency'].get_all_currencies()

View file

@ -19,7 +19,7 @@ class IrModel(models.Model):
accessible_models = []
not_accessible_models = []
for model in models:
if self._check_model_access(model):
if self._is_valid_for_model_selector(model):
accessible_models.append(model)
else:
not_accessible_models.append({"display_name": model, "model": model})
@ -34,9 +34,15 @@ class IrModel(models.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))
def _is_valid_for_model_selector(self, model):
model = self.env.get(model)
return (
self.env.user._is_internal()
and model is not None
and model.has_access("read")
and not model._transient
and not model._abstract
)
@api.model
def get_available_models(self):
@ -44,5 +50,49 @@ class IrModel(models.Model):
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)]
accessible_models = [model for model in self.pool if self._is_valid_for_model_selector(model)]
return self._display_name_for(accessible_models)
def _get_definitions(self, model_names):
model_definitions = {}
for model_name in model_names:
model = self.env[model_name]
# get fields, relational fields are kept only if the related model is in model_names
fields_data_by_fname = {
fname: field_data
for fname, field_data in model.fields_get(
attributes={
'definition_record_field', 'definition_record', 'aggregator',
'name', 'readonly', 'related', 'relation', 'required', 'searchable',
'selection', 'sortable', 'store', 'string', 'tracking', 'type',
},
).items()
if field_data.get('selectable', True) and (
not field_data.get('relation') or field_data['relation'] in model_names
)
}
fields_data_by_fname = {
fname: field_data
for fname, field_data in fields_data_by_fname.items()
if not field_data.get('related') or field_data['related'].split('.')[0] in fields_data_by_fname
}
for fname, field_data in fields_data_by_fname.items():
if fname in model._fields:
inverse_fields = [
field for field in model.pool.field_inverses[model._fields[fname]]
if field.model_name in model_names
and model.env[field.model_name]._has_field_access(field, 'read')
]
if inverse_fields:
field_data['inverse_fname_by_model_name'] = {field.model_name: field.name for field in inverse_fields}
if field_data['type'] == 'many2one_reference':
field_data['model_name_ref_fname'] = model._fields[fname].model_field
model_definitions[model_name] = {
'description': model._description,
'fields': fields_data_by_fname,
'inherit': [model_name for model_name in model._inherit_module if model_name in model_names],
'order': model._order,
'parent_name': model._parent_name,
'rec_name': model._rec_name,
}
return model_definitions

View file

@ -6,20 +6,17 @@ 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 import api, models, fields
from odoo.tools import html_escape as escape
class Image(models.AbstractModel):
class IrQwebFieldImage(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):
@ -36,7 +33,7 @@ class Image(models.AbstractModel):
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]
sha = hashlib.sha512(str(getattr(record, 'write_date', fields.Datetime.now())).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']]:
@ -106,16 +103,16 @@ class Image(models.AbstractModel):
for name, value in atts.items():
if value:
img.append(' ')
img.append(escape(pycompat.to_text(name)))
img.append(escape(name))
img.append('="')
img.append(escape(pycompat.to_text(value)))
img.append(escape(value))
img.append('"')
img.append('/>')
return Markup(''.join(img))
class ImageUrlConverter(models.AbstractModel):
_description = 'Qweb Field Image'
class IrQwebFieldImage_Url(models.AbstractModel):
_inherit = 'ir.qweb.field.image_url'
def _get_src_urls(self, record, field_name, options):

View file

@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo import models
@ -30,22 +32,43 @@ class IrUiMenu(models.Model):
"xmlid": "",
"actionID": False,
"actionModel": False,
"actionPath": False,
"webIcon": None,
"webIconData": None,
"webIconDataMimetype": None,
"backgroundImage": menu.get('backgroundImage'),
}
else:
action = menu['action']
action_id = menu['action_id']
action_model = menu['action_model']
action_path = menu['action_path']
web_icon = menu['web_icon']
web_icon_data = menu['web_icon_data']
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']
while child and not action_id:
action_id = child['action_id']
action_model = child['action_model']
action_path = child['action_path']
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
webIcon = menu.get('web_icon', '')
webIconlist = webIcon and webIcon.split(',')
iconClass = color = backgroundColor = None
if webIconlist:
if len(webIconlist) >= 2:
iconClass, color = webIconlist[:2]
if len(webIconlist) == 3:
backgroundColor = webIconlist[2]
if menu.get('web_icon_data'):
web_icon_data = re.sub(r'\s/g', "", ('data:%s;base64,%s' % (menu['web_icon_data_mimetype'], menu['web_icon_data'])))
elif backgroundColor is not None: # Could split in three parts?
web_icon = ",".join([iconClass or "", color or "", backgroundColor])
else:
web_icon_data = '/web/static/img/default_icon_app.png'
web_menus[menu['id']] = {
"id": menu['id'],
@ -55,8 +78,10 @@ class IrUiMenu(models.Model):
"xmlid": menu['xmlid'],
"actionID": action_id,
"actionModel": action_model,
"webIcon": menu['web_icon'],
"webIconData": menu['web_icon_data'],
"actionPath": action_path,
"webIcon": web_icon,
"webIconData": web_icon_data,
"webIconDataMimetype": menu['web_icon_data_mimetype'],
}
return web_menus

View file

@ -0,0 +1,31 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrUiView(models.Model):
_inherit = 'ir.ui.view'
def get_view_info(self):
_view_info = self._get_view_info()
return {
type_: {
'display_name': display_name,
'icon': _view_info[type_]['icon'],
'multi_record': _view_info[type_].get('multi_record', True),
}
for (type_, display_name)
in self.fields_get(['type'], ['selection'])['type']['selection']
if type_ != 'qweb' and type_ in _view_info
}
def _get_view_info(self):
return {
'list': {'icon': 'oi oi-view-list'},
'form': {'icon': 'fa fa-address-card', 'multi_record': False},
'graph': {'icon': 'fa fa-area-chart'},
'pivot': {'icon': 'oi oi-view-pivot'},
'kanban': {'icon': 'oi oi-view-kanban'},
'calendar': {'icon': 'fa fa-calendar'},
'search': {'icon': 'oi oi-search'},
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
from odoo import _, api, models
from odoo.exceptions import AccessError
class PropertiesBaseDefinition(models.Model):
_inherit = "properties.base.definition"
@api.model
def get_properties_base_definition(self, model_name, field_name):
"""Return the base properties definition if we can read the model."""
model = self.env[model_name]
model.check_access("read")
if model._fields[field_name].type != "properties":
raise AccessError(_("You can not read that field definition."))
return self.sudo().web_search_read(
[
["properties_field_id.name", "=", field_name],
["properties_field_id.model", "=", model_name],
],
specification={"display_name": {}, "properties_definition": {}},
)

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
web_app_name = fields.Char('Web App Name', config_parameter='web.web_app_name')

View file

@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from base64 import b64decode
from odoo import models
from odoo.tools.facade import Proxy, ProxyAttr, ProxyFunc
_logger = logging.getLogger(__name__)
try:
import vobject.vcard
except ImportError:
_logger.warning("`vobject` Python module not found, vcard file generation disabled. Consider installing this module if you want to generate vcard files")
vobject = None
if vobject is not None:
class VBaseProxy(Proxy):
_wrapped__ = vobject.base.VBase
encoding_param = ProxyAttr()
type_param = ProxyAttr()
value = ProxyAttr(None)
class VCardContentsProxy(Proxy):
_wrapped__ = dict
__delitem__ = ProxyFunc()
__contains__ = ProxyFunc()
get = ProxyFunc(lambda lines: [VBaseProxy(line) for line in lines])
class VComponentProxy(Proxy):
_wrapped__ = vobject.base.Component
add = ProxyFunc(VBaseProxy)
contents = ProxyAttr(VCardContentsProxy)
serialize = ProxyFunc()
class ResPartner(models.Model):
_inherit = 'res.partner'
def _build_vcard(self):
""" Build the partner's vCard.
:returns a vobject.vCard object
"""
if not vobject:
return False
vcard = vobject.vCard()
# Name
n = vcard.add('n')
n.value = vobject.vcard.Name(family=self.name or self.complete_name or '')
# Formatted Name
fn = vcard.add('fn')
fn.value = self.name or self.complete_name or ''
# Address
adr = vcard.add('adr')
adr.value = vobject.vcard.Address(street=self.street or '', city=self.city or '', code=self.zip or '')
if self.state_id:
adr.value.region = self.state_id.name
if self.country_id:
adr.value.country = self.country_id.name
# Email
if self.email:
email = vcard.add('email')
email.value = self.email
email.type_param = 'INTERNET'
# Telephone numbers
if self.phone:
tel = vcard.add('tel')
tel.type_param = 'work'
tel.value = self.phone
# URL
if self.website:
url = vcard.add('url')
url.value = self.website
# Organisation
if self.commercial_company_name:
org = vcard.add('org')
org.value = [self.commercial_company_name]
if self.function:
function = vcard.add('title')
function.value = self.function
# Photo
photo = vcard.add('photo')
photo.value = b64decode(self.avatar_512)
photo.encoding_param = 'B'
photo.type_param = 'JPG'
return VComponentProxy(vcard)
def _get_vcard_file(self):
vcard = self._build_vcard()
if vcard:
return vcard.serialize().encode()
return False

View file

@ -0,0 +1,36 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.fields import Domain
from odoo.http import request
SKIP_CAPTCHA_LOGIN = object()
class ResUsers(models.Model):
_inherit = "res.users"
@api.model
def name_search(self, name='', domain=None, operator='ilike', limit=100):
# if we have a search with a limit, move current user as the first result
domain = Domain(domain or Domain.TRUE)
user_list = super().name_search(name, domain, operator, limit)
uid = self.env.uid
# index 0 is correct not Falsy in this case, use None to avoid ignoring it
if (index := next((i for i, (user_id, _name) in enumerate(user_list) if user_id == uid), None)) is not None:
# move found user first
user_tuple = user_list.pop(index)
user_list.insert(0, user_tuple)
elif limit is not None and len(user_list) == limit:
# user not found and limit reached, try to find the user again
if user_tuple := super().name_search(name, domain & Domain('id', '=', uid), operator, limit=1):
user_list = [user_tuple[0], *user_list[:-1]]
return user_list
def _on_webclient_bootstrap(self):
self.ensure_one()
def _should_captcha_login(self, credential):
if request and request.env.context.get('skip_captcha_login') is SKIP_CAPTCHA_LOGIN:
return False
return credential['type'] == 'password'

View file

@ -0,0 +1,36 @@
from odoo import api, fields, models
class ResUsersSettings(models.Model):
_inherit = 'res.users.settings'
embedded_actions_config_ids = fields.One2many('res.users.settings.embedded.action', 'user_setting_id')
@api.model
def _format_settings(self, fields_to_format):
res = super()._format_settings(fields_to_format)
if 'embedded_actions_config_ids' in fields_to_format:
res['embedded_actions_config_ids'] = self.embedded_actions_config_ids._embedded_action_settings_format()
return res
def get_embedded_actions_settings(self):
self.ensure_one()
return self.embedded_actions_config_ids._embedded_action_settings_format()
def set_embedded_actions_setting(self, action_id, res_id, vals):
self.ensure_one()
embedded_actions_config = self.env['res.users.settings.embedded.action'].search([
('user_setting_id', '=', self.id), ('action_id', '=', action_id), ('res_id', '=', res_id)
], limit=1)
for field, value in vals.items():
if field in ('embedded_actions_order', 'embedded_actions_visibility'):
vals[field] = ','.join('false' if action_id is False else str(action_id) for action_id in value)
if embedded_actions_config:
embedded_actions_config.write(vals)
else:
self.env['res.users.settings.embedded.action'].create({
**vals,
'user_setting_id': self.id,
'action_id': action_id,
'res_id': res_id,
})

View file

@ -0,0 +1,66 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class ResUsersSettingsEmbeddedAction(models.Model):
_name = 'res.users.settings.embedded.action'
_description = 'User Settings for Embedded Actions'
user_setting_id = fields.Many2one('res.users.settings', required=True, ondelete='cascade', index='btree_not_null', export_string_translation=False)
action_id = fields.Many2one('ir.actions.act_window', required=True, ondelete='cascade', export_string_translation=False)
res_model = fields.Char(required=True, export_string_translation=False)
res_id = fields.Integer(export_string_translation=False)
embedded_actions_order = fields.Char('List order of embedded action ids', export_string_translation=False)
embedded_actions_visibility = fields.Char('List visibility of embedded actions ids', export_string_translation=False)
embedded_visibility = fields.Boolean('Is top bar visible', export_string_translation=False)
_res_user_settings_embedded_action_unique = models.Constraint(
'UNIQUE (user_setting_id, action_id, res_id)',
'The user should have one unique embedded action setting per user setting, action and record id.',
)
@api.constrains('embedded_actions_order')
def _check_embedded_actions_order(self):
self._check_embedded_actions_field_format('embedded_actions_order')
@api.constrains('embedded_actions_visibility')
def _check_embedded_actions_visibility(self):
self._check_embedded_actions_field_format('embedded_actions_visibility')
def _check_embedded_actions_field_format(self, field_name):
for setting in self:
value = setting[field_name]
if not value:
return
action_ids = value.split(',')
if len(action_ids) != len(set(action_ids)):
raise ValidationError(
self.env._(
'The ids in %(field_name)s must not be duplicated: “%(action_ids)s',
field_name=field_name,
action_ids=action_ids,
)
)
for action_id in action_ids:
if not (action_id.isdigit() or action_id == 'false'):
raise ValidationError(
self.env._(
'The ids in %(field_name)s must only be integers or "false": “%(action_ids)s',
field_name=field_name,
action_ids=action_ids,
)
)
def _embedded_action_settings_format(self):
return {
f'{setting.action_id.id}+{setting.res_id or ""}': {
'embedded_actions_order': [
False if action_id == 'false' else int(action_id) for action_id in setting.embedded_actions_order.split(',')
] if setting.embedded_actions_order else [],
'embedded_actions_visibility': [
False if action_id == 'false' else int(action_id) for action_id in setting.embedded_actions_visibility.split(',')
] if setting.embedded_actions_visibility else [],
'embedded_visibility': setting.embedded_visibility,
}
for setting in self
}