Initial commit: Core packages

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

View file

@ -0,0 +1,7 @@
# -*- coding: utf-8 -*
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import bus
from . import discuss
from . import home
from . import mail

View file

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import SUPERUSER_ID, tools
from odoo.http import request, route
from odoo.addons.bus.controllers.main import BusController
class MailChatController(BusController):
def _default_request_uid(self):
""" For Anonymous people, they receive the access right of SUPERUSER_ID since they have NO access (auth=none)
!!! Each time a method from this controller is call, there is a check if the user (who can be anonymous and Sudo access)
can access to the resource.
"""
return request.session.uid and request.session.uid or SUPERUSER_ID
# --------------------------
# Anonymous routes (Common Methods)
# --------------------------
@route('/mail/chat_post', type="json", auth="public", cors="*")
def mail_chat_post(self, uuid, message_content, **kwargs):
mail_channel = request.env["mail.channel"].sudo().search([('uuid', '=', uuid)], limit=1)
if not mail_channel:
return False
# find the author from the user session
if request.session.uid:
author = request.env['res.users'].sudo().browse(request.session.uid).partner_id
author_id = author.id
email_from = author.email_formatted
else: # If Public User, use catchall email from company
author_id = False
email_from = mail_channel.anonymous_name or mail_channel.create_uid.company_id.catchall_formatted
# post a message without adding followers to the channel. email_from=False avoid to get author from email data
body = tools.plaintext2html(message_content)
message = mail_channel.with_context(mail_create_nosubscribe=True).message_post(
author_id=author_id,
email_from=email_from,
body=body,
message_type='comment',
subtype_xmlid='mail.mt_comment'
)
return message.id if message else False
@route(['/mail/chat_history'], type="json", auth="public", cors="*")
def mail_chat_history(self, uuid, last_id=False, limit=20):
channel = request.env["mail.channel"].sudo().search([('uuid', '=', uuid)], limit=1)
if not channel:
return []
else:
return channel._channel_fetch_message(last_id, limit)

View file

@ -0,0 +1,609 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from datetime import datetime, timedelta
from psycopg2 import IntegrityError
from psycopg2.errorcodes import UNIQUE_VIOLATION
from odoo import http
from odoo.exceptions import AccessError, UserError
from odoo.http import request
from odoo.tools import consteq, file_open
from odoo.tools.misc import get_lang
from odoo.tools.translate import _
from werkzeug.exceptions import NotFound
class DiscussController(http.Controller):
# --------------------------------------------------------------------------
# Public Pages
# --------------------------------------------------------------------------
@http.route([
'/chat/<string:create_token>',
'/chat/<string:create_token>/<string:channel_name>',
], methods=['GET'], type='http', auth='public')
def discuss_channel_chat_from_token(self, create_token, channel_name=None, **kwargs):
return self._response_discuss_channel_from_token(create_token=create_token, channel_name=channel_name)
@http.route([
'/meet/<string:create_token>',
'/meet/<string:create_token>/<string:channel_name>',
], methods=['GET'], type='http', auth='public')
def discuss_channel_meet_from_token(self, create_token, channel_name=None, **kwargs):
return self._response_discuss_channel_from_token(create_token=create_token, channel_name=channel_name, default_display_mode='video_full_screen')
@http.route('/chat/<int:channel_id>/<string:invitation_token>', methods=['GET'], type='http', auth='public')
def discuss_channel_invitation(self, channel_id, invitation_token, **kwargs):
channel_sudo = request.env['mail.channel'].browse(channel_id).sudo().exists()
if not channel_sudo or not channel_sudo.uuid or not consteq(channel_sudo.uuid, invitation_token):
raise NotFound()
return self._response_discuss_channel_invitation(channel_sudo=channel_sudo)
@http.route('/discuss/channel/<int:channel_id>', methods=['GET'], type='http', auth='public')
def discuss_channel(self, channel_id, **kwargs):
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
return self._response_discuss_public_channel_template(channel_sudo=channel_member_sudo.channel_id)
def _response_discuss_channel_from_token(self, create_token, channel_name=None, default_display_mode=False):
if not request.env['ir.config_parameter'].sudo().get_param('mail.chat_from_token'):
raise NotFound()
channel_sudo = request.env['mail.channel'].sudo().search([('uuid', '=', create_token)])
if not channel_sudo:
try:
channel_sudo = channel_sudo.create({
'channel_type': 'channel',
'default_display_mode': default_display_mode,
'group_public_id': None,
'name': channel_name or create_token,
'uuid': create_token,
})
except IntegrityError as e:
if e.pgcode != UNIQUE_VIOLATION:
raise
# concurrent insert attempt: another request created the channel.
# commit the current transaction and get the channel.
request.env.cr.commit()
channel_sudo = channel_sudo.search([('uuid', '=', create_token)])
return self._response_discuss_channel_invitation(channel_sudo=channel_sudo, is_channel_token_secret=False)
def _response_discuss_channel_invitation(self, channel_sudo, is_channel_token_secret=True):
if channel_sudo.channel_type == 'chat':
raise NotFound()
discuss_public_view_data = {
'isChannelTokenSecret': is_channel_token_secret,
}
add_guest_cookie = False
channel_member_sudo = channel_sudo.env['mail.channel.member']._get_as_sudo_from_request(request=request, channel_id=channel_sudo.id)
if channel_member_sudo:
channel_sudo = channel_member_sudo.channel_id # ensure guest is in context
else:
if not channel_sudo.env.user._is_public():
try:
channel_sudo.add_members([channel_sudo.env.user.partner_id.id])
except UserError:
raise NotFound()
else:
guest = channel_sudo.env['mail.guest']._get_guest_from_request(request)
if guest:
channel_sudo = channel_sudo.with_context(guest=guest)
try:
channel_sudo.add_members(guest_ids=[guest.id])
except UserError:
raise NotFound()
else:
if channel_sudo.group_public_id:
raise NotFound()
guest = channel_sudo.env['mail.guest'].create({
'country_id': channel_sudo.env['res.country'].search([('code', '=', request.geoip.get('country_code'))], limit=1).id,
'lang': get_lang(channel_sudo.env).code,
'name': _("Guest"),
'timezone': channel_sudo.env['mail.guest']._get_timezone_from_request(request),
})
add_guest_cookie = True
discuss_public_view_data.update({
'shouldAddGuestAsMemberOnJoin': True,
'shouldDisplayWelcomeViewInitially': True,
})
channel_sudo = channel_sudo.with_context(guest=guest)
response = self._response_discuss_public_channel_template(channel_sudo=channel_sudo, discuss_public_view_data=discuss_public_view_data)
if add_guest_cookie:
# Discuss Guest ID: every route in this file will make use of it to authenticate
# the guest through `_get_as_sudo_from_request` or `_get_as_sudo_from_request_or_raise`.
expiration_date = datetime.now() + timedelta(days=365)
response.set_cookie(guest._cookie_name, f"{guest.id}{guest._cookie_separator}{guest.access_token}", httponly=True, expires=expiration_date)
return response
def _response_discuss_public_channel_template(self, channel_sudo, discuss_public_view_data=None):
discuss_public_view_data = discuss_public_view_data or {}
return request.render('mail.discuss_public_channel_template', {
'data': {
'channelData': channel_sudo.channel_info()[0],
'discussPublicViewData': dict({
'channel': [('insert', {'id': channel_sudo.id, 'model': 'mail.channel'})],
'shouldDisplayWelcomeViewInitially': channel_sudo.default_display_mode == 'video_full_screen',
}, **discuss_public_view_data),
},
'session_info': channel_sudo.env['ir.http'].session_info(),
})
# --------------------------------------------------------------------------
# Semi-Static Content (GET requests with possible cache)
# --------------------------------------------------------------------------
@http.route('/mail/channel/<int:channel_id>/partner/<int:partner_id>/avatar_128', methods=['GET'], type='http', auth='public')
def mail_channel_partner_avatar_128(self, channel_id, partner_id, **kwargs):
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request(request=request, channel_id=channel_id)
partner_sudo = channel_member_sudo.env['res.partner'].browse(partner_id).exists()
placeholder = partner_sudo._avatar_get_placeholder_path()
if channel_member_sudo and channel_member_sudo.env['mail.channel.member'].search([('channel_id', '=', channel_id), ('partner_id', '=', partner_id)], limit=1):
return request.env['ir.binary']._get_image_stream_from(partner_sudo, field_name='avatar_128', placeholder=placeholder).get_response()
if request.env.user.share:
return request.env['ir.binary']._get_placeholder_stream(placeholder).get_response()
return request.env['ir.binary']._get_image_stream_from(partner_sudo.sudo(False), field_name='avatar_128', placeholder=placeholder).get_response()
@http.route('/mail/channel/<int:channel_id>/guest/<int:guest_id>/avatar_128', methods=['GET'], type='http', auth='public')
def mail_channel_guest_avatar_128(self, channel_id, guest_id, **kwargs):
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request(request=request, channel_id=channel_id)
guest_sudo = channel_member_sudo.env['mail.guest'].browse(guest_id).exists()
placeholder = guest_sudo._avatar_get_placeholder_path()
if channel_member_sudo and channel_member_sudo.env['mail.channel.member'].search([('channel_id', '=', channel_id), ('guest_id', '=', guest_id)], limit=1):
return request.env['ir.binary']._get_image_stream_from(guest_sudo, field_name='avatar_128', placeholder=placeholder).get_response()
if request.env.user.share:
return request.env['ir.binary']._get_placeholder_stream(placeholder).get_response()
return request.env['ir.binary']._get_image_stream_from(guest_sudo.sudo(False), field_name='avatar_128', placeholder=placeholder).get_response()
@http.route('/mail/channel/<int:channel_id>/attachment/<int:attachment_id>', methods=['GET'], type='http', auth='public')
def mail_channel_attachment(self, channel_id, attachment_id, download=None, **kwargs):
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
attachment_sudo = channel_member_sudo.env['ir.attachment'].search([
('id', '=', int(attachment_id)),
('res_id', '=', int(channel_id)),
('res_model', '=', 'mail.channel')
], limit=1)
if not attachment_sudo:
raise NotFound()
return request.env['ir.binary']._get_stream_from(attachment_sudo).get_response(as_attachment=download)
@http.route([
'/mail/channel/<int:channel_id>/image/<int:attachment_id>',
'/mail/channel/<int:channel_id>/image/<int:attachment_id>/<int:width>x<int:height>',
], methods=['GET'], type='http', auth='public')
def fetch_image(self, channel_id, attachment_id, width=0, height=0, **kwargs):
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
attachment_sudo = channel_member_sudo.env['ir.attachment'].search([
('id', '=', int(attachment_id)),
('res_id', '=', int(channel_id)),
('res_model', '=', 'mail.channel'),
], limit=1)
if not attachment_sudo:
raise NotFound()
return request.env['ir.binary']._get_image_stream_from(
attachment_sudo, width=int(width), height=int(height)
).get_response(as_attachment=kwargs.get('download'))
# --------------------------------------------------------------------------
# Client Initialization
# --------------------------------------------------------------------------
@http.route('/mail/init_messaging', methods=['POST'], type='json', auth='public')
def mail_init_messaging(self, **kwargs):
if not request.env.user.sudo()._is_public():
return request.env.user.sudo(request.env.user.has_group('base.group_portal'))._init_messaging()
guest = request.env['mail.guest']._get_guest_from_request(request)
if guest:
return guest.sudo()._init_messaging()
raise NotFound()
@http.route('/mail/load_message_failures', methods=['POST'], type='json', auth='user')
def mail_load_message_failures(self, **kwargs):
return request.env.user.partner_id._message_fetch_failed()
# --------------------------------------------------------------------------
# Mailbox
# --------------------------------------------------------------------------
@http.route('/mail/inbox/messages', methods=['POST'], type='json', auth='user')
def discuss_inbox_messages(self, max_id=None, min_id=None, limit=30, **kwargs):
return request.env['mail.message']._message_fetch(domain=[('needaction', '=', True)], max_id=max_id, min_id=min_id, limit=limit).message_format()
@http.route('/mail/history/messages', methods=['POST'], type='json', auth='user')
def discuss_history_messages(self, max_id=None, min_id=None, limit=30, **kwargs):
return request.env['mail.message']._message_fetch(domain=[('needaction', '=', False)], max_id=max_id, min_id=min_id, limit=limit).message_format()
@http.route('/mail/starred/messages', methods=['POST'], type='json', auth='user')
def discuss_starred_messages(self, max_id=None, min_id=None, limit=30, **kwargs):
return request.env['mail.message']._message_fetch(domain=[('starred_partner_ids', 'in', [request.env.user.partner_id.id])], max_id=max_id, min_id=min_id, limit=limit).message_format()
# --------------------------------------------------------------------------
# Thread API (channel/chatter common)
# --------------------------------------------------------------------------
def _get_allowed_message_post_params(self):
return {'attachment_ids', 'body', 'message_type', 'partner_ids', 'subtype_xmlid', 'parent_id'}
@http.route('/mail/message/post', methods=['POST'], type='json', auth='public')
def mail_message_post(self, thread_model, thread_id, post_data, **kwargs):
guest = request.env['mail.guest']._get_guest_from_request(request)
guest.env['ir.attachment'].browse(post_data.get('attachment_ids', []))._check_attachments_access(post_data.get('attachment_tokens'))
if thread_model == 'mail.channel':
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(thread_id))
thread = channel_member_sudo.channel_id
else:
thread = request.env[thread_model].browse(int(thread_id)).exists()
return thread.message_post(**{key: value for key, value in post_data.items() if key in self._get_allowed_message_post_params()}).message_format()[0]
@http.route('/mail/message/update_content', methods=['POST'], type='json', auth='public')
def mail_message_update_content(self, message_id, body, attachment_ids, attachment_tokens=None, **kwargs):
guest = request.env['mail.guest']._get_guest_from_request(request)
guest.env['ir.attachment'].browse(attachment_ids)._check_attachments_access(attachment_tokens)
message_sudo = guest.env['mail.message'].browse(message_id).sudo().exists()
if not message_sudo.is_current_user_or_guest_author and not guest.env.user._is_admin():
raise NotFound()
if not message_sudo.model or not message_sudo.res_id:
raise NotFound()
guest.env[message_sudo.model].browse([message_sudo.res_id])._message_update_content(
message_sudo,
body,
attachment_ids=attachment_ids
)
return {
'id': message_sudo.id,
'body': message_sudo.body,
'attachments': message_sudo.attachment_ids.sorted()._attachment_format(),
}
@http.route('/mail/attachment/upload', methods=['POST'], type='http', auth='public')
def mail_attachment_upload(self, ufile, thread_id, thread_model, is_pending=False, **kwargs):
channel_member = request.env['mail.channel.member']
if thread_model == 'mail.channel':
channel_member = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(thread_id))
vals = {
'name': ufile.filename,
'raw': ufile.read(),
'res_id': int(thread_id),
'res_model': thread_model,
}
if is_pending and is_pending != 'false':
# Add this point, the message related to the uploaded file does
# not exist yet, so we use those placeholder values instead.
vals.update({
'res_id': 0,
'res_model': 'mail.compose.message',
})
if channel_member.env.user.share:
# Only generate the access token if absolutely necessary (= not for internal user).
vals['access_token'] = channel_member.env['ir.attachment']._generate_access_token()
try:
attachment = channel_member.env['ir.attachment'].create(vals)
attachment._post_add_create()
attachmentData = {
'filename': ufile.filename,
'id': attachment.id,
'mimetype': attachment.mimetype,
'name': attachment.name,
'size': attachment.file_size
}
if attachment.access_token:
attachmentData['accessToken'] = attachment.access_token
except AccessError:
attachmentData = {'error': _("You are not allowed to upload an attachment here.")}
return request.make_json_response(attachmentData)
@http.route('/mail/attachment/delete', methods=['POST'], type='json', auth='public')
def mail_attachment_delete(self, attachment_id, access_token=None, **kwargs):
attachment_sudo = request.env['ir.attachment'].browse(int(attachment_id)).sudo().exists()
if not attachment_sudo:
target = request.env.user.partner_id
request.env['bus.bus']._sendone(target, 'ir.attachment/delete', {'id': attachment_id})
return
if not request.env.user.share:
# Check through standard access rights/rules for internal users.
attachment_sudo.sudo(False)._delete_and_notify()
return
# For non-internal users 2 cases are supported:
# - Either the attachment is linked to a message: verify the request is made by the author of the message (portal user or guest).
# - Either a valid access token is given: also verify the message is pending (because unfortunately in portal a token is also provided to guest for viewing others' attachments).
guest = request.env['mail.guest']._get_guest_from_request(request)
message_sudo = guest.env['mail.message'].sudo().search([('attachment_ids', 'in', attachment_sudo.ids)], limit=1)
if message_sudo:
if not message_sudo.is_current_user_or_guest_author:
raise NotFound()
else:
if not access_token or not attachment_sudo.access_token or not consteq(access_token, attachment_sudo.access_token):
raise NotFound()
if attachment_sudo.res_model != 'mail.compose.message' or attachment_sudo.res_id != 0:
raise NotFound()
attachment_sudo._delete_and_notify()
@http.route('/mail/message/add_reaction', methods=['POST'], type='json', auth='public')
def mail_message_add_reaction(self, message_id, content):
guest_sudo = request.env['mail.guest']._get_guest_from_request(request).sudo()
message_sudo = guest_sudo.env['mail.message'].browse(int(message_id)).exists()
if not message_sudo:
raise NotFound()
if request.env.user.sudo()._is_public():
if not guest_sudo or not message_sudo.model == 'mail.channel' or message_sudo.res_id not in guest_sudo.channel_ids.ids:
raise NotFound()
message_sudo._message_add_reaction(content=content)
guests = [('insert', {'id': guest_sudo.id})]
partners = []
else:
message_sudo.sudo(False)._message_add_reaction(content=content)
guests = []
partners = [('insert', {'id': request.env.user.partner_id.id})]
reactions = message_sudo.env['mail.message.reaction'].search([('message_id', '=', message_sudo.id), ('content', '=', content)])
return {
'id': message_sudo.id,
'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', {
'content': content,
'count': len(reactions),
'guests': guests,
'message': {'id', message_sudo.id},
'partners': partners,
})],
}
@http.route('/mail/message/remove_reaction', methods=['POST'], type='json', auth='public')
def mail_message_remove_reaction(self, message_id, content):
guest_sudo = request.env['mail.guest']._get_guest_from_request(request).sudo()
message_sudo = guest_sudo.env['mail.message'].browse(int(message_id)).exists()
if not message_sudo:
raise NotFound()
if request.env.user.sudo()._is_public():
if not guest_sudo or not message_sudo.model == 'mail.channel' or message_sudo.res_id not in guest_sudo.channel_ids.ids:
raise NotFound()
message_sudo._message_remove_reaction(content=content)
guests = [('insert-and-unlink', {'id': guest_sudo.id})]
partners = []
else:
message_sudo.sudo(False)._message_remove_reaction(content=content)
guests = []
partners = [('insert-and-unlink', {'id': request.env.user.partner_id.id})]
reactions = message_sudo.env['mail.message.reaction'].search([('message_id', '=', message_sudo.id), ('content', '=', content)])
return {
'id': message_sudo.id,
'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', {
'content': content,
'count': len(reactions),
'guests': guests,
'message': {'id': message_sudo.id},
'partners': partners,
})],
}
# --------------------------------------------------------------------------
# Channel API
# --------------------------------------------------------------------------
@http.route('/mail/channel/add_guest_as_member', methods=['POST'], type='json', auth='public')
def mail_channel_add_guest_as_member(self, channel_id, channel_uuid, **kwargs):
channel_sudo = request.env['mail.channel'].browse(int(channel_id)).sudo().exists()
if not channel_sudo or not channel_sudo.uuid or not consteq(channel_sudo.uuid, channel_uuid):
raise NotFound()
if channel_sudo.channel_type == 'chat':
raise NotFound()
guest = channel_sudo.env['mail.guest']._get_guest_from_request(request)
# Only guests should take this route.
if not guest:
raise NotFound()
channel_member = channel_sudo.env['mail.channel.member']._get_as_sudo_from_request(request=request, channel_id=channel_id)
# Do not add the guest to channel members if they are already member.
if not channel_member:
channel_sudo = channel_sudo.with_context(guest=guest)
try:
channel_sudo.add_members(guest_ids=[guest.id])
except UserError:
raise NotFound()
@http.route('/mail/channel/messages', methods=['POST'], type='json', auth='public')
def mail_channel_messages(self, channel_id, max_id=None, min_id=None, limit=30, **kwargs):
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
messages = channel_member_sudo.env['mail.message']._message_fetch(domain=[
('res_id', '=', channel_id),
('model', '=', 'mail.channel'),
('message_type', '!=', 'user_notification'),
], max_id=max_id, min_id=min_id, limit=limit)
if not request.env.user._is_public():
messages.set_message_done()
return messages.message_format()
@http.route('/mail/channel/set_last_seen_message', methods=['POST'], type='json', auth='public')
def mail_channel_mark_as_seen(self, channel_id, last_message_id, **kwargs):
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
return channel_member_sudo.channel_id._channel_seen(int(last_message_id))
@http.route('/mail/channel/notify_typing', methods=['POST'], type='json', auth='public')
def mail_channel_notify_typing(self, channel_id, is_typing, **kwargs):
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
channel_member_sudo._notify_typing(is_typing)
@http.route('/mail/channel/ping', methods=['POST'], type='json', auth='public')
def channel_ping(self, channel_id, rtc_session_id=None, check_rtc_session_ids=None):
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
if rtc_session_id:
channel_member_sudo.channel_id.rtc_session_ids.filtered_domain([
('id', '=', int(rtc_session_id)),
('channel_member_id', '=', channel_member_sudo.id),
]).write({}) # update write_date
current_rtc_sessions, outdated_rtc_sessions = channel_member_sudo._rtc_sync_sessions(check_rtc_session_ids=check_rtc_session_ids)
return {'rtcSessions': [
('insert', [rtc_session_sudo._mail_rtc_session_format() for rtc_session_sudo in current_rtc_sessions]),
('insert-and-unlink', [{'id': missing_rtc_session_sudo.id} for missing_rtc_session_sudo in outdated_rtc_sessions]),
]}
# --------------------------------------------------------------------------
# Chatter API
# --------------------------------------------------------------------------
@http.route('/mail/thread/data', methods=['POST'], type='json', auth='user')
def mail_thread_data(self, thread_model, thread_id, request_list, **kwargs):
thread = request.env[thread_model].with_context(active_test=False).search([('id', '=', thread_id)])
return thread._get_mail_thread_data(request_list)
@http.route('/mail/thread/messages', methods=['POST'], type='json', auth='user')
def mail_thread_messages(self, thread_model, thread_id, max_id=None, min_id=None, limit=30, **kwargs):
messages = request.env['mail.message']._message_fetch(domain=[
('res_id', '=', int(thread_id)),
('model', '=', thread_model),
('message_type', '!=', 'user_notification'),
], max_id=max_id, min_id=min_id, limit=limit)
if not request.env.user._is_public():
messages.set_message_done()
return messages.message_format()
@http.route('/mail/read_subscription_data', methods=['POST'], type='json', auth='user')
def read_subscription_data(self, follower_id):
""" Computes:
- message_subtype_data: data about document subtypes: which are
available, which are followed if any """
request.env['mail.followers'].check_access_rights("read")
follower = request.env['mail.followers'].sudo().browse(follower_id)
follower.ensure_one()
request.env[follower.res_model].check_access_rights("read")
record = request.env[follower.res_model].browse(follower.res_id)
record.check_access_rule("read")
# find current model subtypes, add them to a dictionary
subtypes = record._mail_get_message_subtypes()
followed_subtypes_ids = set(follower.subtype_ids.ids)
subtypes_list = [{
'name': subtype.name,
'res_model': subtype.res_model,
'sequence': subtype.sequence,
'default': subtype.default,
'internal': subtype.internal,
'followed': subtype.id in followed_subtypes_ids,
'parent_model': subtype.parent_id.res_model,
'id': subtype.id
} for subtype in subtypes]
return sorted(subtypes_list,
key=lambda it: (it['parent_model'] or '', it['res_model'] or '', it['internal'], it['sequence']))
# --------------------------------------------------------------------------
# RTC API TODO move check logic in routes.
# --------------------------------------------------------------------------
@http.route('/mail/rtc/session/notify_call_members', methods=['POST'], type="json", auth="public")
def session_call_notify(self, peer_notifications):
""" Sends content to other session of the same channel, only works if the user is the user of that session.
This is used to send peer to peer information between sessions.
:param peer_notifications: list of tuple with the following elements:
- int sender_session_id: id of the session from which the content is sent
- list target_session_ids: list of the ids of the sessions that should receive the content
- string content: the content to send to the other sessions
"""
guest = request.env['mail.guest']._get_guest_from_request(request)
notifications_by_session = defaultdict(list)
for sender_session_id, target_session_ids, content in peer_notifications:
session_sudo = guest.env['mail.channel.rtc.session'].sudo().browse(int(sender_session_id)).exists()
if not session_sudo or (session_sudo.guest_id and session_sudo.guest_id != guest) or (session_sudo.partner_id and session_sudo.partner_id != request.env.user.partner_id):
continue
notifications_by_session[session_sudo].append(([int(sid) for sid in target_session_ids], content))
for session_sudo, notifications in notifications_by_session.items():
session_sudo._notify_peers(notifications)
@http.route('/mail/rtc/session/update_and_broadcast', methods=['POST'], type="json", auth="public")
def session_update_and_broadcast(self, session_id, values):
""" Update a RTC session and broadcasts the changes to the members of its channel,
only works of the user is the user of that session.
:param int session_id: id of the session to update
:param dict values: write dict for the fields to update
"""
if request.env.user._is_public():
guest = request.env['mail.guest']._get_guest_from_request(request)
if guest:
session = guest.env['mail.channel.rtc.session'].sudo().browse(int(session_id)).exists()
if session and session.guest_id == guest:
session._update_and_broadcast(values)
return
return
session = request.env['mail.channel.rtc.session'].sudo().browse(int(session_id)).exists()
if session and session.partner_id == request.env.user.partner_id:
session._update_and_broadcast(values)
@http.route('/mail/rtc/channel/join_call', methods=['POST'], type="json", auth="public")
def channel_call_join(self, channel_id, check_rtc_session_ids=None):
""" Joins the RTC call of a channel if the user is a member of that channel
:param int channel_id: id of the channel to join
"""
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
return channel_member_sudo._rtc_join_call(check_rtc_session_ids=check_rtc_session_ids)
@http.route('/mail/rtc/channel/leave_call', methods=['POST'], type="json", auth="public")
def channel_call_leave(self, channel_id):
""" Disconnects the current user from a rtc call and clears any invitation sent to that user on this channel
:param int channel_id: id of the channel from which to disconnect
"""
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
return channel_member_sudo._rtc_leave_call()
@http.route('/mail/rtc/channel/cancel_call_invitation', methods=['POST'], type="json", auth="public")
def channel_call_cancel_invitation(self, channel_id, member_ids=None):
""" Sends invitations to join the RTC call to all connected members of the thread who are not already invited,
if member_ids is provided, only the specified ids will be invited.
:param list member_ids: list of member ids to invite
"""
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
return channel_member_sudo.channel_id._rtc_cancel_invitations(member_ids=member_ids)
@http.route('/mail/rtc/audio_worklet_processor', methods=['GET'], type='http', auth='public')
def audio_worklet_processor(self):
""" Returns a JS file that declares a WorkletProcessor class in
a WorkletGlobalScope, which means that it cannot be added to the
bundles like other assets.
"""
return request.make_response(
file_open('mail/static/src/worklets/audio_processor.js', 'rb').read(),
headers=[
('Content-Type', 'application/javascript'),
('Cache-Control', 'max-age=%s' % http.STATIC_CACHE),
]
)
# --------------------------------------------------------------------------
# Guest API
# --------------------------------------------------------------------------
@http.route('/mail/guest/update_name', methods=['POST'], type='json', auth='public')
def mail_guest_update_name(self, guest_id, name):
guest = request.env['mail.guest']._get_guest_from_request(request)
guest_to_rename_sudo = guest.env['mail.guest'].browse(guest_id).sudo().exists()
if not guest_to_rename_sudo:
raise NotFound()
if guest_to_rename_sudo != guest and not request.env.user._is_admin():
raise NotFound()
guest_to_rename_sudo._update_name(name)
# --------------------------------------------------------------------------
# Link preview API
# --------------------------------------------------------------------------
@http.route('/mail/link_preview', methods=['POST'], type='json', auth='public')
def mail_link_preview(self, message_id):
if not request.env['mail.link.preview'].sudo()._is_link_preview_enabled():
return
guest = request.env['mail.guest']._get_guest_from_request(request)
message = guest.env['mail.message'].search([('id', '=', int(message_id))])
if not message:
return
if not message.is_current_user_or_guest_author and not guest.env.user._is_admin():
return
guest.env['mail.link.preview'].sudo()._create_link_previews(message)
@http.route('/mail/link_preview/delete', methods=['POST'], type='json', auth='public')
def mail_link_preview_delete(self, link_preview_id):
guest = request.env['mail.guest']._get_guest_from_request(request)
link_preview_sudo = guest.env['mail.link.preview'].sudo().search([('id', '=', int(link_preview_id))])
if not link_preview_sudo:
return
if not link_preview_sudo.message_id.is_current_user_or_guest_author and not guest.env.user._is_admin():
return
link_preview_sudo._delete_and_notify()

View file

@ -0,0 +1,42 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ipaddress
from odoo import _, SUPERUSER_ID
from odoo.http import request
from odoo.addons.web.controllers.home import Home as WebHome
def _admin_password_warn(uid):
""" Admin still has `admin` password, flash a message via chatter.
Uses a private mail.channel from the system (/ odoobot) to the user, as
using a more generic mail.thread could send an email which is undesirable
Uses mail.channel directly because using mail.thread might send an email instead.
"""
if request.params['password'] != 'admin':
return
if ipaddress.ip_address(request.httprequest.remote_addr).is_private:
return
env = request.env(user=SUPERUSER_ID, su=True)
admin = env.ref('base.partner_admin')
if uid not in admin.user_ids.ids:
return
has_demo = bool(env['ir.module.module'].search_count([('demo', '=', True)]))
if has_demo:
return
user = request.env(user=uid)['res.users']
MailChannel = env(context=user.context_get())['mail.channel']
MailChannel.browse(MailChannel.channel_get([admin.id])['id'])\
.message_post(
body=_("Your password is the default (admin)! If this system is exposed to untrusted users it is important to change it immediately for security reasons. I will keep nagging you about it!"),
message_type='comment',
subtype_xmlid='mail.mt_comment'
)
class Home(WebHome):
def _login_redirect(self, uid, redirect=None):
if request.params.get('login_success'):
_admin_password_warn(uid)
return super()._login_redirect(uid, redirect)

View file

@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from werkzeug.urls import url_encode
from odoo import http
from odoo.exceptions import AccessError
from odoo.http import request
from odoo.tools import consteq
_logger = logging.getLogger(__name__)
class MailController(http.Controller):
_cp_path = '/mail'
@classmethod
def _redirect_to_generic_fallback(cls, model, res_id, access_token=None, **kwargs):
if request.session.uid is None:
return cls._redirect_to_login_with_mail_view(
model, res_id, access_token=access_token, **kwargs,
)
return cls._redirect_to_messaging()
@classmethod
def _redirect_to_messaging(cls):
url = '/web#%s' % url_encode({'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(
'view', **{
'model': model,
'res_id': res_id,
'access_token': access_token,
**kwargs,
}
)
mail_view_url = f'{url_base}?{url_encode(url_params, sort=True)}'
return request.redirect(f'/web/login?{url_encode({"redirect": mail_view_url})}')
@classmethod
def _check_token(cls, token):
base_link = request.httprequest.path
params = dict(request.params)
params.pop('token', '')
valid_token = request.env['mail.thread']._notify_encode_link(base_link, params)
return consteq(valid_token, str(token))
@classmethod
def _check_token_and_record_or_redirect(cls, model, res_id, token):
comparison = cls._check_token(token)
if not comparison:
_logger.warning('Invalid token in route %s', request.httprequest.url)
return comparison, None, cls._redirect_to_generic_fallback(model, res_id)
try:
record = request.env[model].browse(res_id).exists()
except Exception:
record = None
redirect = cls._redirect_to_generic_fallback(model, res_id)
else:
redirect = cls._redirect_to_record(model, res_id)
return comparison, record, redirect
@classmethod
def _redirect_to_record(cls, model, res_id, access_token=None, **kwargs):
# access_token and kwargs are used in the portal controller override for the Send by email or Share Link
# to give access to the record to a recipient that has normally no access.
uid = request.session.uid
user = request.env['res.users'].sudo().browse(uid)
cids = []
# no model / res_id, meaning no possible record -> redirect to login
if not model or not res_id or model not in request.env:
return cls._redirect_to_generic_fallback(
model, res_id, access_token=access_token, **kwargs,
)
# find the access action using sudo to have the details about the access link
RecordModel = request.env[model]
record_sudo = RecordModel.sudo().browse(res_id).exists()
if not record_sudo:
# record does not seem to exist -> redirect to login
return cls._redirect_to_generic_fallback(
model, res_id, access_token=access_token, **kwargs,
)
suggested_company = record_sudo._get_mail_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):
return cls._redirect_to_generic_fallback(
model, res_id, access_token=access_token, **kwargs,
)
try:
# 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(',')]
try:
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access_rule('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
# - 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,
# redirect to the messaging.
if not suggested_company:
raise AccessError('')
cids = cids + [suggested_company.id]
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access_rule('read')
except AccessError:
return cls._redirect_to_generic_fallback(
model, res_id, access_token=access_token, **kwargs,
)
else:
record_action = record_sudo._get_access_action(access_uid=uid)
else:
record_action = record_sudo._get_access_action()
# we have an act_url (probably a portal link): we need to retry being logged to check access
if record_action['type'] == 'ir.actions.act_url' and record_action.get('target_type') != 'public':
return cls._redirect_to_login_with_mail_view(
model, res_id, access_token=access_token, **kwargs,
)
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'])
# anything else than an act_window is not supported
elif not record_action['type'] == 'ir.actions.act_window':
return cls._redirect_to_messaging()
# backend act_window: when not logged, unless really readable as public,
# user is going to be redirected to login -> keep mail/view as redirect
# 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
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'),
}
view_id = record_sudo.get_formview_id()
if view_id:
url_params['view_id'] = view_id
if cids:
url_params['cids'] = ','.join([str(cid) for cid in cids])
url = '/web?#%s' % url_encode(url_params, sort=True)
return request.redirect(url)
@http.route('/mail/view', type='http', auth='public')
def mail_action_view(self, model=None, res_id=None, access_token=None, **kwargs):
""" Generic access point from notification emails. The heuristic to
choose where to redirect the user is the following :
- find a public URL
- if none found
- users with a read access are redirected to the document
- users without read access are redirected to the Messaging
- not logged users are redirected to the login page
models that have an access_token may apply variations on this.
"""
# ==============================================================================================
# This block of code disappeared on saas-11.3 to be reintroduced by TBE.
# This is needed because after a migration from an older version to saas-11.3, the link
# received by mail with a message_id no longer work.
# So this block of code is needed to guarantee the backward compatibility of those links.
if kwargs.get('message_id'):
try:
message = request.env['mail.message'].sudo().browse(int(kwargs['message_id'])).exists()
except:
message = request.env['mail.message']
if message:
model, res_id = message.model, message.res_id
# ==============================================================================================
if res_id and isinstance(res_id, str):
try:
res_id = int(res_id)
except ValueError:
res_id = False
return self._redirect_to_record(model, res_id, access_token, **kwargs)