diff --git a/addons/crm_livechat/__manifest__.py b/addons/crm_livechat/__manifest__.py index 3c56438b36c..a12ae3c817c 100644 --- a/addons/crm_livechat/__manifest__.py +++ b/addons/crm_livechat/__manifest__.py @@ -16,6 +16,9 @@ 'auto_install': True, 'license': 'LGPL-3', 'assets': { + 'mail.assets_discuss_public': [ + 'crm_livechat/static/src/models/*/*.js', + ], 'web.assets_backend': [ 'crm_livechat/static/src/models/*/*.js', ], diff --git a/addons/hr/__manifest__.py b/addons/hr/__manifest__.py index 48df0416c0d..ff542e54766 100644 --- a/addons/hr/__manifest__.py +++ b/addons/hr/__manifest__.py @@ -49,6 +49,9 @@ 'application': True, 'auto_install': False, 'assets': { + 'mail.assets_discuss_public': [ + 'hr/static/src/models/*/*.js', + ], 'web.assets_backend': [ 'hr/static/src/scss/hr.scss', 'hr/static/src/js/chat_mixin.js', @@ -56,10 +59,7 @@ 'hr/static/src/js/language.js', 'hr/static/src/js/m2x_avatar_employee.js', 'hr/static/src/js/standalone_m2o_avatar_employee.js', - 'hr/static/src/models/employee/employee.js', - 'hr/static/src/models/messaging/messaging.js', - 'hr/static/src/models/partner/partner.js', - 'hr/static/src/models/user/user.js', + 'hr/static/src/models/*/*.js', ], 'web.qunit_suite_tests': [ 'hr/static/tests/helpers/mock_models.js', diff --git a/addons/hr_holidays/__manifest__.py b/addons/hr_holidays/__manifest__.py index 9021d7f8514..db8472955f9 100644 --- a/addons/hr_holidays/__manifest__.py +++ b/addons/hr_holidays/__manifest__.py @@ -58,26 +58,26 @@ A synchronization with an internal agenda (Meetings of the CRM module) is also p 'application': True, 'auto_install': False, 'assets': { + 'mail.assets_discuss_public': [ + 'hr_holidays/static/src/components/*/*', + 'hr_holidays/static/src/models/*/*.js', + ], 'web.assets_backend': [ 'hr_holidays/static/src/js/time_off_calendar.js', 'hr_holidays/static/src/js/leave_stats_widget.js', - 'hr_holidays/static/src/models/partner/partner.js', + 'hr_holidays/static/src/models/*/*.js', 'hr_holidays/static/src/scss/time_off.scss', - 'hr_holidays/static/src/components/thread_view/thread_view.scss', + 'hr_holidays/static/src/components/*/*.scss', ], 'web.tests_assets': [ 'hr_holidays/static/tests/helpers/**/*', ], 'web.qunit_suite_tests': [ - 'hr_holidays/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js', - 'hr_holidays/static/src/components/thread_icon/thread_icon_tests.js', - 'hr_holidays/static/src/components/thread_view/thread_view_tests.js', + 'hr_holidays/static/src/components/*/tests/*.js', 'hr_holidays/static/tests/test_leave_stats_widget.js', ], 'web.assets_qweb': [ - 'hr_holidays/static/src/components/partner_im_status_icon/partner_im_status_icon.xml', - 'hr_holidays/static/src/components/thread_icon/thread_icon.xml', - 'hr_holidays/static/src/components/thread_view/thread_view.xml', + 'hr_holidays/static/src/components/*/*.xml', 'hr_holidays/static/src/xml/*.xml', ], }, diff --git a/addons/hr_holidays/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js b/addons/hr_holidays/static/src/components/partner_im_status_icon/tests/partner_im_status_icon_tests.js similarity index 100% rename from addons/hr_holidays/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js rename to addons/hr_holidays/static/src/components/partner_im_status_icon/tests/partner_im_status_icon_tests.js diff --git a/addons/hr_holidays/static/src/components/thread_icon/thread_icon_tests.js b/addons/hr_holidays/static/src/components/thread_icon/tests/thread_icon_tests.js similarity index 100% rename from addons/hr_holidays/static/src/components/thread_icon/thread_icon_tests.js rename to addons/hr_holidays/static/src/components/thread_icon/tests/thread_icon_tests.js diff --git a/addons/hr_holidays/static/src/components/thread_view/thread_view_tests.js b/addons/hr_holidays/static/src/components/thread_view/tests/thread_view_tests.js similarity index 100% rename from addons/hr_holidays/static/src/components/thread_view/thread_view_tests.js rename to addons/hr_holidays/static/src/components/thread_view/tests/thread_view_tests.js diff --git a/addons/im_livechat/__manifest__.py b/addons/im_livechat/__manifest__.py index fcd5441fb60..272493824f0 100644 --- a/addons/im_livechat/__manifest__.py +++ b/addons/im_livechat/__manifest__.py @@ -43,40 +43,26 @@ Help your customers with this chat, and analyse their feedback. 'auto_install': False, 'application': True, 'assets': { + 'mail.assets_discuss_public': [ + 'im_livechat/static/src/components/*/*', + 'im_livechat/static/src/models/*/*.js', + ], 'web.assets_backend': [ 'im_livechat/static/src/js/im_livechat_channel_form_view.js', 'im_livechat/static/src/js/im_livechat_channel_form_controller.js', - 'im_livechat/static/src/components/discuss/discuss.js', - 'im_livechat/static/src/components/notification_list/notification_list.js', - 'im_livechat/static/src/components/thread_needaction_preview/thread_needaction_preview.js', - 'im_livechat/static/src/components/thread_preview/thread_preview.js', - 'im_livechat/static/src/models/discuss/discuss.js', - 'im_livechat/static/src/models/discuss_sidebar_category_item/discuss_sidebar_category_item.js', - 'im_livechat/static/src/models/chat_window/chat_window.js', - 'im_livechat/static/src/models/messaging_initializer/messaging_initializer.js', - 'im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js', - 'im_livechat/static/src/models/partner/partner.js', - 'im_livechat/static/src/models/thread/thread.js', + 'im_livechat/static/src/components/*/*.js', + 'im_livechat/static/src/models/*/*.js', 'im_livechat/static/src/scss/im_livechat_history.scss', 'im_livechat/static/src/scss/im_livechat_form.scss', ], 'web.qunit_suite_tests': [ - 'im_livechat/static/src/components/discuss_sidebar_category/tests/discuss_sidebar_category_tests.js', - 'im_livechat/static/src/components/discuss_sidebar_category_item/tests/discuss_sidebar_category_item_tests.js', - 'im_livechat/static/src/components/chat_window_manager/chat_window_manager_tests.js', - 'im_livechat/static/src/components/composer/composer_tests.js', - 'im_livechat/static/src/components/discuss/discuss_tests.js', - 'im_livechat/static/src/components/messaging_menu/messaging_menu_tests.js', - 'im_livechat/static/src/components/thread_icon/thread_icon_tests.js', - 'im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js', + 'im_livechat/static/src/components/*/tests/*.js', 'im_livechat/static/src/legacy/public_livechat.js', 'im_livechat/static/tests/helpers/mock_models.js', 'im_livechat/static/tests/helpers/mock_server.js', ], 'web.assets_qweb': [ - 'im_livechat/static/src/components/composer/composer.xml', - 'im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.xml', - 'im_livechat/static/src/components/thread_icon/thread_icon.xml', + 'im_livechat/static/src/components/*/*.xml', ], # Bundle of External Librairies of the Livechat (Odoo + required modules) 'im_livechat.external_lib': [ diff --git a/addons/im_livechat/models/im_livechat_channel.py b/addons/im_livechat/models/im_livechat_channel.py index 40f51269364..fc1be753f39 100644 --- a/addons/im_livechat/models/im_livechat_channel.py +++ b/addons/im_livechat/models/im_livechat_channel.py @@ -118,7 +118,7 @@ class ImLivechatChannel(models.Model): visitor_user = False if user_id: visitor_user = self.env['res.users'].browse(user_id) - if visitor_user and visitor_user.active: # valid session user (not public) + if visitor_user and visitor_user.active and visitor_user != operator: # valid session user (not public) channel_partner_to_add.append(Command.create({'partner_id': visitor_user.partner_id.id})) return { 'channel_last_seen_partner_ids': channel_partner_to_add, diff --git a/addons/im_livechat/models/mail_channel.py b/addons/im_livechat/models/mail_channel.py index 8f7b9e6dd40..d12f52a7b53 100644 --- a/addons/im_livechat/models/mail_channel.py +++ b/addons/im_livechat/models/mail_channel.py @@ -49,10 +49,10 @@ class MailChannel(models.Model): notifications = self._channel_channel_notifications(unpinned_channel_partner.mapped('partner_id').ids) + notifications return notifications - def channel_fetch_message(self, last_id=False, limit=20): + def _channel_fetch_message(self, last_id=False, limit=20): """ Override to add the context of the livechat username.""" channel = self.with_context(im_livechat_use_username=True) if self.channel_type == 'livechat' else self - return super(MailChannel, channel).channel_fetch_message(last_id=last_id, limit=limit) + return super(MailChannel, channel)._channel_fetch_message(last_id=last_id, limit=limit) def channel_info(self, extra_info=False): """ Extends the channel header by adding the livechat operator and the 'anonymous' profile diff --git a/addons/im_livechat/static/src/components/chat_window_manager/chat_window_manager_tests.js b/addons/im_livechat/static/src/components/chat_window_manager/tests/chat_window_manager_tests.js similarity index 100% rename from addons/im_livechat/static/src/components/chat_window_manager/chat_window_manager_tests.js rename to addons/im_livechat/static/src/components/chat_window_manager/tests/chat_window_manager_tests.js diff --git a/addons/im_livechat/static/src/components/composer/composer_tests.js b/addons/im_livechat/static/src/components/composer/tests/composer_tests.js similarity index 100% rename from addons/im_livechat/static/src/components/composer/composer_tests.js rename to addons/im_livechat/static/src/components/composer/tests/composer_tests.js diff --git a/addons/im_livechat/static/src/components/discuss/discuss_tests.js b/addons/im_livechat/static/src/components/discuss/tests/discuss_tests.js similarity index 100% rename from addons/im_livechat/static/src/components/discuss/discuss_tests.js rename to addons/im_livechat/static/src/components/discuss/tests/discuss_tests.js diff --git a/addons/im_livechat/static/src/components/messaging_menu/messaging_menu_tests.js b/addons/im_livechat/static/src/components/messaging_menu/tests/messaging_menu_tests.js similarity index 100% rename from addons/im_livechat/static/src/components/messaging_menu/messaging_menu_tests.js rename to addons/im_livechat/static/src/components/messaging_menu/tests/messaging_menu_tests.js diff --git a/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js b/addons/im_livechat/static/src/components/thread_icon/tests/thread_icon_tests.js similarity index 100% rename from addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js rename to addons/im_livechat/static/src/components/thread_icon/tests/thread_icon_tests.js diff --git a/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js b/addons/im_livechat/static/src/components/thread_textual_typing_status/tests/thread_textual_typing_status_tests.js similarity index 100% rename from addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js rename to addons/im_livechat/static/src/components/thread_textual_typing_status/tests/thread_textual_typing_status_tests.js diff --git a/addons/im_livechat/static/src/legacy/public_livechat.js b/addons/im_livechat/static/src/legacy/public_livechat.js index 509fccd23d6..d1b8db0d16c 100644 --- a/addons/im_livechat/static/src/legacy/public_livechat.js +++ b/addons/im_livechat/static/src/legacy/public_livechat.js @@ -269,7 +269,7 @@ var LivechatButton = Widget.extend({ self.call('bus_service', 'addChannel', self._livechat.getUUID()); self.call('bus_service', 'startPolling'); - utils.set_cookie('im_livechat_session', utils.unaccent(JSON.stringify(self._livechat.toData())), 60 * 60); + utils.set_cookie('im_livechat_session', utils.unaccent(JSON.stringify(self._livechat.toData()), true), 60 * 60); utils.set_cookie('im_livechat_auto_popup', JSON.stringify(false), 60 * 60); if (livechatData.operator_pid[0]) { // livechatData.operator_pid contains a tuple (id, name) @@ -436,7 +436,7 @@ var LivechatButton = Widget.extend({ */ _onSaveChatWindow: function (ev) { ev.stopPropagation(); - utils.set_cookie('im_livechat_session', utils.unaccent(JSON.stringify(this._livechat.toData())), 60 * 60); + utils.set_cookie('im_livechat_session', utils.unaccent(JSON.stringify(this._livechat.toData()), true), 60 * 60); }, /** * @private diff --git a/addons/im_livechat/static/src/models/thread/thread.js b/addons/im_livechat/static/src/models/thread/thread.js index 2cfa893cac7..a45e90018b2 100644 --- a/addons/im_livechat/static/src/models/thread/thread.js +++ b/addons/im_livechat/static/src/models/thread/thread.js @@ -95,6 +95,15 @@ registerInstancePatchModel('mail.thread', 'im_livechat/static/src/models/thread/ } return this._super(); }, + /** + * @override + */ + _computeHasMemberListFeature() { + if (this.channel_type === 'livechat') { + return true; + } + return this._super(); + }, /** * @override */ diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 3a23aee1a4b..18a79b75080 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -35,7 +35,9 @@ 'data/ir_cron_data.xml', 'security/mail_security.xml', 'security/ir.model.access.csv', + 'views/discuss_public_templates.xml', 'views/mail_alias_views.xml', + 'views/mail_guest_views.xml', 'views/res_users_views.xml', 'views/res_users_settings_views.xml', 'views/mail_template_views.xml', @@ -51,6 +53,70 @@ 'installable': True, 'application': True, 'assets': { + 'mail.assets_common_discuss_public': [ + ('include', 'web.assets_common'), + ('remove', 'web_tour/**'), + ], + 'mail.assets_discuss_public': [ + # SCSS dependencies (the order is important) + ('include', 'web._assets_helpers'), + 'web/static/src/legacy/scss/bootstrap_overridden.scss', + 'web/static/lib/bootstrap/scss/_variables.scss', + 'web/static/src/legacy/scss/import_bootstrap.scss', + 'web/static/src/legacy/scss/bootstrap_review.scss', + 'web/static/src/webclient/webclient.scss', + 'web/static/src/webclient/webclient_extra.scss', + # Dependency of notification_group, notification_request, thread_needaction_preview and thread_preview + 'mail/static/src/components/notification_list/notification_list_item.scss', + 'mail/static/src/component_hooks/*/*.js', + 'mail/static/src/components/*/*.js', + 'mail/static/src/components/*/*.scss', + 'mail/static/src/components/*/*.xml', + 'mail/static/src/js/emojis.js', + 'mail/static/src/js/utils.js', + 'mail/static/src/model/*.js', + 'mail/static/src/models/*/*.js', + 'mail/static/src/public/discuss_public_boot.js', + 'mail/static/src/services/messaging/messaging.js', + 'mail/static/src/utils/*/*.js', + 'mail/static/src/utils/messaging_component.js', + 'mail/static/src/utils/utils.js', + # Framework JS + 'bus/static/src/js/*.js', + 'bus/static/src/js/services/bus_service.js', + 'web/static/lib/luxon/luxon.js', + 'web/static/lib/py.js/lib/py.js', + 'web/static/src/core/**/*.js', + ('remove', 'web/static/src/core/debug/**/*.[js][scss]'), + 'web/static/src/env.js', + 'web/static/src/legacy/js/_deprecated/basic_fields.js', + 'web/static/src/legacy/js/_deprecated/data.js', + 'web/static/src/legacy/js/chrome/action_mixin.js', + 'web/static/src/legacy/js/components/*.js', + 'web/static/src/legacy/js/control_panel/*.js', + 'web/static/src/legacy/js/core/context.js', + 'web/static/src/legacy/js/core/data_comparison_utils.js', + 'web/static/src/legacy/js/core/domain.js', + 'web/static/src/legacy/js/core/misc.js', + 'web/static/src/legacy/js/core/mvc.js', + 'web/static/src/legacy/js/core/py_utils.js', + 'web/static/src/legacy/js/env.js', + 'web/static/src/legacy/js/fields/*.js', + 'web/static/src/legacy/js/model.js', + 'web/static/src/legacy/js/owl_compatibility.js', + 'web/static/src/legacy/js/public/lazyloader.js', + 'web/static/src/legacy/js/services/data_manager.js', + 'web/static/src/legacy/js/services/session.js', + 'web/static/src/legacy/js/views/**/*.js', + 'web/static/src/legacy/js/widgets/data_export.js', + 'web/static/src/legacy/js/widgets/date_picker.js', + 'web/static/src/legacy/js/widgets/domain_selector.js', + 'web/static/src/legacy/js/widgets/domain_selector_dialog.js', + 'web/static/src/legacy/js/widgets/model_field_selector.js', + 'web/static/src/legacy/utils.js', + 'web/static/src/legacy/web_client.js', + 'web/static/src/legacy/xml/base.xml', + ], 'web._assets_primary_variables': [ 'mail/static/src/scss/variables/primary_variables.scss', ], diff --git a/addons/mail/controllers/bus.py b/addons/mail/controllers/bus.py index 0e653f1a800..bcae11f344b 100644 --- a/addons/mail/controllers/bus.py +++ b/addons/mail/controllers/bus.py @@ -19,17 +19,21 @@ class MailChatController(BusController): # Extends BUS Controller Poll # -------------------------- def _poll(self, dbname, channels, last, options): + channels = list(channels) # do not alter original list + guest_sudo = request.env['mail.guest']._get_guest_from_request(request).sudo() + mail_channels = request.env['mail.channel'] if request.session.uid: - partner_id = request.env.user.partner_id.id - - if partner_id: - channels = list(channels) # do not alter original list - for mail_channel in request.env['mail.channel'].search([('channel_partner_ids', 'in', [partner_id])]): - channels.append((request.db, 'mail.channel', mail_channel.id)) - # personal and needaction channel - channels.append((request.db, 'res.partner', partner_id)) - channels.append((request.db, 'ir.needaction', partner_id)) - return super(MailChatController, self)._poll(dbname, channels, last, options) + partner = request.env.user.partner_id + mail_channels = partner.channel_ids + # personal and needaction channel + channels.append((request.db, 'res.partner', partner.id)) + channels.append((request.db, 'ir.needaction', partner.id)) + elif guest_sudo: + mail_channels = guest_sudo.channel_ids + channels.append((request.db, 'mail.guest', guest_sudo.id)) + for mail_channel in mail_channels: + channels.append((request.db, 'mail.channel', mail_channel.id)) + return super()._poll(dbname, channels, last, options) # -------------------------- # Anonymous routes (Common Methods) @@ -65,4 +69,4 @@ class MailChatController(BusController): if not channel: return [] else: - return channel.channel_fetch_message(last_id, limit) + return channel._channel_fetch_message(last_id, limit) diff --git a/addons/mail/controllers/discuss.py b/addons/mail/controllers/discuss.py index c9d12ff0978..8c60a406659 100644 --- a/addons/mail/controllers/discuss.py +++ b/addons/mail/controllers/discuss.py @@ -1,18 +1,225 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import base64 -import werkzeug.utils -import werkzeug.wrappers +import json from odoo import http -from odoo.exceptions import AccessError from odoo.http import request +from odoo.tools import consteq +from odoo.tools.misc import get_lang +from odoo.tools.translate import _ +from werkzeug.exceptions import NotFound class DiscussController(http.Controller): - @http.route('/mail/read_followers', type='json', auth='user') + # -------------------------------------------------------------------------- + # Public Pages + # -------------------------------------------------------------------------- + + @http.route('/chat//', methods=['GET'], type='http', auth='public') + def discuss_channel_invitation(self, channel_id, invitation_token, **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, invitation_token): + raise NotFound() + response = request.redirect(f'/discuss/channel/{channel_sudo.id}') + if request.env['mail.channel.partner']._get_as_sudo_from_request(request=request, channel_id=int(channel_id)): + return response + if channel_sudo.channel_type == 'chat': + raise NotFound() + if request.session.uid: + channel_sudo.add_members([request.env.user.partner_id.id]) + return response + guest = request.env['mail.guest']._get_guest_from_request(request) + if not guest: + guest = request.env['mail.guest'].sudo().create({ + 'country_id': request.env['res.country'].sudo().search([('code', '=', request.session.get('geoip', {}).get('country_code'))], limit=1).id, + 'lang': get_lang(request.env).code, + 'name': _("Guest"), + 'timezone': request.env['mail.guest']._get_timezone_from_request(request), + }) + # Guest session cookies, every route in this file will make use of them to authenticate + # the guest through `_get_as_sudo_from_request` or `_get_as_sudo_from_request_or_raise`. + response.set_cookie('mail.guest_id', str(guest.id), httponly=True) + response.set_cookie('mail.guest_access_token', guest.access_token, httponly=True) + channel_sudo.add_members(guest_ids=[guest.id]) + return response + + @http.route('/discuss/channel/', methods=['GET'], type='http', auth='public') + def discuss_channel(self, channel_id, **kwargs): + channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) + return request.render('mail.discuss_public_channel_template', { + 'channel_sudo': channel_partner_sudo.channel_id, + 'session_info': channel_partner_sudo.env['ir.http'].session_info(), + }) + + # -------------------------------------------------------------------------- + # Semi-Static Content (GET requests with possible cache) + # -------------------------------------------------------------------------- + + @http.route('/mail/channel//partner//avatar_128', methods=['GET'], type='http', auth='public') + def mail_channel_partner_avatar_128(self, channel_id, partner_id, **kwargs): + channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) + if not channel_partner_sudo.env['mail.channel.partner'].search([('channel_id', '=', int(channel_id)), ('partner_id', '=', int(partner_id))], limit=1): + raise NotFound() + return channel_partner_sudo.env['ir.http']._content_image(model='res.partner', res_id=int(partner_id), field='avatar_128') + + @http.route('/mail/channel//guest//avatar_128', methods=['GET'], type='http', auth='public') + def mail_channel_guest_avatar_128(self, channel_id, guest_id, **kwargs): + channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) + if not channel_partner_sudo.env['mail.channel.partner'].search([('channel_id', '=', int(channel_id)), ('guest_id', '=', int(guest_id))], limit=1): + raise NotFound() + return channel_partner_sudo.env['ir.http']._content_image(model='mail.guest', res_id=int(guest_id), field='avatar_128') + + @http.route('/mail/channel//attachment/', methods=['GET'], type='http', auth='public') + def mail_channel_attachment(self, channel_id, attachment_id, **kwargs): + channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) + if not channel_partner_sudo.env['ir.attachment'].search([('id', '=', int(attachment_id)), ('res_id', '=', int(channel_id)), ('res_model', '=', 'mail.channel')], limit=1): + raise NotFound() + return channel_partner_sudo.env['ir.http']._get_content_common(res_id=int(attachment_id), download=True) + + @http.route('/mail/channel//image//x', methods=['GET'], type='http', auth='public') + def fetch_image(self, channel_id, attachment_id, width, height, **kwargs): + channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) + if not channel_partner_sudo.env['ir.attachment'].search([('id', '=', int(attachment_id)), ('res_id', '=', int(channel_id)), ('res_model', '=', 'mail.channel')], limit=1): + raise NotFound() + return channel_partner_sudo.env['ir.http']._content_image(res_id=int(attachment_id), height=int(height), width=int(width)) + + # -------------------------------------------------------------------------- + # Client Initialization + # -------------------------------------------------------------------------- + + @http.route('/mail/init_messaging', methods=['POST'], type='json', auth='public') + def mail_init_messaging(self, **kwargs): + if request.session.uid: + return request.env.user._init_messaging() + guest = request.env['mail.guest']._get_guest_from_request(request) + if guest: + return guest.sudo()._init_messaging() + raise NotFound() + + # -------------------------------------------------------------------------- + # 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) + + @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) + + @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) + + # -------------------------------------------------------------------------- + # Thread API (channel/chatter common) + # -------------------------------------------------------------------------- + + @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 == 'mail.channel': + channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(thread_id)) + thread = channel_partner_sudo.channel_id + else: + thread = request.env[thread_model].browse(int(thread_id)).exists() + allowed_params = {'attachment_ids', 'body', 'message_type', 'partner_ids', 'subtype_xmlid'} + return thread.message_post(**{key: value for key, value in post_data.items() if key in allowed_params}).message_format()[0] + + @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_partner = request.env['mail.channel.partner'] + if thread_model == 'mail.channel': + channel_partner = request.env['mail.channel.partner']._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_partner.env.user.share: + # Only generate the access token if absolutely necessary (= not for internal user). + vals['access_token'] = channel_partner.env['ir.attachment']._generate_access_token() + attachment = channel_partner.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 + return request.make_response( + data=json.dumps(attachmentData), + headers=[('Content-Type', 'application/json')] + ) + + @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: + raise NotFound() + if not request.env.user.share: + # Check through standard access rights/rules for internal users. + return attachment_sudo.sudo(False).unlink() + # 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). + message_sudo = request.env['mail.message'].sudo().search([('attachment_ids', 'in', attachment_sudo.ids)], limit=1) + if message_sudo: + if request.session.uid and (not message_sudo.author_id or message_sudo.author_id != request.env.user.partner_id): + raise NotFound() + if not request.session.uid and (not message_sudo.author_guest_id or message_sudo.author_guest_id != request.env['mail.guest']._get_guest_from_request(request)): + 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() + return attachment_sudo.unlink() + + # -------------------------------------------------------------------------- + # Channel API + # -------------------------------------------------------------------------- + + @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_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) + return channel_partner_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) + + @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_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) + return channel_partner_sudo.channel_id._channel_seen(int(last_message_id)) + + # -------------------------------------------------------------------------- + # Chatter API + # -------------------------------------------------------------------------- + + @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): + return 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) + + @http.route('/mail/read_followers', methods=['POST'], type='json', auth='user') def read_followers(self, res_model, res_id): request.env['mail.followers'].check_access_rights("read") request.env[res_model].check_access_rights("read") @@ -40,7 +247,7 @@ class DiscussController(http.Controller): 'subtypes': self.read_subscription_data(follower_id) if follower_id else None } - @http.route('/mail/read_subscription_data', type='json', auth='user') + @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 @@ -69,36 +276,7 @@ class DiscussController(http.Controller): return sorted(subtypes_list, key=lambda it: (it['parent_model'] or '', it['res_model'] or '', it['internal'], it['sequence'])) - @http.route('/mail///avatar/', type='http', auth='public') - def avatar(self, res_model, res_id, partner_id): - headers = [('Content-Type', 'image/png')] - status = 200 - content = 'R0lGODlhAQABAIABAP///wAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' # default image is one white pixel - if res_model in request.env: - try: - # if the current user has access to the document, get the partner avatar as sudo() - request.env[res_model].browse(res_id).check_access_rule('read') - if partner_id in request.env[res_model].browse(res_id).sudo().exists().message_ids.mapped('author_id').ids: - status, headers, _content = request.env['ir.http'].sudo().binary_content( - model='res.partner', id=partner_id, field='avatar_128', default_mimetype='image/png') - # binary content return an empty string and not a placeholder if obj[field] is False - if _content != '': - content = _content - if status == 304: - return werkzeug.wrappers.Response(status=304) - except AccessError: - pass - image_base64 = base64.b64decode(content) - headers.append(('Content-Length', len(image_base64))) - response = request.make_response(image_base64, headers) - response.status = str(status) - return response - - @http.route('/mail/init_messaging', type='json', auth='user') - def mail_init_messaging(self): - return request.env.user._init_messaging() - - @http.route('/mail/get_suggested_recipients', type='json', auth='user') + @http.route('/mail/get_suggested_recipients', methods=['POST'], type='json', auth='user') def message_get_suggested_recipients(self, model, res_ids): records = request.env[model].browse(res_ids) try: diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py index e17d42bcaf6..616d82f72f0 100644 --- a/addons/mail/models/__init__.py +++ b/addons/mail/models/__init__.py @@ -29,6 +29,7 @@ from . import mail_template # discuss from . import mail_channel_partner from . import mail_channel +from . import mail_guest from . import mail_shortcode from . import res_users_settings diff --git a/addons/mail/models/ir_http.py b/addons/mail/models/ir_http.py index 400b8dee7d8..1acb2498de6 100644 --- a/addons/mail/models/ir_http.py +++ b/addons/mail/models/ir_http.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import odoo from odoo import models +from odoo.addons.web.controllers.main import HomeStaticTemplateHelpers from odoo.http import request @@ -13,4 +15,17 @@ class IrHttp(models.AbstractModel): result = super(IrHttp, self).session_info() if self.env.user.has_group('base.group_user'): result['notification_type'] = user.notification_type + assets_discuss_public_hash = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle='mail.assets_discuss_public') + result['cache_hashes']['assets_discuss_public'] = assets_discuss_public_hash + guest = self.env.context.get('guest') + if guest: + user_context = {'lang': guest.lang} + mods = odoo.conf.server_wide_modules or [] + lang = user_context.get("lang") + translation_hash = request.env['ir.translation'].sudo().get_web_translations_hash(mods, lang) + result['cache_hashes']['translations'] = translation_hash + result.update({ + 'name': guest.name, + 'user_context': user_context, + }) return result diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index c18bb4418cd..0fe82b90954 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -4,7 +4,7 @@ import base64 import logging from hashlib import sha512 -from uuid import uuid4 +from secrets import choice from odoo import _, api, fields, models, tools, Command from odoo.addons.base.models.avatar_mixin import get_hsl_from_seed @@ -42,6 +42,12 @@ class Channel(models.Model): res['alias_contact'] = 'everyone' if res.get('public', 'private') == 'public' else 'followers' return res + @api.model + def _generate_random_token(self): + # Built to be shared on invitation link. It uses non-ambiguous characters and it is of a + # reasonable length: enough to avoid brute force, but short enough to be shareable easily. + return ''.join(choice('abcdefghijkmnopqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ23456789') for _i in range(10)) + # description name = fields.Char('Name', required=True, translate=True) active = fields.Boolean(default=True, help="Set active to false to hide the channel without removing it.") @@ -63,13 +69,14 @@ class Channel(models.Model): 'mail.channel.partner', 'channel_id', string='Last Seen', groups='base.group_user') is_member = fields.Boolean('Is Member', compute='_compute_is_member', compute_sudo=True) + member_count = fields.Integer(string="Member Count", compute='_compute_member_count', help="Excluding guests from count.") group_ids = fields.Many2many( 'res.groups', string='Auto Subscription', help="Members of those groups will automatically added as followers. " "Note that they will be able to manage their subscription manually " "if necessary.") # access - uuid = fields.Char('UUID', size=50, index=True, default=lambda self: str(uuid4()), copy=False) + uuid = fields.Char('UUID', size=50, index=True, default=_generate_random_token, copy=False) public = fields.Selection([ ('public', 'Everyone'), ('private', 'Invited people only'), @@ -136,6 +143,11 @@ class Channel(models.Model): for channel in self: channel.is_member = self.env.user.partner_id in channel.channel_partner_ids + @api.depends('channel_partner_ids') + def _compute_member_count(self): + for channel in self: + channel.member_count = len(channel.with_context(active_test=False).channel_partner_ids) + # ONCHANGE @api.onchange('public') @@ -269,17 +281,18 @@ class Channel(models.Model): 'type': 'mail.channel_update', 'payload': { 'id': self.id, - 'memberCount': len(self.channel_partner_ids), + 'memberCount': self.member_count, 'members': [('insert-and-unlink', {'id': partner.id})], }, }) return result - def add_members(self, partner_ids): - """ Adds the given partner_ids as member of self channels. """ + def add_members(self, partner_ids=None, guest_ids=None): + """ Adds the given partner_ids and guest_ids as member of self channels. """ self.check_access_rights('write') self.check_access_rule('write') - partners = self.env['res.partner'].browse(partner_ids) + partners = self.env['res.partner'].browse(partner_ids or []) + guests = self.env['mail.guest'].browse(guest_ids or []) members_to_create = [] for channel in self: if channel.public == 'groups': @@ -291,13 +304,18 @@ class Channel(models.Model): group_name=channel.group_public_id.name, partner_names=', '.join(partner.name for partner in invalid_partners) )) - existing_partners = self.env['res.partner'].search([('id', 'in', partner_ids), ('channel_ids', 'in', channel.id)]) + existing_partners = self.env['res.partner'].search([('id', 'in', partners.ids), ('channel_ids', 'in', channel.id)]) members_to_create += [{ 'partner_id': partner.id, 'channel_id': channel.id, } for partner in partners - existing_partners] + existing_guests = self.env['mail.guest'].search([('id', 'in', guests.ids), ('channel_ids', 'in', channel.id)]) + members_to_create += [{ + 'guest_id': partner.id, + 'channel_id': channel.id, + } for partner in guests - existing_guests] new_members = self.env['mail.channel.partner'].sudo().create(members_to_create) - for channel_partner in new_members: + for channel_partner in new_members.filtered(lambda channel_partner: channel_partner.partner_id): user = channel_partner.partner_id.user_ids[0] if channel_partner.partner_id.user_ids else self.env['res.users'] # notify invited members through the bus if user: @@ -322,7 +340,7 @@ class Channel(models.Model): 'type': 'mail.channel_update', 'payload': { 'id': channel_partner.channel_id.id, - 'memberCount': len(channel_partner.channel_id.channel_partner_ids), + 'memberCount': channel_partner.channel_id.member_count, 'members': [('insert', { 'id': channel_partner.partner_id.id, 'im_status': channel_partner.partner_id.im_status, @@ -421,7 +439,7 @@ class Channel(models.Model): AND partner.id = ANY(%s) AND partner.id != ANY(%s)""" self.env.cr.execute( sql_query, - (email_from, list(pids), [author_id] if author_id else [], ) + (email_from or '', list(pids), [author_id] if author_id else [], ) ) for partner_id, partner_share, notif in self._cr.fetchall(): # ocn_client: will add partners to recipient recipient_data. more ocn notifications. We neeed to filter them maybe @@ -597,12 +615,11 @@ class Channel(models.Model): } if extra_info: info['info'] = extra_info - # add last message preview (only used in mobile) info['last_message_id'] = channel_last_message_ids.get(channel.id, False) # listeners of the channel channel_partners = channel.channel_last_seen_partner_ids - info['memberCount'] = len(channel_partners) + info['memberCount'] = channel.member_count # find the channel partner state, if logged user if self.env.user and self.env.user.partner_id: info['message_needaction_counter'] = channel.message_needaction_counter @@ -632,7 +649,7 @@ class Channel(models.Model): channel_infos.append(info) return channel_infos - def channel_fetch_message(self, last_id=False, limit=20): + def _channel_fetch_message(self, last_id=False, limit=20): """ Return message values of the current channel. :param last_id : last message id to start the research :param limit : maximum number of messages to fetch @@ -643,7 +660,7 @@ class Channel(models.Model): domain = ["&", ("model", "=", "mail.channel"), ("res_id", "in", self.ids)] if last_id: domain.append(("id", "<", last_id)) - return self.env['mail.message'].message_fetch(domain=domain, limit=limit) + return self.env['mail.message']._message_fetch(domain=domain, limit=limit) # User methods @api.model @@ -759,7 +776,7 @@ class Channel(models.Model): if channel_partners: channel_partners.write({'is_pinned': pinned}) - def channel_seen(self, last_message_id=None): + def _channel_seen(self, last_message_id=None): """ Mark channel as seen by updating seen message id of the current logged partner :param last_message_id: the id of the message to be marked as seen, last message of the @@ -773,9 +790,7 @@ class Channel(models.Model): last_message = self.env['mail.message'].search(domain, order="id DESC", limit=1) if not last_message: return - self._set_last_seen_message(last_message) - data = { 'info': 'channel_seen', 'last_message_id': last_message.id, @@ -801,6 +816,7 @@ class Channel(models.Model): [('seen_message_id', '<', last_message.id)] ]) ]) + channel_partner_domain = expression.AND([channel_partner_domain, [('partner_id', '=', self.env.user.partner_id.id)]]) channel_partner = self.env['mail.channel.partner'].search(channel_partner_domain) channel_partner.write({ 'fetched_message_id': last_message.id, diff --git a/addons/mail/models/mail_channel_partner.py b/addons/mail/models/mail_channel_partner.py index cba802513e8..b12024ef771 100644 --- a/addons/mail/models/mail_channel_partner.py +++ b/addons/mail/models/mail_channel_partner.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from werkzeug.exceptions import NotFound + from odoo import api, fields, models, _ from odoo.exceptions import AccessError @@ -12,10 +14,11 @@ class ChannelPartner(models.Model): _rec_name = 'partner_id' # identity - partner_id = fields.Many2one('res.partner', string='Recipient', ondelete='cascade', required=True) + partner_id = fields.Many2one('res.partner', string='Recipient', ondelete='cascade', readonly=True, index=True) + guest_id = fields.Many2one(string="Guest", comodel_name='mail.guest', ondelete='cascade', readonly=True, index=True) partner_email = fields.Char('Email', related='partner_id.email', readonly=False) # channel - channel_id = fields.Many2one('mail.channel', string='Channel', ondelete='cascade', required=True) + channel_id = fields.Many2one('mail.channel', string='Channel', ondelete='cascade', readonly=True, required=True) # state custom_channel_name = fields.Char('Custom channel name') fetched_message_id = fields.Many2one('mail.message', string='Last Fetched') @@ -25,6 +28,14 @@ class ChannelPartner(models.Model): is_pinned = fields.Boolean("Is pinned on the interface", default=True) last_interest_dt = fields.Datetime("Last Interest", default=fields.Datetime.now, help="Contains the date and time of the last interesting event that happened in this channel for this partner. This includes: creating, joining, pinning, and new message posted.") + def init(self): + self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS mail_channel_partner_partner_unique ON %s (channel_id, partner_id) WHERE partner_id IS NOT NULL" % self._table) + self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS mail_channel_partner_guest_unique ON %s (channel_id, guest_id) WHERE guest_id IS NOT NULL" % self._table) + + _sql_constraints = [ + ("partner_or_guest_exists", "CHECK((partner_id IS NOT NULL AND guest_id IS NULL) OR (partner_id IS NULL AND guest_id IS NOT NULL))", "A channel member must be a partner or a guest."), + ] + @api.model_create_multi def create(self, vals_list): """Similar access rule as the access rule of the mail channel. @@ -42,7 +53,35 @@ class ChannelPartner(models.Model): return super(ChannelPartner, self).create(vals_list) def write(self, vals): - if not self.env.is_admin(): - if {'channel_id', 'partner_id', 'partner_email'} & set(vals): - raise AccessError(_('You can not write on this field')) + for channel_partner in self: + for field_name in {'channel_id', 'partner_id', 'guest_id'}: + if field_name in vals and vals[field_name] != channel_partner[field_name].id: + raise AccessError(_('You can not write on %(field_name)s.', field_name=field_name)) return super(ChannelPartner, self).write(vals) + + @api.model + def _get_as_sudo_from_request_or_raise(self, request, channel_id): + channel_partner = self._get_as_sudo_from_request(request=request, channel_id=channel_id) + if not channel_partner: + raise NotFound() + return channel_partner + + @api.model + def _get_as_sudo_from_request(self, request, channel_id): + """ Seeks a channel partner matching the provided `channel_id` and the + current user or guest. + + :param channel_id: The id of the channel of which the user/guest is + expected to be member. + :type channel_id: int + :return: A record set containing the channel partner if found, or an + empty record set otherwise. In case of guest, the record is returned + with the 'guest' record in the context. + :rtype: mail.channel.partner + """ + if request.session.uid: + return self.env['mail.channel.partner'].sudo().search([('channel_id', '=', channel_id), ('partner_id', '=', self.env.user.partner_id.id)], limit=1) + guest = self.env['mail.guest']._get_guest_from_request(request) + if guest: + return guest.env['mail.channel.partner'].sudo().search([('channel_id', '=', channel_id), ('guest_id', '=', guest.id)], limit=1) + return self.env['mail.channel.partner'] diff --git a/addons/mail/models/mail_guest.py b/addons/mail/models/mail_guest.py new file mode 100644 index 00000000000..0f83bf121cc --- /dev/null +++ b/addons/mail/models/mail_guest.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import pytz +import uuid + +from odoo.tools import consteq +from odoo import api, fields, models +from odoo.addons.base.models.res_partner import _tz_get + + +class MailGuest(models.Model): + _name = 'mail.guest' + _description = "Guest" + _inherit = ['avatar.mixin'] + _avatar_name_field = "name" + + @api.model + def _lang_get(self): + return self.env['res.lang'].get_installed() + + name = fields.Char(string="Name", required=True) + access_token = fields.Char(string="Access Token", default=lambda self: str(uuid.uuid4()), groups='base.group_system', required=True, readonly=True, copy=False) + country_id = fields.Many2one(string="Country", comodel_name='res.country') + lang = fields.Selection(string="Language", selection=_lang_get) + timezone = fields.Selection(string="Timezone", selection=_tz_get) + channel_ids = fields.Many2many(string="Channels", comodel_name='mail.channel', relation='mail_channel_partner', column1='guest_id', column2='channel_id', copy=False) + + def _get_guest_from_request(self, request): + guest_id = request.httprequest.cookies.get('mail.guest_id') + guest_access_token = request.httprequest.cookies.get('mail.guest_access_token') + if not guest_id or not guest_access_token: + return self.env['mail.guest'] + guest = self.env['mail.guest'].browse(int(guest_id)).sudo().exists() + if not guest or not guest.access_token or not consteq(guest.access_token, guest_access_token): + return self.env['mail.guest'] + if not guest.timezone: + timezone = self._get_timezone_from_request(request) + if timezone: + guest._update_timezone(timezone) + return guest.sudo(False).with_context(guest=guest) + + def _get_timezone_from_request(self, request): + timezone = request.httprequest.cookies.get('tz') + return timezone if timezone in pytz.all_timezones else False + + def _update_timezone(self, timezone): + query = """ + UPDATE mail_guest + SET timezone = %s + WHERE id IN ( + SELECT id FROM mail_guest WHERE id = %s + FOR NO KEY UPDATE SKIP LOCKED + ) + """ + self.env.cr.execute(query, (timezone, self.id)) + + def _init_messaging(self): + self.ensure_one() + partner_root = self.env.ref('base.partner_root') + return { + 'channels': self.channel_ids.channel_info(), + 'currentGuest': { + 'id': self.id, + 'name': self.name, + }, + 'current_partner': False, + 'current_user_id': False, + 'current_user_settings': False, + 'mail_failures': [], + 'menu_id': False, + 'needaction_inbox_counter': False, + 'partner_root': { + 'id': partner_root.id, + 'name': partner_root.name, + }, + 'public_partners': [], + 'shortcodes': [], + 'starred_counter': False, + } diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index da8fd308120..67e12c255bb 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -124,6 +124,7 @@ class Message(models.Model): 'res.partner', 'Author', index=True, ondelete='set null', help="Author of the message. If not set, email_from may hold an email address that did not match any partner.") author_avatar = fields.Binary("Author's avatar", related='author_id.avatar_128', depends=['author_id'], readonly=False) + author_guest_id = fields.Many2one(string="Guest", comodel_name='mail.guest') # recipients: include inactive partners (they may have been archived after # the message was sent, but they should remain visible in the relation) partner_ids = fields.Many2many('res.partner', string='Recipients', context={'active_test': False}) @@ -790,8 +791,14 @@ class Message(models.Model): else: record_name = False + if message_sudo.author_guest_id: + vals['guestAuthor'] = [('insert', { + 'id': message_sudo.author_guest_id.id, + 'name': message_sudo.author_guest_id.name, + })] + else: + vals['author_id'] = author vals.update({ - 'author_id': author, 'notifications': message_sudo.notification_ids._filtered_for_web_client()._notification_format(), 'attachment_ids': attachments_formatted, 'tracking_value_ids': tracking_value_ids, @@ -801,12 +808,18 @@ class Message(models.Model): return vals_list @api.model - def message_fetch(self, domain, limit=20): + def _message_fetch(self, domain, max_id=None, min_id=None, limit=30): """ Get a limited amount of formatted messages with provided domain. :param domain: the domain to filter messages; + :param min_id: messages must be more recent than this id + :param max_id: message must be less recent than this id :param limit: the maximum amount of messages to get; :returns list(dict). """ + if max_id: + domain = expression.AND([domain, [('id', '<', max_id)]]) + if min_id: + domain = expression.AND([domain, [('id', '>', min_id)]]) return self.search(domain, limit=limit).message_format() def message_format(self): diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 52d4cc129e3..3d0fef2b8cd 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -13,7 +13,6 @@ import lxml import logging import pytz import re -import socket import time import threading @@ -28,7 +27,6 @@ from odoo import _, api, exceptions, fields, models, tools, registry, SUPERUSER_ from odoo.exceptions import MissingError from odoo.osv import expression -from odoo.tools import ustr from odoo.tools.misc import clean_context, split_every _logger = logging.getLogger(__name__) @@ -1804,7 +1802,12 @@ class MailThread(models.AbstractModel): record_name = record_name or self.display_name # Find the message's author - author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=True) + if 'guest' in self.env.context: + author_guest_id = self.env.context['guest'].id + author_id, email_from = False, False + else: + author_guest_id = False + author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=True) if subtype_xmlid: subtype_id = self.env['ir.model.data']._xmlid_to_res_id(subtype_xmlid) @@ -1836,6 +1839,7 @@ class MailThread(models.AbstractModel): values = dict(msg_kwargs) values.update({ 'author_id': author_id, + 'author_guest_id': author_guest_id, 'email_from': email_from, 'model': self._name, 'res_id': self.id, diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index c9247554794..dd39f5326ba 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -116,6 +116,8 @@ class Partner(models.Model): "user_id": main_user.id, "is_internal_user": not partner.partner_share, } + if 'guest' in self.env.context: + partners_format[partner].pop('email') return partners_format def _message_fetch_failed(self): diff --git a/addons/mail/models/res_users.py b/addons/mail/models/res_users.py index c8119d52cf8..98c68a2c293 100644 --- a/addons/mail/models/res_users.py +++ b/addons/mail/models/res_users.py @@ -94,6 +94,7 @@ class Users(models.Model): partner_root = self.env.ref('base.partner_root') values = { 'channels': self.partner_id._get_channels_as_member().channel_info(), + 'currentGuest': False, 'current_partner': self.partner_id.mail_partner_format().get(self.partner_id), 'current_user_id': self.id, 'current_user_settings': self.env['res.users.settings']._find_or_create_for_user(self)._res_users_settings_format(), diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv index 4708e0c2a0f..3ae8196eafb 100644 --- a/addons/mail/security/ir.model.access.csv +++ b/addons/mail/security/ir.model.access.csv @@ -45,6 +45,9 @@ access_mail_resend_message,access.mail.resend.message,model_mail_resend_message, access_mail_resend_partner,access.mail.resend.partner,model_mail_resend_partner,base.group_user,1,1,1,0 access_mail_template_preview,access.mail.template.preview,model_mail_template_preview,base.group_user,1,1,1,0 access_mail_blacklist_remove_system,acesss.mail.blacklist.remove.system,model_mail_blacklist_remove,base.group_system,1,1,1,1 +access_mail_guest_all,mail.guest,model_mail_guest,,0,0,0,0 +access_mail_guest_user,mail.guest,model_mail_guest,base.group_user,1,0,0,0 +access_mail_guest_system,mail.guest,model_mail_guest,base.group_system,1,1,1,1 access_res_users_settings_all,res.users.settings,model_res_users_settings,,0,0,0,0 access_res_users_settings_user,res.users.settings,model_res_users_settings,base.group_user,1,1,1,1 ir_actions_report_access_user,ir.actions.report.access.user,base.model_ir_actions_report,base.group_user,1,0,0,0 diff --git a/addons/mail/static/src/components/activity/activity.xml b/addons/mail/static/src/components/activity/activity.xml index 583a3e93b3d..116e918fa47 100644 --- a/addons/mail/static/src/components/activity/activity.xml +++ b/addons/mail/static/src/components/activity/activity.xml @@ -131,8 +131,7 @@ diff --git a/addons/mail/static/src/components/attachment/attachment.js b/addons/mail/static/src/components/attachment/attachment.js index d5aa575dde6..229476c426e 100644 --- a/addons/mail/static/src/components/attachment/attachment.js +++ b/addons/mail/static/src/components/attachment/attachment.js @@ -72,22 +72,20 @@ export class Attachment extends Component { // be fetched at all. return ''; } - let size; + let url; + // The size of background-image depends on the props.imageSize + // to sync with width and height of `.o_Attachment_image`. if (this.detailsMode === 'card') { - size = '38x38'; - } else { - // The size of background-image depends on the props.imageSize - // to sync with width and height of `.o_Attachment_image`. - if (this.props.imageSize === "large") { - size = '400x400'; - } else if (this.props.imageSize === "medium") { - size = '200x200'; - } else if (this.props.imageSize === "small") { - size = '100x100'; - } + url = this.attachment.imageTinyUrl; + } else if (this.props.imageSize === "large") { + url = this.attachment.imageLargeUrl; + } else if (this.props.imageSize === "medium") { + url = this.attachment.imageMediumUrl; + } else if (this.props.imageSize === "small") { + url = this.attachment.imageSmallUrl; } // background-size set to override value from `o_image` which makes small image stretched - return `background-image:url(/web/image/${this.attachment.id}/${size}); background-size: auto;`; + return `background-image:url(${url}); background-size: auto;`; } //-------------------------------------------------------------------------- @@ -102,7 +100,7 @@ export class Attachment extends Component { */ _onClickDownload(ev) { ev.stopPropagation(); - this.env.services.navigate(`/web/content/ir.attachment/${this.attachment.id}/datas`, { download: true }); + this.env.services.navigate(this.attachment.downloadUrl, { download: true }); } /** diff --git a/addons/mail/static/src/components/attachment/tests/attachment_tests.js b/addons/mail/static/src/components/attachment/tests/attachment_tests.js index 54edcc91b0c..d99566e61fb 100644 --- a/addons/mail/static/src/components/attachment/tests/attachment_tests.js +++ b/addons/mail/static/src/components/attachment/tests/attachment_tests.js @@ -595,9 +595,8 @@ QUnit.test('clicking on the delete attachment button multiple times should do th assert.expect(2); await this.start({ async mockRPC(route, args) { - if (args.method === "unlink" && args.model === "ir.attachment") { - assert.step('attachment_unlink'); - return; + if (route === '/mail/attachment/delete') { + assert.step('attachment_delete'); } return this._super(...arguments); }, @@ -621,7 +620,7 @@ QUnit.test('clicking on the delete attachment button multiple times should do th document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click(); }); assert.verifySteps( - ['attachment_unlink'], + ['attachment_delete'], "The unlink method must be called once" ); }); diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.xml b/addons/mail/static/src/components/attachment_box/attachment_box.xml index 9cd3e713401..e6575b5486d 100644 --- a/addons/mail/static/src/components/attachment_box/attachment_box.xml +++ b/addons/mail/static/src/components/attachment_box/attachment_box.xml @@ -38,8 +38,7 @@ diff --git a/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.xml b/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.xml index dc6fdb9b6d3..b370b990648 100644 --- a/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.xml +++ b/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.xml @@ -56,6 +56,12 @@ + +

Invitation Link

+
+ +
+
diff --git a/addons/mail/static/src/components/channel_member_list/channel_member_list.xml b/addons/mail/static/src/components/channel_member_list/channel_member_list.xml index 5e37fafe0df..48fd8b66800 100644 --- a/addons/mail/static/src/components/channel_member_list/channel_member_list.xml +++ b/addons/mail/static/src/components/channel_member_list/channel_member_list.xml @@ -34,7 +34,8 @@
- Avatar + Avatar + + +
+ +
+
diff --git a/addons/mail/static/src/components/thread_preview/tests/thread_preview_tests.js b/addons/mail/static/src/components/thread_preview/tests/thread_preview_tests.js index 2713bdf8567..93d339a57ad 100644 --- a/addons/mail/static/src/components/thread_preview/tests/thread_preview_tests.js +++ b/addons/mail/static/src/components/thread_preview/tests/thread_preview_tests.js @@ -50,8 +50,8 @@ QUnit.test('mark as read', async function (assert) { await this.start({ hasChatWindow: true, async mockRPC(route, args) { - if (route.includes('channel_seen')) { - assert.step('channel_seen'); + if (route.includes('set_last_seen_message')) { + assert.step('set_last_seen_message'); } return this._super(...arguments); }, @@ -76,7 +76,7 @@ QUnit.test('mark as read', async function (assert) { document.querySelector('.o_ThreadPreview_markAsRead').click() ); assert.verifySteps( - ['channel_seen'], + ['set_last_seen_message'], "should have marked the thread as seen" ); assert.hasClass( diff --git a/addons/mail/static/src/components/thread_view/tests/thread_view_tests.js b/addons/mail/static/src/components/thread_view/tests/thread_view_tests.js index 02a7376d0b7..83cd21d6259 100644 --- a/addons/mail/static/src/components/thread_view/tests/thread_view_tests.js +++ b/addons/mail/static/src/components/thread_view/tests/thread_view_tests.js @@ -291,7 +291,7 @@ QUnit.test('message list asc order', async function (assert) { }); QUnit.test('mark channel as fetched when a new message is loaded and as seen when focusing composer [REQUIRE FOCUS]', async function (assert) { - assert.expect(8); + assert.expect(7); this.data['res.partner'].records.push({ email: "fred@example.com", @@ -322,18 +322,13 @@ QUnit.test('mark channel as fetched when a new message is loaded and as seen whe 'channel_fetched is called on the right channel model' ); assert.step('rpc:channel_fetch'); - } else if (args.method === 'channel_seen') { + } else if (route === '/mail/channel/set_last_seen_message') { assert.strictEqual( - args.args[0][0], + args.channel_id, 100, - 'channel_seen is called on the right channel id' + 'set_last_seen_message is called on the right channel id' ); - assert.strictEqual( - args.model, - 'mail.channel', - 'channel_seeb is called on the right channel model' - ); - assert.step('rpc:channel_seen'); + assert.step('rpc:set_last_seen_message'); } return this._super(...arguments); } @@ -374,13 +369,13 @@ QUnit.test('mark channel as fetched when a new message is loaded and as seen whe }, })); assert.verifySteps( - ['rpc:channel_seen'], + ['rpc:set_last_seen_message'], "Channel should have been marked as seen after threadView got the focus" ); }); QUnit.test('mark channel as fetched and seen when a new message is loaded if composer is focused [REQUIRE FOCUS]', async function (assert) { - assert.expect(4); + assert.expect(3); this.data['res.partner'].records.push({ id: 10, @@ -396,18 +391,13 @@ QUnit.test('mark channel as fetched and seen when a new message is loaded if com mockRPC(route, args) { if (args.method === 'channel_fetched' && args.args[0] === 100) { throw new Error("'channel_fetched' RPC must not be called for created channel as message is directly seen"); - } else if (args.method === 'channel_seen') { + } else if (route === '/mail/channel/set_last_seen_message') { assert.strictEqual( - args.args[0][0], + args.channel_id, 100, - 'channel_seen is called on the right channel id' + 'set_last_seen_message is called on the right channel id' ); - assert.strictEqual( - args.model, - 'mail.channel', - 'channel_seen is called on the right channel model' - ); - assert.step('rpc:channel_seen'); + assert.step('rpc:set_last_seen_message'); } return this._super(...arguments); } @@ -444,7 +434,7 @@ QUnit.test('mark channel as fetched and seen when a new message is loaded if com }, }); assert.verifySteps( - ['rpc:channel_seen'], + ['rpc:set_last_seen_message'], "Channel should have been mark as seen directly" ); }); @@ -526,7 +516,7 @@ QUnit.test('do not show message subject when subject is the same as the thread n }); QUnit.test('[technical] new messages separator on posting message', async function (assert) { - // technical as we need to remove focus from text input to avoid `channel_seen` call + // technical as we need to remove focus from text input to avoid `set_last_seen_message` call assert.expect(4); this.data['mail.channel'].records = [{ @@ -568,7 +558,7 @@ QUnit.test('[technical] new messages separator on posting message', async functi document.querySelector('.o_ComposerTextInput_textarea').focus(); await afterNextRender(() => document.execCommand('insertText', false, "hey !")); await afterNextRender(() => { - // need to remove focus from text area to avoid channel_seen + // need to remove focus from text area to avoid set_last_seen_message document.querySelector('.o_Composer_buttonSend').focus(); document.querySelector('.o_Composer_buttonSend').click(); @@ -1697,7 +1687,7 @@ QUnit.test('message with subtype should be displayed (and not considered as empt QUnit.test('[technical] message list with a full page of empty messages should show load more if there are other messages', async function (assert) { // Technical assumptions : - // - message_fetch fetching exactly 30 messages, + // - /mail/channel/messages fetching exactly 30 messages, // - empty messages not being displayed // - auto-load more being triggered on scroll, not automatically when the 30 first messages are empty assert.expect(2); @@ -1754,7 +1744,7 @@ QUnit.test('[technical] message list with a full page of empty messages should s QUnit.test('first unseen message should be directly preceded by the new message separator if there is a transient message just before it while composer is not focused [REQUIRE FOCUS]', async function (assert) { // The goal of removing the focus is to ensure the thread is not marked as seen automatically. - // Indeed that would trigger channel_seen no matter what, which is already covered by other tests. + // Indeed that would trigger set_last_seen_message no matter what, which is already covered by other tests. // The goal of this test is to cover the conditions specific to transient messages, // and the conditions from focus would otherwise shadow them. assert.expect(3); @@ -1863,7 +1853,7 @@ QUnit.test('failure on loading messages should display error', async function (a }); await this.start({ async mockRPC(route, args) { - if (args.method === 'message_fetch') { + if (route === '/mail/channel/messages') { throw new Error(); } return this._super(...arguments); @@ -1897,7 +1887,7 @@ QUnit.test('failure on loading messages should prompt retry button', async funct }); await this.start({ async mockRPC(route, args) { - if (args.method === 'message_fetch') { + if (route === '/mail/channel/messages') { throw new Error(); } return this._super(...arguments); @@ -1943,7 +1933,7 @@ QUnit.test('failure on loading more messages should not alter message list displ })); await this.start({ async mockRPC(route, args) { - if (args.method === 'message_fetch') { + if (route === '/mail/channel/messages') { if (messageFetchShouldFail) { throw new Error(); } @@ -1994,7 +1984,7 @@ QUnit.test('failure on loading more messages should display error and prompt ret })); await this.start({ async mockRPC(route, args) { - if (args.method === 'message_fetch') { + if (route === '/mail/channel/messages') { if (messageFetchShouldFail) { throw new Error(); } @@ -2054,7 +2044,7 @@ QUnit.test('Retry loading more messages on failed load more messages should load }); await this.start({ async mockRPC(route, args) { - if (args.method === 'message_fetch') { + if (route === '/mail/channel/messages') { if (messageFetchShouldFail) { throw new Error(); } diff --git a/addons/mail/static/src/components/thread_view/thread_view.xml b/addons/mail/static/src/components/thread_view/thread_view.xml index 352090e7bd8..12bad72b27c 100644 --- a/addons/mail/static/src/components/thread_view/thread_view.xml +++ b/addons/mail/static/src/components/thread_view/thread_view.xml @@ -2,7 +2,7 @@ -
+
diff --git a/addons/mail/static/src/model/model_field.js b/addons/mail/static/src/model/model_field.js index 7eb61e563ea..e563e8ebe45 100644 --- a/addons/mail/static/src/model/model_field.js +++ b/addons/mail/static/src/model/model_field.js @@ -839,6 +839,9 @@ export class ModelField { * @throws {Error} if record does not satisfy related model */ _verifyRelationalValue(record) { + if (!record.modelManager) { + throw Error(`${record} is not a record. Did you try to use link() instead of insert() with data?`); + } const OtherModel = record.modelManager.models[this.to]; if (!OtherModel.get(record.localId, { isCheckingInheritance: true })) { throw Error(`Record ${record.localId} is not valid for relational field ${this.fieldName}.`); diff --git a/addons/mail/static/src/model/model_manager.js b/addons/mail/static/src/model/model_manager.js index 751bdd50c15..750828fc4c0 100644 --- a/addons/mail/static/src/model/model_manager.js +++ b/addons/mail/static/src/model/model_manager.js @@ -1158,10 +1158,10 @@ export class ModelManager { if (!field) { throw new Error(`Cannot create/update record with data unrelated to a field. (record: "${record.localId}", non-field attempted update: "${fieldName}")`); } - if (field.readonly && !allowWriteReadonly) { + const newVal = data[fieldName]; + if (field.readonly && !allowWriteReadonly && record[fieldName] !== newVal) { throw new Error(`Can't update "${field.fieldName}" (record: "${record.localId}") because it's readonly.`); } - const newVal = data[fieldName]; if (!field.parseAndExecuteCommands(record, newVal, options)) { continue; } diff --git a/addons/mail/static/src/models/attachment/attachment.js b/addons/mail/static/src/models/attachment/attachment.js index 6a050347d0e..c1912dd4edb 100644 --- a/addons/mail/static/src/models/attachment/attachment.js +++ b/addons/mail/static/src/models/attachment/attachment.js @@ -103,9 +103,11 @@ function factory(dependencies) { this.update({ isUnlinkPending: true }); try { await this.async(() => this.env.services.rpc({ - model: 'ir.attachment', - method: 'unlink', - args: [this.id], + route: `/mail/attachment/delete`, + params: { + access_token: this.accessToken, + attachment_id: this.id, + }, }, { shadow: true })); } finally { this.update({ isUnlinkPending: false }); @@ -154,13 +156,14 @@ function factory(dependencies) { */ _computeDefaultSource() { if (this.fileType === 'image') { - return `/web/image/${this.id}?unique=1&signature=${this.checksum}&model=ir.attachment`; + const unique = this.checksum ? `?unique=${this.checksum}` : ''; + return `/web/image/${this.id}${unique}`; } if (this.fileType === 'application/pdf') { - return `/web/static/lib/pdfjs/web/viewer.html?file=/web/content/${this.id}?model%3Dir.attachment`; + return `/web/static/lib/pdfjs/web/viewer.html?file=/web/content/${this.id}`; } if (this.fileType && this.fileType.includes('text')) { - return `/web/content/${this.id}?model%3Dir.attachment`; + return `/web/content/${this.id}`; } if (this.fileType === 'youtu') { const urlArr = this.url.split('/'); @@ -192,6 +195,18 @@ function factory(dependencies) { return clear(); } + /** + * @private + * @returns {string} + */ + _computeDownloadUrl() { + if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') { + return `/mail/channel/${this.originThread.id}/attachment/${this.id}`; + } + const accessToken = this.accessToken ? `?access_token=${this.accessToken}` : ''; + return `/web/content/ir.attachment/${this.id}/datas${accessToken}`; + } + /** * @private * @returns {string|undefined} @@ -249,6 +264,54 @@ function factory(dependencies) { return clear(); } + /** + * @private + * @returns {string} + */ + _computeImageLargeUrl() { + if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') { + return `/mail/channel/${this.originThread.id}/image/${this.id}/400x400`; + } + const accessToken = this.accessToken ? `?access_token=${this.accessToken}` : ''; + return `/web/image/${this.id}/400x400${accessToken}`; + } + + /** + * @private + * @returns {string} + */ + _computeImageMediumUrl() { + if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') { + return `/mail/channel/${this.originThread.id}/image/${this.id}/200x200`; + } + const accessToken = this.accessToken ? `?access_token=${this.accessToken}` : ''; + return `/web/image/${this.id}/200x200${accessToken}`; + } + + /** + * @private + * @returns {string} + */ + _computeImageSmallUrl() { + if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') { + return `/mail/channel/${this.originThread.id}/image/${this.id}/100x100`; + } + const accessToken = this.accessToken ? `?access_token=${this.accessToken}` : ''; + return `/web/image/${this.id}/100x100${accessToken}`; + } + + /** + * @private + * @returns {string} + */ + _computeImageTinyUrl() { + if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') { + return `/mail/channel/${this.originThread.id}/image/${this.id}/38x38`; + } + const accessToken = this.accessToken ? `?access_token=${this.accessToken}` : ''; + return `/web/image/${this.id}/38x38${accessToken}`; + } + /** * @private * @returns {boolean} @@ -328,6 +391,7 @@ function factory(dependencies) { } Attachment.fields = { + accessToken: attr(), activities: many2many('mail.activity', { inverse: 'attachments', }), @@ -344,6 +408,9 @@ function factory(dependencies) { displayName: attr({ compute: '_computeDisplayName', }), + downloadUrl: attr({ + compute: '_computeDownloadUrl', + }), extension: attr({ compute: '_computeExtension', }), @@ -354,6 +421,18 @@ function factory(dependencies) { id: attr({ required: true, }), + imageLargeUrl: attr({ + compute: '_computeImageLargeUrl', + }), + imageMediumUrl: attr({ + compute: '_computeImageMediumUrl', + }), + imageSmallUrl: attr({ + compute: '_computeImageSmallUrl', + }), + imageTinyUrl: attr({ + compute: '_computeImageTinyUrl', + }), isLinkedToComposer: attr({ compute: '_computeIsLinkedToComposer', }), diff --git a/addons/mail/static/src/models/chat_window/chat_window.js b/addons/mail/static/src/models/chat_window/chat_window.js index 7243b5ff8bd..e3e5e6aaa65 100644 --- a/addons/mail/static/src/models/chat_window/chat_window.js +++ b/addons/mail/static/src/models/chat_window/chat_window.js @@ -43,7 +43,7 @@ function factory(dependencies) { // Flux specific: 'closed' fold state should only be saved on the // server when manually closing the chat window. Delete at destroy // or sync from server value for example should not save the value. - if (this.thread && notifyServer) { + if (this.thread && notifyServer && !this.messaging.currentGuest) { this.thread.notifyFoldStateToServer('closed'); } if (this.exists()) { @@ -90,7 +90,7 @@ function factory(dependencies) { this.update({ isFolded: true }); // Flux specific: manually folding the chat window should save the // new state on the server. - if (this.thread && notifyServer) { + if (this.thread && notifyServer && !this.messaging.currentGuest) { this.thread.notifyFoldStateToServer('folded'); } } @@ -163,7 +163,7 @@ function factory(dependencies) { this.update({ isFolded: false }); // Flux specific: manually opening the chat window should save the // new state on the server. - if (this.thread && notifyServer) { + if (this.thread && notifyServer && !this.messaging.currentGuest) { this.thread.notifyFoldStateToServer('open'); } } diff --git a/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js index 01fceacc183..8d5ebd01579 100644 --- a/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js +++ b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js @@ -137,7 +137,7 @@ function factory(dependencies) { chatWindow.makeActive({ notifyServer: false }); } // Flux specific: notify server of chat window being opened. - if (notifyServer) { + if (notifyServer && !this.messaging.currentGuest) { const foldState = chatWindow.isFolded ? 'folded' : 'open'; thread.notifyFoldStateToServer(foldState); } diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js index 25e032f79be..ad9079f4ec0 100644 --- a/addons/mail/static/src/models/composer/composer.js +++ b/addons/mail/static/src/models/composer/composer.js @@ -2,7 +2,7 @@ import { registerNewModel } from '@mail/model/model_core'; import { attr, many2many, many2one, one2one } from '@mail/model/model_field'; -import { clear, insert, link, replace, unlink, unlinkAll } from '@mail/model/model_field_command'; +import { clear, link, replace, unlink, unlinkAll } from '@mail/model/model_field_command'; import { OnChange } from '@mail/model/model_onchange'; import emojis from '@mail/js/emojis'; import { @@ -203,8 +203,16 @@ function factory(dependencies) { * Post a message in provided composer's thread based on current composer fields values. */ async postMessage() { - const thread = this.thread; - this.thread.unregisterCurrentPartnerIsTyping({ immediateNotify: true }); + if (this.thread.model === 'mail.channel') { + const command = this._getCommandFromText(this.textInputContent); + if (command) { + command.execute({ channel: this.thread, body: this.textInputContent }); + return; + } + } + if (this.messaging.currentPartner) { + this.thread.unregisterCurrentPartnerIsTyping({ immediateNotify: true }); + } const escapedAndCompactContent = escapeAndCompactTextContent(this.textInputContent); let body = escapedAndCompactContent.replace(/ /g, ' ').trim(); // This message will be received from the mail composer as html content @@ -216,73 +224,51 @@ function factory(dependencies) { body = this._generateMentionsLinks(body); body = parseAndTransform(body, addLink); body = this._generateEmojisOnHtml(body); - let postData = { + const postData = { attachment_ids: this.attachments.map(attachment => attachment.id), body, message_type: 'comment', partner_ids: this.recipients.map(partner => partner.id), }; + const params = { + 'post_data': postData, + 'thread_id': this.thread.id, + 'thread_model': this.thread.model, + }; try { - let messageId; this.update({ isPostingMessage: true }); - if (thread.model === 'mail.channel') { - const command = this._getCommandFromText(body); + if (this.thread.model === 'mail.channel') { Object.assign(postData, { subtype_xmlid: 'mail.mt_comment', }); - if (command) { - command.execute({ channel: thread, postData }); - } else { - messageId = await this.async(() => - this.messaging.models['mail.thread'].performRpcMessagePost({ - postData, - threadId: thread.id, - threadModel: thread.model, - }) - ); - } } else { Object.assign(postData, { subtype_xmlid: this.isLog ? 'mail.mt_note' : 'mail.mt_comment', }); if (!this.isLog) { - postData.context = { - mail_post_autofollow: true, - }; + params.context = { mail_post_autofollow: true }; } - messageId = await this.async(() => - this.messaging.models['mail.thread'].performRpcMessagePost({ - postData, - threadId: thread.id, - threadModel: thread.model, - }) - ); - const [messageData] = await this.async(() => this.env.services.rpc({ - model: 'mail.message', - method: 'message_format', - args: [[messageId]], - }, { shadow: true })); - this.messaging.models['mail.message'].insert(Object.assign( - {}, - this.messaging.models['mail.message'].convertData(messageData), - { - originThread: insert({ - id: thread.id, - model: thread.model, - }), - }) - ); - thread.loadNewMessages(); } - for (const threadView of this.thread.threadViews) { + const messageData = await this.env.services.rpc({ route: `/mail/message/post`, params }); + if (!this.messaging) { + return; + } + const message = this.messaging.models['mail.message'].insert( + this.messaging.models['mail.message'].convertData(messageData) + ); + for (const threadView of message.originThread.threadViews) { // Reset auto scroll to be able to see the newly posted message. threadView.update({ hasAutoScrollOnMessageReceived: true }); } - thread.refreshFollowers(); - thread.fetchAndUpdateSuggestedRecipients(); + if (message.originThread.model !== 'mail.channel') { + message.originThread.refreshFollowers(); + message.originThread.fetchAndUpdateSuggestedRecipients(); + } this._reset(); } finally { - this.update({ isPostingMessage: false }); + if (this.exists()) { + this.update({ isPostingMessage: false }); + } } } @@ -295,6 +281,9 @@ function factory(dependencies) { if (!this.thread) { return; } + if (!this.messaging.currentPartner) { + return; + } if ( this.suggestionModelName === 'mail.channel_command' || this._getCommandFromText(this.textInputContent) diff --git a/addons/mail/static/src/models/guest/guest.js b/addons/mail/static/src/models/guest/guest.js new file mode 100644 index 00000000000..d0bd05a37b3 --- /dev/null +++ b/addons/mail/static/src/models/guest/guest.js @@ -0,0 +1,46 @@ +/** @odoo-module **/ + +import { attr, one2many } from '@mail/model/model_field'; +import { registerNewModel } from '@mail/model/model_core'; + +function factory(dependencies) { + + class Guest extends dependencies['mail.model'] { + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + /** + * @private + * @returns {string} + */ + _computeAvatarUrl() { + return `/web/image/mail.guest/${this.id}/avatar_128`; + } + + } + + Guest.fields = { + avatarUrl: attr({ + compute: '_computeAvatarUrl', + }), + authoredMessages: one2many('mail.message', { + inverse: 'guestAuthor', + }), + id: attr({ + required: true, + readonly: true, + }), + name: attr(), + }; + + Guest.modelName = 'mail.guest'; + + return Guest; +} + +registerNewModel('mail.guest', factory); diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js index 3d081f6f495..47d5c518a09 100644 --- a/addons/mail/static/src/models/message/message.js +++ b/addons/mail/static/src/models/message/message.js @@ -2,7 +2,7 @@ import { registerNewModel } from '@mail/model/model_core'; import { attr, many2many, many2one, one2many } from '@mail/model/model_field'; -import { clear, insert, insertAndReplace, link, replace, unlink, unlinkAll } from '@mail/model/model_field_command'; +import { clear, insert, insertAndReplace, replace, unlinkAll } from '@mail/model/model_field_command'; import emojis from '@mail/js/emojis'; import { addLink, htmlToTextContentInline, parseAndTransform, timeFromNow } from '@mail/js/utils'; @@ -54,7 +54,10 @@ function factory(dependencies) { if ('email_from' in data) { data2.email_from = data.email_from; } - if ('history_partner_ids' in data) { + if ('guestAuthor' in data) { + data2.guestAuthor = data.guestAuthor; + } + if ('history_partner_ids' in data && this.messaging.currentPartner) { data2.isHistory = data.history_partner_ids.includes(this.messaging.currentPartner.id); } if ('id' in data) { @@ -88,7 +91,7 @@ function factory(dependencies) { } data2.originThread = insert(originThreadData); } - if ('needaction_partner_ids' in data) { + if ('needaction_partner_ids' in data && this.messaging.currentPartner) { data2.isNeedaction = data.needaction_partner_ids.includes(this.messaging.currentPartner.id); } if ('notifications' in data) { @@ -96,10 +99,10 @@ function factory(dependencies) { this.messaging.models['mail.notification'].convertData(notificationData) )); } - if ('partner_ids' in data) { + if ('partner_ids' in data && this.messaging.currentPartner) { data2.isCurrentPartnerMentioned = data.partner_ids.includes(this.messaging.currentPartner.id); } - if ('starred_partner_ids' in data) { + if ('starred_partner_ids' in data && this.messaging.currentPartner) { data2.isStarred = data.starred_partner_ids.includes(this.messaging.currentPartner.id); } if ('subject' in data) { @@ -153,24 +156,15 @@ function factory(dependencies) { } /** - * Performs the `message_fetch` RPC on `mail.message`. + * Performs the given `route` RPC to fetch messages. * * @static - * @param {Array[]} domain - * @param {integer} [limit] - * @param {Object} [context] + * @param {string} route + * @param {Object} params * @returns {mail.message[]} */ - static async performRpcMessageFetch(domain, limit, context) { - const messagesData = await this.env.services.rpc({ - model: 'mail.message', - method: 'message_fetch', - kwargs: { - context, - domain, - limit, - }, - }, { shadow: true }); + static async performRpcMessageFetch(route, params) { + const messagesData = await this.env.services.rpc({ route, params }, { shadow: true }); if (!this.messaging) { return; } @@ -477,6 +471,9 @@ function factory(dependencies) { failureNotifications: one2many('mail.notification', { compute: '_computeFailureNotifications', }), + guestAuthor: many2one('mail.guest', { + inverse: 'authoredMessages', + }), id: attr({ required: true, }), diff --git a/addons/mail/static/src/models/messaging/messaging.js b/addons/mail/static/src/models/messaging/messaging.js index 686aafbe32d..ba2087c0da8 100644 --- a/addons/mail/static/src/models/messaging/messaging.js +++ b/addons/mail/static/src/models/messaging/messaging.js @@ -214,6 +214,7 @@ function factory(dependencies) { readonly: true, }), commands: one2many('mail.channel_command'), + currentGuest: one2one('mail.guest'), currentPartner: one2one('mail.partner'), currentUser: one2one('mail.user'), device: one2one('mail.device', { diff --git a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js index e251880a896..d81ed012203 100644 --- a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js +++ b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js @@ -39,13 +39,9 @@ function factory(dependencies) { }); const device = this.messaging.device; device.start(); - const context = Object.assign({ - isMobile: device.isMobile, - }, this.env.session.user_context); const discuss = this.messaging.discuss; const data = await this.async(() => this.env.services.rpc({ route: '/mail/init_messaging', - params: { context: context } }, { shadow: true })); await this.async(() => this._init(data)); if (discuss.isOpen) { @@ -64,6 +60,7 @@ function factory(dependencies) { * @private * @param {Object} param0 * @param {Object[]} param0.channels + * @param {Object} param0.currentGuest * @param {Object} param0.current_partner * @param {integer} param0.current_user_id * @param {Object} param0.current_user_settings @@ -78,6 +75,7 @@ function factory(dependencies) { channels, commands = [], current_partner, + currentGuest, current_user_id, current_user_settings, mail_failures = {}, @@ -91,6 +89,7 @@ function factory(dependencies) { const discuss = this.messaging.discuss; // partners first because the rest of the code relies on them this._initPartners({ + currentGuest, current_partner, current_user_id, partner_root, @@ -103,7 +102,9 @@ function factory(dependencies) { starred_counter, }); // init mail user settings - this._initResUsersSettings(current_user_settings); + if (current_user_settings) { + this._initResUsersSettings(current_user_settings); + } // various suggestions in no particular order this._initCannedResponses(shortcodes); this._initCommands(); @@ -138,7 +139,12 @@ function factory(dependencies) { // (e.g. to know when to display "invited" notification) // Current partner can always be assumed to be a member of // channels received at init. - convertedData.members = link(this.messaging.currentPartner); + if (this.messaging.currentPartner) { + convertedData.members = link(this.messaging.currentPartner); + } + if (this.messaging.currentGuest) { + convertedData.guestMembers = link(this.messaging.currentGuest); + } } const channel = this.messaging.models['mail.thread'].insert( Object.assign({ model: 'mail.channel' }, convertedData) @@ -248,29 +254,35 @@ function factory(dependencies) { /** * @private + * @param {Object} currentGuest * @param {Object} current_partner * @param {integer} current_user_id * @param {Object} partner_root * @param {Object[]} [public_partners=[]] */ _initPartners({ + currentGuest, current_partner, current_user_id: currentUserId, partner_root, public_partners = [], }) { + if (currentGuest) { + this.messaging.update({ currentGuest: insert(currentGuest) }); + } + if (current_partner) { + const partnerData = this.messaging.models['mail.partner'].convertData(current_partner); + partnerData.user = insert({ id: currentUserId }); + this.messaging.update({ + currentPartner: insert(partnerData), + currentUser: insert({ id: currentUserId }), + }); + } this.messaging.update({ - currentPartner: insert(Object.assign( - this.messaging.models['mail.partner'].convertData(current_partner), - { - user: insert({ id: currentUserId }), - } - )), - currentUser: insert({ id: currentUserId }), partnerRoot: insert(this.messaging.models['mail.partner'].convertData(partner_root)), publicPartners: insert(public_partners.map( publicPartner => this.messaging.models['mail.partner'].convertData(publicPartner) - )) + )), }); } diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js index 688631e1df9..eaccf4f03da 100644 --- a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js +++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js @@ -302,7 +302,7 @@ function factory(dependencies) { message, }); } - if (channel.model === 'mail.channel' && channel.channel_type !== 'channel') { + if (channel.model === 'mail.channel' && channel.channel_type !== 'channel' && !this.messaging.currentGuest) { // disabled on non-channel threads and // on `channel` channels for performance reasons channel.markAsFetched(); @@ -333,6 +333,7 @@ function factory(dependencies) { async _handleNotificationChannelSeen(channelId, { last_message_id, partner_id, + guest_id, }) { const channel = this.messaging.models['mail.thread'].findFromIdentifyingData({ id: channelId, @@ -358,7 +359,7 @@ function factory(dependencies) { messageId: lastMessage.id, }); } - if (this.messaging.currentPartner.id === partner_id) { + if (this.messaging.currentPartner && this.messaging.currentPartner.id === partner_id) { channel.update({ lastSeenByCurrentPartnerMessageId: last_message_id, pendingSeenMessageId: undefined, diff --git a/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js index cfdd0d937bb..f1473997db1 100644 --- a/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js +++ b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js @@ -13,6 +13,9 @@ function factory(dependencies) { //---------------------------------------------------------------------- computeGroups() { + if (!this.messaging.currentPartner) { + return; + } for (const group of this.groups) { group.delete(); } diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index 60f3d47af71..02d7129d7a9 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -29,7 +29,11 @@ function factory(dependencies) { */ this._currentPartnerInactiveTypingTimer = new Timer( this.env, - () => this.async(() => this._onCurrentPartnerInactiveTypingTimeout()), + () => this.async(() => { + if (this.messaging.currentPartner) { + return this._onCurrentPartnerInactiveTypingTimeout(); + } + }), 5 * 1000 ); /** @@ -445,19 +449,18 @@ function factory(dependencies) { } /** - * Performs the `channel_seen` RPC on `mail.channel`. + * Performs the `/mail/channel/set_last_seen_message` RPC. * * @static * @param {Object} param0 - * @param {integer[]} param0.ids list of id of channels + * @param {integer} param0.id id of channel * @param {integer[]} param0.lastMessageId */ - static async performRpcChannelSeen({ ids, lastMessageId }) { + static async performRpcChannelSeen({ id, lastMessageId }) { return this.env.services.rpc({ - model: 'mail.channel', - method: 'channel_seen', - args: [ids], - kwargs: { + route: `/mail/channel/set_last_seen_message`, + params: { + channel_id: id, last_message_id: lastMessageId, }, }, { shadow: true }); @@ -546,25 +549,6 @@ function factory(dependencies) { ); } - /** - * Performs the `message_post` RPC on given threadModel. - * - * @static - * @param {Object} param0 - * @param {Object} param0.postData - * @param {integer} param0.threadId - * @param {string} param0.threadModel - * @return {integer} the posted message id - */ - static async performRpcMessagePost({ postData, threadId, threadModel }) { - return this.env.services.rpc({ - model: threadModel, - method: 'message_post', - args: [threadId], - kwargs: postData, - }); - } - /** * Performs RPC on the route `/mail/get_suggested_recipients`. * @@ -771,6 +755,9 @@ function factory(dependencies) { * @param {mail.message} message the message to be considered as last seen. */ async markAsSeen(message) { + if (this.messaging.currentGuest) { + return; + } if (this.model !== 'mail.channel') { return; } @@ -785,7 +772,7 @@ function factory(dependencies) { } this.update({ pendingSeenMessageId: message.id }); return this.messaging.models['mail.thread'].performRpcChannelSeen({ - ids: [this.id], + id: this.id, lastMessageId: message.id, }); } @@ -905,6 +892,9 @@ function factory(dependencies) { */ async pin() { this.update({ isPendingPinned: true }); + if (this.messaging.currentGuest) { + return; + } await this.notifyPinStateToServer(); } @@ -1087,6 +1077,9 @@ function factory(dependencies) { */ async unpin() { this.update({ isPendingPinned: false }); + if (this.messaging.currentGuest) { + return; + } await this.notifyPinStateToServer(); } @@ -1219,6 +1212,42 @@ function factory(dependencies) { return this.name; } + /** + * @private + * @returns {Object} + */ + _computeFetchMessagesParams() { + if (this.model === 'mail.box') { + return {}; + } + if (this.model === 'mail.channel') { + return { 'channel_id': this.id }; + } + return { + 'thread_id': this.id, + 'thread_model': this.model, + }; + } + + /** + * @private + * @returns {string} + */ + _computeFetchMessagesUrl() { + switch (this) { + case this.messaging.inbox: + return '/mail/inbox/messages'; + case this.messaging.history: + return '/mail/history/messages'; + case this.messaging.starred: + return '/mail/starred/messages'; + } + if (this.model === 'mail.channel') { + return `/mail/channel/messages`; + } + return `/mail/thread/messages`; + } + /** * @private * @returns {mail.activity[]} @@ -1273,6 +1302,16 @@ function factory(dependencies) { return this.model === 'mail.channel' && ['channel', 'group'].includes(this.channel_type); } + /** + * @returns {string} + */ + _computeInvitationLink() { + if (!this.uuid || !this.channel_type || this.channel_type === 'chat') { + return clear(); + } + return `${window.location.origin}/chat/${this.id}/${this.uuid}`; + } + /** * @private * @returns {boolean} @@ -1389,7 +1428,8 @@ function factory(dependencies) { continue; } if ( - message.author === this.messaging.currentPartner || + (message.author && this.messaging.currentPartner && message.author === this.messaging.currentPartner) || + (message.guestAuthor && this.messaging.currentGuest && message.guestAuthor === this.messaging.currentGuest) || message.isTransient ) { lastSeenByCurrentPartnerMessageId = message.id; @@ -1853,6 +1893,12 @@ function factory(dependencies) { displayName: attr({ compute: '_computeDisplayName', }), + fetchMessagesParams: attr({ + compute: '_computeFetchMessagesParams', + }), + fetchMessagesUrl: attr({ + compute: '_computeFetchMessagesUrl', + }), followersPartner: many2many('mail.partner', { related: 'followers.partner', }), @@ -1866,6 +1912,7 @@ function factory(dependencies) { futureActivities: one2many('mail.activity', { compute: '_computeFutureActivities', }), + guestMembers: many2many('mail.guest'), group_based_subscription: attr({ default: false, }), @@ -1899,6 +1946,9 @@ function factory(dependencies) { id: attr({ required: true, }), + invitationLink: attr({ + compute: '_computeInvitationLink', + }), /** * Determines whether this description can be changed. * Only makes sense for channels. @@ -2011,6 +2061,7 @@ function factory(dependencies) { }), /** * States the number of members in this thread according to the server. + * Guests are excluded from the count. * Only makes sense if this thread is a channel. */ memberCount: attr(), diff --git a/addons/mail/static/src/models/thread_cache/thread_cache.js b/addons/mail/static/src/models/thread_cache/thread_cache.js index d8ba4afc7a9..95bb1e51632 100644 --- a/addons/mail/static/src/models/thread_cache/thread_cache.js +++ b/addons/mail/static/src/models/thread_cache/thread_cache.js @@ -27,10 +27,7 @@ function factory(dependencies) { let fetchedMessages; let success; try { - fetchedMessages = await this._loadMessages({ - extraDomain: [['id', '<', Math.min(...messageIds)]], - limit, - }); + fetchedMessages = await this._loadMessages({ limit, maxId: Math.min(...messageIds) }); success = true; } catch (e) { success = false; @@ -61,10 +58,7 @@ function factory(dependencies) { return; } const messageIds = this.fetchedMessages.map(message => message.id); - const fetchedMessages = this._loadMessages({ - extraDomain: [['id', '>', Math.max(...messageIds)]], - limit: false, - }); + const fetchedMessages = this._loadMessages({ minId: Math.max(...messageIds) }); if (!fetchedMessages) { return; } @@ -213,55 +207,25 @@ function factory(dependencies) { return res; } - /** - * @private - * @param {Array} domain - * @returns {Array} - */ - _extendMessageDomain(domain) { - const thread = this.thread; - if (thread === this.messaging.inbox) { - return domain.concat([['needaction', '=', true]]); - } else if (thread === this.messaging.starred) { - return domain.concat([ - ['starred_partner_ids', 'in', [this.messaging.currentPartner.id]], - ]); - } else if (thread === this.messaging.history) { - return domain.concat([['needaction', '=', false]]); - } else { - // Avoid to load user_notification as these messages are not - // meant to be shown on chatters. - return domain.concat([ - ['message_type', '!=', 'user_notification'], - ['model', '=', thread.model], - ['res_id', '=', thread.id], - ]); - } - } - /** * @private * @param {Object} [param0={}] - * @param {Array[]} [param0.extraDomain] * @param {integer} [param0.limit=30] + * @param {integer} [param0.maxId] + * @param {integer} [param0.minId] * @returns {mail.message[]} * @throws {Error} when failed to load messages */ - async _loadMessages({ extraDomain, limit = 30 } = {}) { + async _loadMessages({ limit = 30, maxId, minId } = {}) { this.update({ isLoading: true }); - let domain = []; - domain = this._extendMessageDomain(domain); - if (extraDomain) { - domain = extraDomain.concat(domain); - } - const context = this.env.session.user_context; let messages; try { - messages = await this.messaging.models['mail.message'].performRpcMessageFetch( - domain, + messages = await this.messaging.models['mail.message'].performRpcMessageFetch(this.thread.fetchMessagesUrl, { + ...this.thread.fetchMessagesParams, limit, - context, - ); + 'max_id': maxId, + 'min_id': minId, + }); } catch (e) { if (this.exists()) { this.update({ @@ -280,7 +244,7 @@ function factory(dependencies) { isLoaded: true, isLoading: false, }); - if (!extraDomain && messages.length < limit) { + if (!minId && messages.length < limit) { this.update({ isAllHistoryLoaded: true }); } this.messaging.messagingBus.trigger('o-thread-cache-loaded-messages', { @@ -312,6 +276,9 @@ function factory(dependencies) { * @private */ _onChangeMarkAllAsRead() { + if (this.messaging.currentGuest) { + return; + } if ( !this.isMarkAllAsReadRequested || !this.isLoaded || diff --git a/addons/mail/static/src/models/thread_view/thread_view.js b/addons/mail/static/src/models/thread_view/thread_view.js index c8b726494cf..8936e431be0 100644 --- a/addons/mail/static/src/models/thread_view/thread_view.js +++ b/addons/mail/static/src/models/thread_view/thread_view.js @@ -162,6 +162,9 @@ function factory(dependencies) { // See task-2277543 return; } + if (this.messaging.currentGuest) { + return; + } this.thread.markAsSeen(this.thread.lastNonTransientMessage).catch(e => { // prevent crash when executing compute during destroy if (!(e instanceof RecordDeletedError)) { @@ -258,6 +261,12 @@ function factory(dependencies) { composer: many2one('mail.composer', { related: 'thread.composer', }), + /** + * Determines which extra class this thread view component should have. + */ + extraClass: attr({ + related: 'threadViewer.extraClass', + }), hasComposerFocus: attr({ related: 'composer.hasFocus', }), diff --git a/addons/mail/static/src/models/thread_view/thread_viewer.js b/addons/mail/static/src/models/thread_view/thread_viewer.js index a3086e67f92..8d23f710dd0 100644 --- a/addons/mail/static/src/models/thread_view/thread_viewer.js +++ b/addons/mail/static/src/models/thread_view/thread_viewer.js @@ -77,6 +77,10 @@ function factory(dependencies) { } ThreadViewer.fields = { + /** + * Determines which extra class this thread view component should have. + */ + extraClass: attr(), /** * Determines whether this thread viewer has a member list. * Only makes sense if this thread is a channel and if the channel is diff --git a/addons/mail/static/src/public/discuss_public_boot.js b/addons/mail/static/src/public/discuss_public_boot.js new file mode 100644 index 00000000000..d48e5853386 --- /dev/null +++ b/addons/mail/static/src/public/discuss_public_boot.js @@ -0,0 +1,82 @@ +/** @odoo-module **/ + +import { insert } from '@mail/model/model_field_command'; +import { MessagingService } from '@mail/services/messaging/messaging'; +import { getMessagingComponent } from '@mail/utils/messaging_component'; + +import { processTemplates } from '@web/core/assets'; +import { registry } from "@web/core/registry"; +import { makeEnv, startServices } from '@web/env'; +import { session } from '@web/session'; + +import * as AbstractService from "web.AbstractService"; +import { serviceRegistry as legacyServiceRegistry } from 'web.core'; +import * as legacyEnv from "web.env"; +import { + makeLegacyCrashManagerService, + makeLegacyDialogMappingService, + makeLegacyNotificationService, + makeLegacyRpcService, + makeLegacySessionService, + mapLegacyEnvToWowlEnv, +} from "@web/legacy/utils"; +import * as legacySession from "web.session"; + +owl.Component.env = legacyEnv; + +export async function boot() { + await owl.utils.whenReady(); + owl.config.mode = owl.Component.env.isDebug() ? "dev" : "prod"; + AbstractService.prototype.deployServices(owl.Component.env); + const serviceRegistry = registry.category("services"); + serviceRegistry.add("legacy_rpc", makeLegacyRpcService(owl.Component.env)); + serviceRegistry.add("legacy_session", makeLegacySessionService(owl.Component.env, legacySession)); + serviceRegistry.add("legacy_notification", makeLegacyNotificationService(owl.Component.env)); + serviceRegistry.add("legacy_crash_manager", makeLegacyCrashManagerService(owl.Component.env)); + serviceRegistry.add("legacy_dialog_mapping", makeLegacyDialogMappingService(owl.Component.env)); + await legacySession.is_bound; + owl.Component.env.qweb.addTemplates(legacySession.owlTemplates); + Object.assign(odoo, { + info: { + db: session.db, + server_version: session.server_version, + server_version_info: session.server_version_info, + isEnterprise: session.server_version_info.slice(-1)[0] === 'e', + }, + isReady: false, + }); + const env = makeEnv(); + const [, templates] = await Promise.all([ + startServices(env), + odoo.loadTemplatesPromise.then(processTemplates), + ]); + env.qweb.addTemplates(templates); + mapLegacyEnvToWowlEnv(owl.Component.env, env); + odoo.isReady = true; + legacyServiceRegistry.add('messaging', MessagingService.extend({ + messagingValues: { + autofetchPartnerImStatus: false, + }, + })); + + async function createThreadViewFromChannelData(channelData) { + const messaging = await owl.Component.env.services.messaging.get(); + const threadViewer = messaging.models['mail.thread_viewer'].create({ + extraClass: 'flex-grow-1', + hasMemberList: true, + hasThreadView: true, + hasTopbar: true, + thread: insert(messaging.models['mail.thread'].convertData(channelData)), + }); + const ThreadView = getMessagingComponent('ThreadView'); + const threadViewComponent = new ThreadView(null, { + composerAttachmentsDetailsMode: 'card', + hasComposer: true, + hasComposerThreadTyping: true, + threadViewLocalId: threadViewer.threadView.localId, + }); + await threadViewComponent.mount(document.body); + } + + return { createThreadViewFromChannelData }; +} diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js index 13cdf04b323..e8edf3d8cd9 100644 --- a/addons/mail/static/tests/helpers/mock_server.js +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -54,43 +54,25 @@ MockServer.include({ * @override */ async _performFetch(resource, init) { - if (resource === '/web/binary/upload_attachment') { - const formData = init.body; - const model = formData.get('model'); - const id = parseInt(formData.get('id')); - const ufiles = formData.getAll('ufile'); - const callback = formData.get('callback'); - - const attachmentIds = []; - for (const ufile of ufiles) { - const attachmentId = this._mockCreate('ir.attachment', { - // datas, - mimetype: ufile.type, - name: ufile.name, - res_id: id, - res_model: model, - }); - attachmentIds.push(attachmentId); - } - const attachments = this._getRecords('ir.attachment', [['id', 'in', attachmentIds]]); - const formattedAttachments = attachments.map(attachment => { - return { - 'filename': attachment.name, - 'id': attachment.id, - 'mimetype': attachment.mimetype, - 'size': attachment.file_size - }; + if (resource === '/mail/attachment/upload') { + const ufile = init.body.get('ufile'); + const is_pending = init.body.get('is_pending'); + const model = is_pending ? 'mail.compose.message' : init.body.get('thread_model'); + const id = is_pending ? 0 : parseInt(init.body.get('thread_id')); + const attachmentId = this._mockCreate('ir.attachment', { + // datas, + mimetype: ufile.type, + name: ufile.name, + res_id: id, + res_model: model, }); - return { - text() { - return ` - - `; - }, - }; + const attachment = this._getRecords('ir.attachment', [['id', '=', attachmentId]])[0]; + return new window.Response(JSON.stringify({ + 'filename': attachment.name, + 'id': attachment.id, + 'mimetype': attachment.mimetype, + 'size': attachment.file_size + })); } return this._super(...arguments); }, @@ -99,6 +81,16 @@ MockServer.include({ */ async _performRpc(route, args) { // routes + if (route === '/mail/message/post') { + if (args.thread_model === 'mail.channel') { + return this._mockMailChannelMessagePost(args.thread_id, args.post_data, args.context); + } + return this._mockMailThreadMessagePost(args.thread_model, [args.thread_id], args.post_data, args.context); + } + if (route === '/mail/attachment/delete') { + const { attachment_id } = args; + return this._mockRouteMailAttachmentRemove(attachment_id); + } if (route === '/mail/chat_post') { const uuid = args.uuid; const message_content = args.message_content; @@ -113,6 +105,18 @@ MockServer.include({ if (route === '/mail/init_messaging') { return this._mockRouteMailInitMessaging(); } + if (route === '/mail/history/messages') { + const { min_id, max_id, limit } = args; + return this._mockRouteMailMessageHistory(min_id, max_id, limit); + } + if (route === '/mail/inbox/messages') { + const { min_id, max_id, limit } = args; + return this._mockRouteMailMessageInbox(min_id, max_id, limit); + } + if (route === '/mail/starred/messages') { + const { min_id, max_id, limit } = args; + return this._mockRouteMailMessageStarredMessages(min_id, max_id, limit); + } if (route === '/mail/read_followers') { return this._mockRouteMailReadFollowers(args); } @@ -120,6 +124,17 @@ MockServer.include({ const follower_id = args.follower_id; return this._mockRouteMailReadSubscriptionData(follower_id); } + if (route === '/mail/thread/messages') { + const { min_id, max_id, limit, thread_model, thread_id } = args; + return this._mockRouteMailThreadFetchMessages(thread_model, thread_id, max_id, min_id, limit); + } + if (route === '/mail/channel/messages') { + const { channel_id, min_id, max_id, limit } = args; + return this._mockRouteMailChannelMessages(channel_id, max_id, min_id, limit); + } + if (new RegExp('/mail/channel/\\d+/partner/\\d+/avatar_128').test(route)) { + return; + } // mail.activity methods if (args.model === 'mail.activity' && args.method === 'activity_format') { let res = this._mockRead(args.model, args.args, args.kwargs); @@ -190,10 +205,10 @@ MockServer.include({ const name = args.args[1] || args.kwargs.name; return this._mockMailChannelChannelRename(ids, name); } - if (args.model === 'mail.channel' && args.method === 'channel_seen') { - const channel_ids = args.args[0]; - const last_message_id = args.args[1] || args.kwargs.last_message_id; - return this._mockMailChannelChannelSeen(channel_ids, last_message_id); + if (route === '/mail/channel/set_last_seen_message') { + const id = args.channel_id; + const last_message_id = args.last_message_id; + return this._mockMailChannel_ChannelSeen([id], last_message_id); } if (args.model === 'mail.channel' && args.method === 'channel_set_custom_name') { const ids = args.args[0]; @@ -210,13 +225,6 @@ MockServer.include({ if (args.model === 'mail.channel' && args.method === 'execute_command_who') { return this._mockMailChannelExecuteCommandWho(args); } - if (args.model === 'mail.channel' && args.method === 'message_post') { - const id = args.args[0]; - const kwargs = args.kwargs; - const context = kwargs.context; - delete kwargs.context; - return this._mockMailChannelMessagePost(id, kwargs, context); - } if (args.model === 'mail.channel' && args.method === 'notify_typing') { const ids = args.args[0]; const is_typing = args.args[1] || args.kwargs.is_typing; @@ -232,14 +240,6 @@ MockServer.include({ const domain = args.args[0] || args.kwargs.domain; return this._mockMailMessageMarkAllAsRead(domain); } - if (args.model === 'mail.message' && args.method === 'message_fetch') { - // TODO FIXME delay RPC until next potential render as a workaround - // to issue https://github.com/odoo/owl/pull/724 - await nextAnimationFrame(); - const domain = args.args[0] || args.kwargs.domain; - const limit = args.args[1] || args.kwargs.limit; - return this._mockMailMessageMessageFetch(domain, limit); - } if (args.model === 'mail.message' && args.method === 'message_format') { const ids = args.args[0]; return this._mockMailMessageMessageFormat(ids); @@ -310,6 +310,35 @@ MockServer.include({ // Private Mocked Routes //-------------------------------------------------------------------------- + /** + * Simulates the `/mail/attachment/delete` route. + * + * @private + * @param {integer} attachment_id + */ + async _mockRouteMailAttachmentRemove(attachment_id) { + return this._mockUnlink('ir.attachment', [[attachment_id]]); + }, + + /** + * Simulates the `/mail/channel/messages` route. + * + * @private + * @param {integer} channel_id + * @param {integer} limit + * @param {integer} max_id + * @param {integer} min_id + * @returns {Object} list of messages + */ + async _mockRouteMailChannelMessages(channel_id, max_id = false, min_id = false, limit = 30) { + const domain = [ + ['res_id', '=', channel_id], + ['model', '=', 'mail.channel'], + ['message_type', '!=', 'user_notification'], + ]; + return this._mockMailMessage_MessageFetch(domain, max_id, min_id, limit); + }, + /** * Simulates the `/mail/chat_post` route. * @@ -382,6 +411,36 @@ MockServer.include({ _mockRouteMailInitMessaging() { return this._mockResUsers_InitMessaging([this.currentUserId]); }, + /** + * Simulates the `/mail/history/messages` route. + * + * @private + * @returns {Object} + */ + _mockRouteMailMessageHistory(min_id = false, max_id = false, limit = 30) { + const domain = [['needaction', '=', false]]; + return this._mockMailMessage_MessageFetch(domain, max_id, min_id, limit); + }, + /** + * Simulates the `/mail/inbox/messages` route. + * + * @private + * @returns {Object} + */ + _mockRouteMailMessageInbox(min_id = false, max_id = false, limit = 30) { + const domain = [['needaction', '=', true]]; + return this._mockMailMessage_MessageFetch(domain, max_id, min_id, limit); + }, + /** + * Simulates the `/mail/starred/messages` route. + * + * @private + * @returns {Object} + */ + _mockRouteMailMessageStarredMessages(min_id = false, max_id = false, limit = 30) { + const domain = [['starred_partner_ids', 'in', [this.currentPartnerId]]]; + return this._mockMailMessage_MessageFetch(domain, max_id, min_id, limit); + }, /** * Simulates the `/mail/read_followers` route. * @@ -434,6 +493,26 @@ MockServer.include({ return subtypes_list; }, + /** + * Simulates the `/mail/thread/messages` route. + * + * @private + * @param {string} res_model + * @param {integer} res_id + * @param {integer} max_id + * @param {integer} min_id + * @param {integer} limit + * @returns {Object[]} list of messages + */ + async _mockRouteMailThreadFetchMessages(res_model, res_id, max_id = false, min_id = false, limit = 30) { + const domain = [ + ['res_id', '=', res_id], + ['model', '=', res_model], + ['message_type', '!=', 'user_notification'], + ]; + return this._mockMailMessage_MessageFetch(domain, max_id, min_id, limit); + }, + //-------------------------------------------------------------------------- // Private Mocked Methods //-------------------------------------------------------------------------- @@ -817,13 +896,13 @@ MockServer.include({ this._widget.call('bus_service', 'trigger', 'notification', [notification]); }, /** - * Simulates the `channel_seen` method of `mail.channel`. + * Simulates the `_channel_seen` method of `mail.channel`. * * @private * @param integer[] ids * @param {integer} last_message_id */ - async _mockMailChannelChannelSeen(ids, last_message_id) { + async _mockMailChannel_ChannelSeen(ids, last_message_id) { // Update record const channel_id = ids[0]; if (!channel_id) { @@ -1045,7 +1124,7 @@ MockServer.include({ }, ]); } - const messageId = this._mockMailThreadMessagePost( + const messageData = this._mockMailThreadMessagePost( 'mail.channel', [id], Object.assign(kwargs, { @@ -1054,14 +1133,14 @@ MockServer.include({ context, ); if (kwargs.author_id === this.currentPartnerId) { - this._mockMailChannel_SetLastSeenMessage([channel.id], messageId); + this._mockMailChannel_SetLastSeenMessage([channel.id], messageData.id); } else { this._mockWrite('mail.channel', [ [channel.id], { message_unread_counter: (channel.message_unread_counter || 0) + 1 }, ]); } - return messageId; + return messageData; }, /** * Simulates `notify_typing` on `mail.channel`. @@ -1182,14 +1261,23 @@ MockServer.include({ return messageIds; }, /** - * Simulates `message_fetch` on `mail.message`. + * Simulates `_message_fetch` on `mail.message`. * * @private * @param {Array[]} domain * @param {string} [limit=20] * @returns {Object[]} */ - _mockMailMessageMessageFetch(domain, limit = 20) { + async _mockMailMessage_MessageFetch(domain, max_id, min_id, limit = 30) { + // TODO FIXME delay RPC until next potential render as a workaround + // to OWL issue (possibly https://github.com/odoo/owl/issues/904) + await nextAnimationFrame(); + if (max_id) { + domain.push(['id', '<', max_id]); + } + if (min_id) { + domain.push(['id', '>', min_id]); + } let messages = this._getRecords('mail.message', domain); // sorted from highest ID to lowest ID (i.e. from youngest to oldest) messages.sort(function (m1, m2) { @@ -1563,7 +1651,7 @@ MockServer.include({ * @param {integer[]} ids * @param {Object} kwargs * @param {Object} [context] - * @returns {integer} + * @returns {Object} */ _mockMailThreadMessagePost(model, ids, kwargs, context) { const id = ids[0]; // ensure_one @@ -1601,7 +1689,7 @@ MockServer.include({ delete values.subtype_xmlid; const messageId = this._mockCreate('mail.message', values); this._mockMailThread_NotifyThread(model, ids, messageId); - return messageId; + return this._mockMailMessageMessageFormat([messageId])[0]; }, /** * Simulates `message_subscribe` on `mail.thread`. diff --git a/addons/mail/static/tests/systray/systray_activity_menu_tests.js b/addons/mail/static/tests/systray/systray_activity_menu_tests.js index ebc9297f825..1a0f0f7b25c 100644 --- a/addons/mail/static/tests/systray/systray_activity_menu_tests.js +++ b/addons/mail/static/tests/systray/systray_activity_menu_tests.js @@ -238,9 +238,6 @@ QUnit.test('activity menu widget: close on messaging menu click', async function data: this.data, hasMessagingMenu: true, async mockRPC(route, args) { - if (args.method === 'message_fetch') { - return []; - } if (args.method === 'systray_get_activities') { return []; } diff --git a/addons/mail/tests/test_mail_channel.py b/addons/mail/tests/test_mail_channel.py index f2f670531a6..b743b44682d 100644 --- a/addons/mail/tests/test_mail_channel.py +++ b/addons/mail/tests/test_mail_channel.py @@ -340,14 +340,14 @@ class TestChannelInternals(MailCommon): msg_1 = self._add_messages(self.test_channel, 'Body1', author=self.user_employee.partner_id) msg_2 = self._add_messages(self.test_channel, 'Body2', author=self.user_employee.partner_id) - self.test_channel.channel_seen(msg_2.id) + self.test_channel._channel_seen(msg_2.id) self.assertEqual( channel.channel_info()[0]['seen_partners_info'][0]['seen_message_id'], msg_2.id, "Last message id should have been updated" ) - self.test_channel.channel_seen(msg_1.id) + self.test_channel._channel_seen(msg_1.id) self.assertEqual( channel.channel_info()[0]['seen_partners_info'][0]['seen_message_id'], msg_2.id, diff --git a/addons/mail/tests/test_mail_channel_partner.py b/addons/mail/tests/test_mail_channel_partner.py index e777a13f5c5..ab89753d124 100644 --- a/addons/mail/tests/test_mail_channel_partner.py +++ b/addons/mail/tests/test_mail_channel_partner.py @@ -89,8 +89,9 @@ class TestMailChannelMembers(MailCommon): with self.assertRaises(AccessError): channel_partner.write({'channel_id': self.private_channel.id}) - # But with SUDO, User 2 can - channel_partner.sudo().channel_id = self.private_channel.id + # Even with SUDO, channel_id of channel.partner should not be changed. + with self.assertRaises(AccessError): + channel_partner.sudo().channel_id = self.private_channel.id # User 2 can not write on the `partner_id` of `mail.channel.partner` # of an other partner to join a private channel @@ -99,9 +100,9 @@ class TestMailChannelMembers(MailCommon): channel_partner_1.with_user(self.user_2).partner_id = self.user_2.partner_id self.assertEqual(channel_partner_1.partner_id, self.user_1.partner_id) - # but with SUDO he can... - channel_partner_1.with_user(self.user_2).sudo().partner_id = self.user_2.partner_id - self.assertEqual(channel_partner_1.partner_id, self.user_2.partner_id) + # Even with SUDO, partner_id of channel.partner should not be changed. + with self.assertRaises(AccessError): + channel_partner_1.with_user(self.user_2).sudo().partner_id = self.user_2.partner_id def test_channel_private_members(self): """Test invitation in private channel part 1 (invite using crud methods).""" diff --git a/addons/mail/views/discuss_public_templates.xml b/addons/mail/views/discuss_public_templates.xml new file mode 100644 index 00000000000..ff867fd698a --- /dev/null +++ b/addons/mail/views/discuss_public_templates.xml @@ -0,0 +1,50 @@ + + +