From ad58e0fdd8a528d741f30b497a8293a45e895685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Wed, 26 Apr 2023 09:13:14 +0000 Subject: [PATCH] [REF] mail: move discuss.channel code to its own folder (controllers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit focuses on removing all references to discuss.channel from controllers of mail module. In preparation of splitting discuss and mail modules. Part of task-3265211 closes odoo/odoo#119523 Signed-off-by: Sébastien Theys (seb) --- addons/mail/controllers/__init__.py | 8 +- addons/mail/controllers/attachment.py | 77 +++ addons/mail/controllers/discuss.py | 622 ------------------ addons/mail/controllers/discuss/__init__.py | 6 + addons/mail/controllers/discuss/binary.py | 103 +++ addons/mail/controllers/discuss/channel.py | 75 +++ .../mail/controllers/discuss/public_page.py | 160 +++++ addons/mail/controllers/discuss/rtc.py | 119 ++++ addons/mail/controllers/guest.py | 18 + addons/mail/controllers/link_preview.py | 28 + addons/mail/controllers/mail.py | 1 - addons/mail/controllers/mailbox.py | 21 + addons/mail/controllers/message_reaction.py | 86 +++ addons/mail/controllers/thread.py | 96 +++ addons/mail/controllers/webclient.py | 21 + addons/mail/models/discuss/ir_attachment.py | 15 +- addons/mail/models/ir_attachment.py | 6 +- 17 files changed, 836 insertions(+), 626 deletions(-) create mode 100644 addons/mail/controllers/attachment.py delete mode 100644 addons/mail/controllers/discuss.py create mode 100644 addons/mail/controllers/discuss/__init__.py create mode 100644 addons/mail/controllers/discuss/binary.py create mode 100644 addons/mail/controllers/discuss/channel.py create mode 100644 addons/mail/controllers/discuss/public_page.py create mode 100644 addons/mail/controllers/discuss/rtc.py create mode 100644 addons/mail/controllers/guest.py create mode 100644 addons/mail/controllers/link_preview.py create mode 100644 addons/mail/controllers/mailbox.py create mode 100644 addons/mail/controllers/message_reaction.py create mode 100644 addons/mail/controllers/thread.py create mode 100644 addons/mail/controllers/webclient.py diff --git a/addons/mail/controllers/__init__.py b/addons/mail/controllers/__init__.py index 0ae7eeab10f..d45073201db 100644 --- a/addons/mail/controllers/__init__.py +++ b/addons/mail/controllers/__init__.py @@ -1,7 +1,13 @@ -# -*- coding: utf-8 -* # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import attachment +from . import guest +from . import link_preview from . import mail +from . import mailbox +from . import message_reaction +from . import thread +from . import webclient # after mail specifically as discuss module depends on mail from . import discuss diff --git a/addons/mail/controllers/attachment.py b/addons/mail/controllers/attachment.py new file mode 100644 index 00000000000..c49eba10bd0 --- /dev/null +++ b/addons/mail/controllers/attachment.py @@ -0,0 +1,77 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug.exceptions import NotFound + +from odoo import _, http +from odoo.exceptions import AccessError +from odoo.http import request +from odoo.tools import consteq + + +class AttachmentController(http.Controller): + @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): + env = request.env["ir.attachment"]._get_upload_env(request, thread_model, 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 env.user.share: + # Only generate the access token if absolutely necessary (= not for internal user). + vals["access_token"] = env["ir.attachment"]._generate_access_token() + try: + attachment = 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): + 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() diff --git a/addons/mail/controllers/discuss.py b/addons/mail/controllers/discuss.py deleted file mode 100644 index 01273ce90d0..00000000000 --- a/addons/mail/controllers/discuss.py +++ /dev/null @@ -1,622 +0,0 @@ -# -*- 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 markupsafe import Markup -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/', - '/chat//', - ], 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/', - '/meet//', - ], 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//', methods=['GET'], type='http', auth='public') - def discuss_channel_invitation(self, channel_id, invitation_token, **kwargs): - channel_sudo = request.env['discuss.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/', methods=['GET'], type='http', auth='public') - def discuss_channel(self, channel_id, **kwargs): - channel_member_sudo = request.env['discuss.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) - return self._response_discuss_public_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['discuss.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['discuss.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.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({ - 'addGuestAsMemberOnJoin': True, - 'shouldDisplayWelcomeViewInitially': True, - }) - channel_sudo = channel_sudo.with_context(guest=guest) - response = self._response_discuss_public_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_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': 'discuss.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('/discuss/channel//partner//avatar_128', methods=['GET'], type='http', auth='public') - def discuss_channel_partner_avatar_128(self, channel_id, partner_id, **kwargs): - channel_member_sudo = request.env['discuss.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['discuss.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('/discuss/channel//guest//avatar_128', methods=['GET'], type='http', auth='public') - def discuss_channel_guest_avatar_128(self, channel_id, guest_id, **kwargs): - channel_member_sudo = request.env['discuss.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['discuss.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('/discuss/channel//attachment/', methods=['GET'], type='http', auth='public') - def discuss_channel_attachment(self, channel_id, attachment_id, download=None, **kwargs): - channel_member_sudo = request.env['discuss.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', '=', 'discuss.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([ - '/discuss/channel//image/', - '/discuss/channel//image//x', - ], methods=['GET'], type='http', auth='public') - def fetch_image(self, channel_id, attachment_id, width=0, height=0, **kwargs): - channel_member_sudo = request.env['discuss.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', '=', 'discuss.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('/discuss/channel/members', methods=['POST'], type='json', auth='public') - def discuss_channel_members(self, channel_id, known_member_ids): - channel_member = request.env['discuss.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=channel_id) - return channel_member.channel_id.sudo().load_more_members(known_member_ids) - - @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, before=None, after=None, limit=30, around=None, **kwargs): - return request.env['mail.message']._message_fetch(domain=[('needaction', '=', True)], before=before, after=after, around=around, limit=limit).message_format() - - @http.route('/mail/history/messages', methods=['POST'], type='json', auth='user') - def discuss_history_messages(self, before=None, after=None, limit=30, **kwargs): - return request.env['mail.message']._message_fetch(domain=[('needaction', '=', False)], before=before, after=after, limit=limit).message_format() - - @http.route('/mail/starred/messages', methods=['POST'], type='json', auth='user') - def discuss_starred_messages(self, before=None, after=None, limit=30, **kwargs): - return request.env['mail.message']._message_fetch(domain=[('starred_partner_ids', 'in', [request.env.user.partner_id.id])], before=before, after=after, 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): - if thread_model == 'discuss.channel': - channel_member_sudo = request.env['discuss.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() - if 'body' in post_data: - post_data['body'] = Markup(post_data['body']) # contains HTML such as @mentions - message_data = thread.message_post(**{key: value for key, value in post_data.items() if key in self._get_allowed_message_post_params()}).message_format()[0] - if 'temporary_id' in request.context: - message_data['temporary_id'] = request.context['temporary_id'] - return message_data - - @http.route('/mail/message/update_content', methods=['POST'], type='json', auth='public') - def mail_message_update_content(self, message_id, body, attachment_ids): - guest = request.env['mail.guest']._get_guest_from_request(request) - 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() - request.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, - 'attachment_ids': message_sudo.attachment_ids._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['discuss.channel.member'] - if thread_model == 'discuss.channel': - channel_member = request.env['discuss.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 == 'discuss.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 == 'discuss.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('/discuss/channel/add_guest_as_member', methods=['POST'], type='json', auth='public') - def discuss_channel_add_guest_as_member(self, channel_id, channel_uuid, **kwargs): - channel_sudo = request.env['discuss.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['discuss.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('/discuss/channel/messages', methods=['POST'], type='json', auth='public') - def discuss_channel_messages(self, channel_id, before=None, after=None, limit=30, around=None, **kwargs): - channel_member_sudo = request.env['discuss.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) - domain = [ - ('res_id', '=', channel_id), - ('model', '=', 'discuss.channel'), - ('message_type', '!=', 'user_notification'), - ] - messages = channel_member_sudo.env['mail.message']._message_fetch(domain=domain, before=before, after=after, around=around, limit=limit) - if not request.env.user._is_public() and not around: - messages.set_message_done() - return messages.sorted('id', reverse=True).message_format() - - @http.route('/discuss/channel/pinned_messages', methods=['POST'], type='json', auth='public') - def discuss_channel_pins(self, channel_id, **kwargs): - channel_member_sudo = request.env['discuss.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) - return channel_member_sudo.channel_id.pinned_message_ids.sorted(key='pinned_at', reverse=True).message_format() - - @http.route('/discuss/channel/set_last_seen_message', methods=['POST'], type='json', auth='public') - def discuss_channel_mark_as_seen(self, channel_id, last_message_id, allow_older=False, **kwargs): - channel_member_sudo = request.env['discuss.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) - return channel_member_sudo.channel_id._channel_seen(last_message_id, allow_older=allow_older) - - @http.route('/discuss/channel/notify_typing', methods=['POST'], type='json', auth='public') - def discuss_channel_notify_typing(self, channel_id, is_typing, **kwargs): - channel_member_sudo = request.env['discuss.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('/discuss/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['discuss.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, before=None, after=None, around=None, limit=30, **kwargs): - messages = request.env['mail.message']._message_fetch(domain=[ - ('res_id', '=', int(thread_id)), - ('model', '=', thread_model), - ('message_type', '!=', 'user_notification'), - ], before=before, after=after, around=around, 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 - # -------------------------------------------------------------------------- - - @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['discuss.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['discuss.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['discuss.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['discuss.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['discuss.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): - """ - :param member_ids: members whose invitation is to cancel - :type member_ids: list(int) or None - """ - channel_member_sudo = request.env['discuss.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() diff --git a/addons/mail/controllers/discuss/__init__.py b/addons/mail/controllers/discuss/__init__.py new file mode 100644 index 00000000000..e0ae1fd860e --- /dev/null +++ b/addons/mail/controllers/discuss/__init__.py @@ -0,0 +1,6 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import binary +from . import channel +from . import public_page +from . import rtc diff --git a/addons/mail/controllers/discuss/binary.py b/addons/mail/controllers/discuss/binary.py new file mode 100644 index 00000000000..bd421c7fea2 --- /dev/null +++ b/addons/mail/controllers/discuss/binary.py @@ -0,0 +1,103 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import request + + +class BinaryController(http.Controller): + @http.route( + "/discuss/channel//partner//avatar_128", + methods=["GET"], + type="http", + auth="public", + ) + def discuss_channel_partner_avatar_128(self, channel_id, partner_id): + channel_member_sudo = request.env["discuss.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() + domain = [("channel_id", "=", channel_id), ("partner_id", "=", partner_id)] + if channel_member_sudo and channel_member_sudo.env["discuss.channel.member"].search(domain, 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( + "/discuss/channel//guest//avatar_128", methods=["GET"], type="http", auth="public" + ) + def discuss_channel_guest_avatar_128(self, channel_id, guest_id): + channel_member_sudo = request.env["discuss.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() + domain = [("channel_id", "=", channel_id), ("guest_id", "=", guest_id)] + if channel_member_sudo and channel_member_sudo.env["discuss.channel.member"].search(domain, 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( + "/discuss/channel//attachment/", methods=["GET"], type="http", auth="public" + ) + def discuss_channel_attachment(self, channel_id, attachment_id, download=None): + channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_request_or_raise( + request=request, channel_id=int(channel_id) + ) + domain = [ + ("id", "=", int(attachment_id)), + ("res_id", "=", int(channel_id)), + ("res_model", "=", "discuss.channel"), + ] + attachment_sudo = channel_member_sudo.env["ir.attachment"].search(domain, 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( + [ + "/discuss/channel//image/", + "/discuss/channel//image//x", + ], + methods=["GET"], + type="http", + auth="public", + ) + def fetch_image(self, channel_id, attachment_id, width=0, height=0, **kwargs): + channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_request_or_raise( + request=request, channel_id=int(channel_id) + ) + domain = [ + ("id", "=", int(attachment_id)), + ("res_id", "=", int(channel_id)), + ("res_model", "=", "discuss.channel"), + ] + attachment_sudo = channel_member_sudo.env["ir.attachment"].search(domain, 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")) + ) diff --git a/addons/mail/controllers/discuss/channel.py b/addons/mail/controllers/discuss/channel.py new file mode 100644 index 00000000000..4c5ea52eff6 --- /dev/null +++ b/addons/mail/controllers/discuss/channel.py @@ -0,0 +1,75 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.exceptions import UserError +from odoo.http import request +from odoo.tools import consteq + + +class ChannelController(http.Controller): + @http.route("/discuss/channel/members", methods=["POST"], type="json", auth="public") + def discuss_channel_members(self, channel_id, known_member_ids): + channel_member = request.env["discuss.channel.member"]._get_as_sudo_from_request_or_raise( + request=request, channel_id=channel_id + ) + return channel_member.channel_id.sudo().load_more_members(known_member_ids) + + @http.route("/discuss/channel/add_guest_as_member", methods=["POST"], type="json", auth="public") + def discuss_channel_add_guest_as_member(self, channel_id, channel_uuid): + channel_sudo = request.env["discuss.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["discuss.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("/discuss/channel/messages", methods=["POST"], type="json", auth="public") + def discuss_channel_messages(self, channel_id, before=None, after=None, limit=30, around=None): + channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_request_or_raise( + request=request, channel_id=int(channel_id) + ) + domain = [ + ("res_id", "=", channel_id), + ("model", "=", "discuss.channel"), + ("message_type", "!=", "user_notification"), + ] + messages = channel_member_sudo.env["mail.message"]._message_fetch(domain, before, after, around, limit) + if not request.env.user._is_public() and not around: + messages.set_message_done() + return messages.sorted("id", reverse=True).message_format() + + @http.route("/discuss/channel/pinned_messages", methods=["POST"], type="json", auth="public") + def discuss_channel_pins(self, channel_id): + channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_request_or_raise( + request=request, channel_id=int(channel_id) + ) + return channel_member_sudo.channel_id.pinned_message_ids.sorted(key="pinned_at", reverse=True).message_format() + + @http.route("/discuss/channel/set_last_seen_message", methods=["POST"], type="json", auth="public") + def discuss_channel_mark_as_seen(self, channel_id, last_message_id, allow_older=False): + channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_request_or_raise( + request=request, channel_id=int(channel_id) + ) + return channel_member_sudo.channel_id._channel_seen(last_message_id, allow_older=allow_older) + + @http.route("/discuss/channel/notify_typing", methods=["POST"], type="json", auth="public") + def discuss_channel_notify_typing(self, channel_id, is_typing): + channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_request_or_raise( + request=request, channel_id=int(channel_id) + ) + channel_member_sudo._notify_typing(is_typing) diff --git a/addons/mail/controllers/discuss/public_page.py b/addons/mail/controllers/discuss/public_page.py new file mode 100644 index 00000000000..9529624e02a --- /dev/null +++ b/addons/mail/controllers/discuss/public_page.py @@ -0,0 +1,160 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta +from psycopg2 import IntegrityError +from psycopg2.errorcodes import UNIQUE_VIOLATION +from werkzeug.exceptions import NotFound + +from odoo import _, http +from odoo.exceptions import UserError +from odoo.http import request +from odoo.tools import consteq +from odoo.tools.misc import get_lang + + +class PublicPageController(http.Controller): + @http.route( + [ + "/chat/", + "/chat//", + ], + methods=["GET"], + type="http", + auth="public", + ) + def discuss_channel_chat_from_token(self, create_token, channel_name=None): + return self._response_discuss_channel_from_token(create_token=create_token, channel_name=channel_name) + + @http.route( + [ + "/meet/", + "/meet//", + ], + methods=["GET"], + type="http", + auth="public", + ) + def discuss_channel_meet_from_token(self, create_token, channel_name=None): + return self._response_discuss_channel_from_token( + create_token=create_token, channel_name=channel_name, default_display_mode="video_full_screen" + ) + + @http.route("/chat//", methods=["GET"], type="http", auth="public") + def discuss_channel_invitation(self, channel_id, invitation_token): + channel_sudo = request.env["discuss.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/", methods=["GET"], type="http", auth="public") + def discuss_channel(self, channel_id): + channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_request_or_raise( + request=request, channel_id=int(channel_id) + ) + return self._response_discuss_public_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["discuss.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["discuss.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.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( + { + "addGuestAsMemberOnJoin": True, + "shouldDisplayWelcomeViewInitially": True, + } + ) + channel_sudo = channel_sudo.with_context(guest=guest) + response = self._response_discuss_public_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_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": "discuss.channel"})], + "shouldDisplayWelcomeViewInitially": channel_sudo.default_display_mode + == "video_full_screen", + }, + **discuss_public_view_data, + ), + }, + "session_info": channel_sudo.env["ir.http"].session_info(), + }, + ) diff --git a/addons/mail/controllers/discuss/rtc.py b/addons/mail/controllers/discuss/rtc.py new file mode 100644 index 00000000000..7ad5d99ddd2 --- /dev/null +++ b/addons/mail/controllers/discuss/rtc.py @@ -0,0 +1,119 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict + +from odoo import http +from odoo.http import request +from odoo.tools import file_open + + +class RtcController(http.Controller): + @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["discuss.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["discuss.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["discuss.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["discuss.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["discuss.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): + """ + :param member_ids: members whose invitation is to cancel + :type member_ids: list(int) or None + """ + channel_member_sudo = request.env["discuss.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), + ], + ) + + @http.route("/discuss/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["discuss.channel.member"]._get_as_sudo_from_request_or_raise( + request=request, channel_id=int(channel_id) + ) + if rtc_session_id: + domain = [ + ("id", "=", int(rtc_session_id)), + ("channel_member_id", "=", channel_member_sudo.id), + ] + channel_member_sudo.channel_id.rtc_session_ids.filtered_domain(domain).write({}) # update write_date + current_rtc_sessions, outdated_rtc_sessions = channel_member_sudo._rtc_sync_sessions(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], + ), + ] + } diff --git a/addons/mail/controllers/guest.py b/addons/mail/controllers/guest.py new file mode 100644 index 00000000000..e4cae347567 --- /dev/null +++ b/addons/mail/controllers/guest.py @@ -0,0 +1,18 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import request + + +class GuestController(http.Controller): + @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) diff --git a/addons/mail/controllers/link_preview.py b/addons/mail/controllers/link_preview.py new file mode 100644 index 00000000000..f297ae72ac1 --- /dev/null +++ b/addons/mail/controllers/link_preview.py @@ -0,0 +1,28 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http +from odoo.http import request + + +class LinkPreviewController(http.Controller): + @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() diff --git a/addons/mail/controllers/mail.py b/addons/mail/controllers/mail.py index 42c2d966f2e..62fcc30019e 100644 --- a/addons/mail/controllers/mail.py +++ b/addons/mail/controllers/mail.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging diff --git a/addons/mail/controllers/mailbox.py b/addons/mail/controllers/mailbox.py new file mode 100644 index 00000000000..a5f335449a9 --- /dev/null +++ b/addons/mail/controllers/mailbox.py @@ -0,0 +1,21 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http +from odoo.http import request + + +class MailboxController(http.Controller): + @http.route("/mail/inbox/messages", methods=["POST"], type="json", auth="user") + def discuss_inbox_messages(self, before=None, after=None, limit=30, around=None): + domain = [("needaction", "=", True)] + return request.env["mail.message"]._message_fetch(domain, before, after, around, limit).message_format() + + @http.route("/mail/history/messages", methods=["POST"], type="json", auth="user") + def discuss_history_messages(self, before=None, after=None, limit=30): + domain = [("needaction", "=", False)] + return request.env["mail.message"]._message_fetch(domain, before, after, limit=limit).message_format() + + @http.route("/mail/starred/messages", methods=["POST"], type="json", auth="user") + def discuss_starred_messages(self, before=None, after=None, limit=30): + domain = [("starred_partner_ids", "in", [request.env.user.partner_id.id])] + return request.env["mail.message"]._message_fetch(domain, before, after, limit=limit).message_format() diff --git a/addons/mail/controllers/message_reaction.py b/addons/mail/controllers/message_reaction.py new file mode 100644 index 00000000000..07dccfafea1 --- /dev/null +++ b/addons/mail/controllers/message_reaction.py @@ -0,0 +1,86 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import request + + +class MessageReactionController(http.Controller): + @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 == "discuss.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 == "discuss.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, + }, + ) + ], + } diff --git a/addons/mail/controllers/thread.py b/addons/mail/controllers/thread.py new file mode 100644 index 00000000000..050fcb16bad --- /dev/null +++ b/addons/mail/controllers/thread.py @@ -0,0 +1,96 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from markupsafe import Markup +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import request + + +class ThreadController(http.Controller): + @http.route("/mail/thread/data", methods=["POST"], type="json", auth="user") + def mail_thread_data(self, thread_model, thread_id, request_list): + 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, before=None, after=None, around=None, limit=30): + domain = [ + ("res_id", "=", int(thread_id)), + ("model", "=", thread_model), + ("message_type", "!=", "user_notification"), + ] + messages = request.env["mail.message"]._message_fetch(domain, before, after, around, 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"]), + ) + + 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): + if thread_model == "discuss.channel": + channel_member_sudo = request.env["discuss.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() + if "body" in post_data: + post_data["body"] = Markup(post_data["body"]) # contains HTML such as @mentions + message_data = thread.message_post( + **{key: value for key, value in post_data.items() if key in self._get_allowed_message_post_params()} + ).message_format()[0] + if "temporary_id" in request.context: + message_data["temporary_id"] = request.context["temporary_id"] + return message_data + + @http.route("/mail/message/update_content", methods=["POST"], type="json", auth="public") + def mail_message_update_content(self, message_id, body, attachment_ids): + guest = request.env["mail.guest"]._get_guest_from_request(request) + 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() + request.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, + "attachment_ids": message_sudo.attachment_ids._attachment_format(), + } diff --git a/addons/mail/controllers/webclient.py b/addons/mail/controllers/webclient.py new file mode 100644 index 00000000000..750e8057771 --- /dev/null +++ b/addons/mail/controllers/webclient.py @@ -0,0 +1,21 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import request + + +class WebclientController(http.Controller): + @http.route("/mail/init_messaging", methods=["POST"], type="json", auth="public") + def mail_init_messaging(self): + 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): + return request.env.user.partner_id._message_fetch_failed() diff --git a/addons/mail/models/discuss/ir_attachment.py b/addons/mail/models/discuss/ir_attachment.py index c61cb4ae4ca..50bc9b629fd 100644 --- a/addons/mail/models/discuss/ir_attachment.py +++ b/addons/mail/models/discuss/ir_attachment.py @@ -1,6 +1,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import models +from odoo import api, models class IrAttachment(models.Model): @@ -14,3 +14,16 @@ class IrAttachment(models.Model): if self.env.user._is_public() and guest: return guest return super()._bus_notification_target() + + @api.model + def _get_upload_env(self, request, thread_model, thread_id): + """Overriden to allow guests and (portal) users to upload attachments to channels they have + access to. The base method returns the env of the current request, which is not sudo and + relies on access rights. Guests or (portal) users need sudo to upload attachments.""" + if thread_model == "discuss.channel": + return ( + request.env["discuss.channel.member"] + ._get_as_sudo_from_request_or_raise(request=request, channel_id=int(thread_id)) + .env + ) + return super()._get_upload_env(request, thread_model, thread_id) diff --git a/addons/mail/models/ir_attachment.py b/addons/mail/models/ir_attachment.py index ea9a6158c35..d76de0e0bbd 100644 --- a/addons/mail/models/ir_attachment.py +++ b/addons/mail/models/ir_attachment.py @@ -3,7 +3,7 @@ import contextlib -from odoo import models +from odoo import api, models from odoo.exceptions import AccessError from odoo.http import request @@ -71,3 +71,7 @@ class IrAttachment(models.Model): }) res_list.append(res) return res_list + + @api.model + def _get_upload_env(self, request, thread_model, thread_id): + return request.env