19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -1,14 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import io
import logging
import time
from math import floor
from PIL import Image, ImageFont, ImageDraw
from werkzeug.urls import url_encode
from werkzeug.exceptions import NotFound
from urllib.parse import parse_qsl, urlencode, urlparse
from odoo import http
from odoo import _, http
from odoo.exceptions import AccessError
from odoo.http import request
from odoo.http import request, Response
from odoo.tools import consteq
from odoo.addons.mail.tools.discuss import add_guest_to_context
from odoo.tools.misc import file_open
_logger = logging.getLogger(__name__)
@ -26,13 +31,13 @@ class MailController(http.Controller):
@classmethod
def _redirect_to_messaging(cls):
url = '/web#%s' % url_encode({'action': 'mail.action_discuss'})
url = '/odoo/action-mail.action_discuss'
return request.redirect(url)
@classmethod
def _redirect_to_login_with_mail_view(cls, model, res_id, access_token=None, **kwargs):
url_base = '/mail/view'
url_params = request.env['mail.thread']._notify_get_action_link_params(
url_params = request.env['mail.thread']._get_action_link_params(
'view', **{
'model': model,
'res_id': res_id,
@ -48,7 +53,7 @@ class MailController(http.Controller):
base_link = request.httprequest.path
params = dict(request.params)
params.pop('token', '')
valid_token = request.env['mail.thread']._notify_encode_link(base_link, params)
valid_token = request.env['mail.thread']._encode_link(base_link, params)
return consteq(valid_token, str(token))
@classmethod
@ -89,10 +94,10 @@ class MailController(http.Controller):
model, res_id, access_token=access_token, **kwargs,
)
suggested_company = record_sudo._get_mail_redirect_suggested_company()
suggested_company = record_sudo._get_redirect_suggested_company()
# the record has a window redirection: check access rights
if uid is not None:
if not RecordModel.with_user(uid).check_access_rights('read', raise_exception=False):
if not RecordModel.with_user(uid).has_access('read'):
return cls._redirect_to_generic_fallback(
model, res_id, access_token=access_token, **kwargs,
)
@ -100,24 +105,25 @@ class MailController(http.Controller):
# We need here to extend the "allowed_company_ids" to allow a redirection
# to any record that the user can access, regardless of currently visible
# records based on the "currently allowed companies".
cids_str = request.httprequest.cookies.get('cids', str(user.company_id.id))
cids = [int(cid) for cid in cids_str.split(',')]
cids_str = request.cookies.get('cids', str(user.company_id.id))
cids = [int(cid) for cid in cids_str.split('-')]
try:
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access_rule('read')
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access('read')
except AccessError:
# In case the allowed_company_ids from the cookies (i.e. the last user configuration
# on their browser) is not sufficient to avoid an ir.rule access error, try to following
# heuristic:
# - Guess the supposed necessary company to access the record via the method
# _get_mail_redirect_suggested_company
# _get_redirect_suggested_company
# - If no company, then redirect to the messaging
# - Merge the suggested company with the companies on the cookie
# - Make a new access test if it succeeds, redirect to the record. Otherwise,
# - Make a new access test if it succeeds, redirect to the record. Otherwise,
# redirect to the messaging.
if not suggested_company:
raise AccessError('')
raise AccessError(_("There is no candidate company that has read access to the record."))
cids = cids + [suggested_company.id]
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access_rule('read')
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access('read')
request.future_response.set_cookie('cids', '-'.join([str(cid) for cid in cids]))
except AccessError:
return cls._redirect_to_generic_fallback(
model, res_id, access_token=access_token, **kwargs,
@ -135,9 +141,15 @@ class MailController(http.Controller):
record_action.pop('target_type', None)
# the record has an URL redirection: use it directly
if record_action['type'] == 'ir.actions.act_url':
return request.redirect(record_action['url'])
url = record_action["url"]
if highlight_message_id := kwargs.get("highlight_message_id"):
parsed_url = urlparse(url)
url = parsed_url._replace(query=urlencode(
parse_qsl(parsed_url.query) + [("highlight_message_id", highlight_message_id)]
)).geturl()
return request.redirect(url)
# anything else than an act_window is not supported
elif not record_action['type'] == 'ir.actions.act_window':
elif record_action['type'] != 'ir.actions.act_window':
return cls._redirect_to_messaging()
# backend act_window: when not logged, unless really readable as public,
@ -145,30 +157,29 @@ class MailController(http.Controller):
# in that case. In case of readable record, we consider this might be
# a customization and we do not change the behavior in stable
if uid is None or request.env.user._is_public():
has_access = record_sudo.with_user(request.env.user).check_access_rights('read', raise_exception=False)
if has_access:
try:
record_sudo.with_user(request.env.user).check_access_rule('read')
except AccessError:
has_access = False
has_access = record_sudo.with_user(request.env.user).has_access('read')
if not has_access:
return cls._redirect_to_login_with_mail_view(
model, res_id, access_token=access_token, **kwargs,
)
url_params = {
'model': model,
'id': res_id,
'active_id': res_id,
'action': record_action.get('id'),
}
url_params = {}
menu_id = request.env['ir.ui.menu']._get_best_backend_root_menu_id_for_model(model)
if menu_id:
url_params['menu_id'] = menu_id
view_id = record_sudo.get_formview_id()
if view_id:
url_params['view_id'] = view_id
if highlight_message_id := kwargs.get("highlight_message_id"):
url_params["highlight_message_id"] = highlight_message_id
if cids:
url_params['cids'] = ','.join([str(cid) for cid in cids])
url = '/web?#%s' % url_encode(url_params, sort=True)
request.future_response.set_cookie('cids', '-'.join([str(cid) for cid in cids]))
# @see commit c63d14a0485a553b74a8457aee158384e9ae6d3f
# @see router.js: heuristics to discrimate a model name from an action path
# is the presence of dots, or the prefix m- for models
model_in_url = model if "." in model else "m-" + model
url = f'/odoo/{model_in_url}/{res_id}?{url_encode(url_params, sort=True)}'
return request.redirect(url)
@http.route('/mail/view', type='http', auth='public')
@ -204,3 +215,168 @@ class MailController(http.Controller):
except ValueError:
res_id = False
return self._redirect_to_record(model, res_id, access_token, **kwargs)
# csrf is disabled here because it will be called by the MUA with unpredictable session at that time
@http.route('/mail/unfollow', type='http', auth='public', csrf=False)
def mail_action_unfollow(self, model, res_id, pid, token, **kwargs):
comparison, record, __ = MailController._check_token_and_record_or_redirect(model, int(res_id), token)
if not comparison or not record:
raise AccessError(_('Non existing record or wrong token.'))
pid = int(pid)
record_sudo = record.sudo()
record_sudo.message_unsubscribe([pid])
display_link = True
if request.session.uid:
display_link = record.has_access('read')
return request.render('mail.message_document_unfollowed', {
'name': record_sudo.display_name,
'model_name': request.env['ir.model'].sudo()._get(model).display_name,
'access_url': record._notify_get_action_link('view', model=model, res_id=res_id) if display_link else False,
})
@http.route('/mail/message/<int:message_id>', type='http', auth='public')
@add_guest_to_context
def mail_thread_message_redirect(self, message_id, **kwargs):
message = request.env['mail.message'].search([('id', '=', message_id)])
if not message:
if request.env.user._is_public():
return request.redirect(f'/web/login?redirect=/mail/message/{message_id}')
raise NotFound()
return self._redirect_to_record(message.model, message.res_id, highlight_message_id=message_id)
# web_editor routes need to be kept otherwise mail already sent won't be able to load icons anymore
@http.route([
'/web_editor/font_to_img/<icon>',
'/web_editor/font_to_img/<icon>/<color>',
'/web_editor/font_to_img/<icon>/<color>/<int:size>',
'/web_editor/font_to_img/<icon>/<color>/<int:width>x<int:height>',
'/web_editor/font_to_img/<icon>/<color>/<int:size>/<int:alpha>',
'/web_editor/font_to_img/<icon>/<color>/<int:width>x<int:height>/<int:alpha>',
'/web_editor/font_to_img/<icon>/<color>/<bg>',
'/web_editor/font_to_img/<icon>/<color>/<bg>/<int:size>',
'/web_editor/font_to_img/<icon>/<color>/<bg>/<int:width>x<int:height>',
'/web_editor/font_to_img/<icon>/<color>/<bg>/<int:width>x<int:height>/<int:alpha>',
'/mail/font_to_img/<icon>',
'/mail/font_to_img/<icon>/<color>',
'/mail/font_to_img/<icon>/<color>/<int:size>',
'/mail/font_to_img/<icon>/<color>/<int:width>x<int:height>',
'/mail/font_to_img/<icon>/<color>/<int:size>/<int:alpha>',
'/mail/font_to_img/<icon>/<color>/<int:width>x<int:height>/<int:alpha>',
'/mail/font_to_img/<icon>/<color>/<bg>',
'/mail/font_to_img/<icon>/<color>/<bg>/<int:size>',
'/mail/font_to_img/<icon>/<color>/<bg>/<int:width>x<int:height>',
'/mail/font_to_img/<icon>/<color>/<bg>/<int:width>x<int:height>/<int:alpha>',
], type='http', auth="none")
def export_icon_to_png(self, icon, color='#000', bg=None, size=100, alpha=255, font='/web/static/src/libs/fontawesome/fonts/fontawesome-webfont.ttf', width=None, height=None):
""" This method converts an unicode character to an image (using Font
Awesome font by default) and is used only for mass mailing because
custom fonts are not supported in mail.
:param icon : decimal encoding of unicode character
:param color : RGB code of the color
:param bg : RGB code of the background color
:param size : Pixels in integer
:param alpha : transparency of the image from 0 to 255
:param font : font path
:param width : Pixels in integer
:param height : Pixels in integer
:returns PNG image converted from given font
"""
# For custom icons, use the corresponding custom font
if icon.isdigit():
oi_font_char_codes = {
# Replacement of existing Twitter icons by X icons (the route
# here receives the old icon code always, but the replacement
# one is also considered for consistency anyway).
"61569": "59464", # F081 -> E848: fa-twitter-square
"61593": "59418", # F099 -> E81A: fa-twitter
# Addition of new icons
"59407": "59407", # E80F: fa-strava
"59409": "59409", # E811: fa-discord
"59416": "59416", # E818: fa-threads
"59417": "59417", # E819: fa-kickstarter
"59419": "59419", # E81B: fa-tiktok
"59420": "59420", # E81C: fa-bluesky
"59421": "59421", # E81D: fa-google-play
}
if icon in oi_font_char_codes:
icon = oi_font_char_codes[icon]
font = "/web/static/lib/odoo_ui_icons/fonts/odoo_ui_icons.woff"
size = max(width, height, 1) if width else size
width = width or size
height = height or size
# Make sure we have at least size=1
width = max(1, min(width, 512))
height = max(1, min(height, 512))
# Initialize font
if font.startswith('/'):
font = font[1:]
font_obj = ImageFont.truetype(file_open(font, 'rb'), height)
# if received character is not a number, keep old behaviour (icon is character)
icon = chr(int(icon)) if icon.isdigit() else icon
# Background standardization
if bg is not None and bg.startswith('rgba'):
bg = bg.replace('rgba', 'rgb')
bg = ','.join(bg.split(',')[:-1]) + ')'
# Convert the opacity value compatible with PIL Image color (0 to 255)
# when color specifier is 'rgba'
if color is not None and color.startswith('rgba'):
*rgb, a = color.strip(')').split(',')
opacity = str(floor(float(a) * 255))
color = ','.join([*rgb, opacity]) + ')'
# Determine the dimensions of the icon
image = Image.new("RGBA", (width, height), color)
draw = ImageDraw.Draw(image)
if hasattr(draw, 'textbbox'):
box = draw.textbbox((0, 0), icon, font=font_obj)
left = box[0]
top = box[1]
boxw = box[2] - box[0]
boxh = box[3] - box[1]
else: # pillow < 8.00 (Focal)
left, top, _right, _bottom = image.getbbox()
boxw, boxh = draw.textsize(icon, font=font_obj)
draw.text((0, 0), icon, font=font_obj)
# Create an alpha mask
imagemask = Image.new("L", (boxw, boxh), 0)
drawmask = ImageDraw.Draw(imagemask)
drawmask.text((-left, -top), icon, font=font_obj, fill=255)
# Create a solid color image and apply the mask
if color.startswith('rgba'):
color = color.replace('rgba', 'rgb')
color = ','.join(color.split(',')[:-1]) + ')'
iconimage = Image.new("RGBA", (boxw, boxh), color)
iconimage.putalpha(imagemask)
# Create output image
outimage = Image.new("RGBA", (boxw, height), bg or (0, 0, 0, 0))
outimage.paste(iconimage, (left, top), iconimage)
# output image
output = io.BytesIO()
outimage.save(output, format="PNG")
response = Response()
response.mimetype = 'image/png'
response.data = output.getvalue()
response.headers['Cache-Control'] = 'public, max-age=604800'
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST'
response.headers['Connection'] = 'close'
response.headers['Date'] = time.strftime("%a, %d-%b-%Y %T GMT", time.gmtime())
response.headers['Expires'] = time.strftime("%a, %d-%b-%Y %T GMT", time.gmtime(time.time() + 604800 * 60))
return response