[IMP] mail, web, *: add support for guest users

* = crm_livechat, hr, hr_holidays, im_livechat, mail_bot, purchase, sms,
    snailmail, survey, test_discuss_full, test_mail, web_editor, website,
    website_livechat

 - Create new model `mail.guest` for guests.
 - Rewrite some RPCs to target routes rather than model methods so that
   guests are able to use them.
 - Patch JS and python models to support guests.
 - Create a stand-alone page and boot the channel in it.

task-2494829

closes odoo/odoo#75496

Related: odoo/enterprise#20417
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Louis Wicket (wil)
2021-09-02 00:43:34 +00:00
committed by Sébastien Theys
parent 8d6bd85d2b
commit 80d74e7ee0
93 changed files with 1509 additions and 706 deletions
+3
View File
@@ -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',
],
+4 -4
View File
@@ -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',
+8 -8
View File
@@ -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',
],
},
+8 -22
View File
@@ -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': [
@@ -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,
+2 -2
View File
@@ -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
@@ -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
@@ -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
*/
+66
View File
@@ -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',
],
+15 -11
View File
@@ -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)
+214 -36
View File
@@ -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/<int:channel_id>/<string:invitation_token>', methods=['GET'], type='http', auth='public')
def discuss_channel_invitation(self, channel_id, invitation_token, **kwargs):
channel_sudo = request.env['mail.channel'].browse(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/<int:channel_id>', 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/<int:channel_id>/partner/<int:partner_id>/avatar_128', methods=['GET'], type='http', auth='public')
def mail_channel_partner_avatar_128(self, channel_id, partner_id, **kwargs):
channel_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/<int:channel_id>/guest/<int:guest_id>/avatar_128', methods=['GET'], type='http', auth='public')
def mail_channel_guest_avatar_128(self, channel_id, guest_id, **kwargs):
channel_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/<int:channel_id>/attachment/<int:attachment_id>', 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/<int:channel_id>/image/<int:attachment_id>/<int:width>x<int:height>', methods=['GET'], type='http', auth='public')
def fetch_image(self, channel_id, attachment_id, width, 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/<string:res_model>/<int:res_id>/avatar/<int:partner_id>', 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:
+1
View File
@@ -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
+15
View File
@@ -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
+33 -17
View File
@@ -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,
+44 -5
View File
@@ -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']
+80
View File
@@ -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,
}
+15 -2
View File
@@ -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):
+7 -3
View File
@@ -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,
+2
View File
@@ -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):
+1
View File
@@ -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(),
+3
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
45 access_mail_resend_partner access.mail.resend.partner model_mail_resend_partner base.group_user 1 1 1 0
46 access_mail_template_preview access.mail.template.preview model_mail_template_preview base.group_user 1 1 1 0
47 access_mail_blacklist_remove_system acesss.mail.blacklist.remove.system model_mail_blacklist_remove base.group_system 1 1 1 1
48 access_mail_guest_all mail.guest model_mail_guest 0 0 0 0
49 access_mail_guest_user mail.guest model_mail_guest base.group_user 1 0 0 0
50 access_mail_guest_system mail.guest model_mail_guest base.group_system 1 1 1 1
51 access_res_users_settings_all res.users.settings model_res_users_settings 0 0 0 0
52 access_res_users_settings_user res.users.settings model_res_users_settings base.group_user 1 1 1 1
53 ir_actions_report_access_user ir.actions.report.access.user base.model_ir_actions_report base.group_user 1 0 0 0
@@ -131,8 +131,7 @@
</button>
<FileUploader
attachmentLocalIds="activity.attachments.map(attachment => attachment.localId)"
uploadId="activity.thread.id"
uploadModel="activity.thread.model"
threadLocalId="activity.thread.localId"
t-on-o-attachment-created="_onAttachmentCreated"
t-ref="fileUploader"
/>
@@ -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 });
}
/**
@@ -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"
);
});
@@ -38,8 +38,7 @@
<FileUploader
attachmentLocalIds="thread.allAttachments.map(attachment => attachment.localId)"
newAttachmentExtraData="newAttachmentExtraData"
uploadModel="thread.model"
uploadId="thread.id"
threadLocalId="thread.localId"
t-on-o-attachment-created="_onAttachmentCreated"
t-ref="fileUploader"
/>
@@ -56,6 +56,12 @@
<t t-esc="channelInvitationForm.inviteButtonText"/>
</button>
</div>
<t t-if="channelInvitationForm.thread and channelInvitationForm.thread.invitationLink">
<h4 class="mx-3 mt-3 mb-2">Invitation Link</h4>
<div class="mx-3 my-2">
<input type="text" t-att-value="channelInvitationForm.thread.invitationLink"/>
</div>
</t>
</div>
</t>
@@ -34,7 +34,8 @@
<t t-foreach="members" t-as="member" t-key="member.localId">
<div class="o_ChannelMemberList_member d-flex align-items-center mx-3 my-1">
<div class="o_ChannelMemberList_avatarContainer position-relative flex-shrink-0" t-on-click="channel.onClickMemberAvatar(member)">
<img class="o_ChannelMemberList_avatar rounded-circle w-100 h-100" t-att-src="member.avatarUrl" alt="Avatar"/>
<img class="o_ChannelMemberList_avatar rounded-circle w-100 h-100" t-attf-src="/mail/channel/{{ channel.id }}/partner/{{ member.id }}/avatar_128" alt="Avatar"/>
<t t-if="member.im_status and member.im_status !== 'im_partner'">
<PartnerImStatusIcon
class="o_ChannelMemberList_partnerImStatusIcon d-flex align-items-center justify-content-center"
@@ -2043,7 +2043,7 @@ QUnit.test('chat window does not fetch messages if hidden', async function (asse
* 2 visible chat windows + hidden menu:
* 10 + 325 + 5 + 325 + 10 + 200 + 5 = 875 < 900
*/
assert.expect(17);
assert.expect(11);
// 3 channels are expected to be found in the messaging menu, each with a
// random unique id that will be referenced in the test
@@ -2074,14 +2074,9 @@ QUnit.test('chat window does not fetch messages if hidden', async function (asse
},
},
mockRPC(route, args) {
if (args.method === 'message_fetch') {
// domain should be like [['message_type', '=', 'user_notification'], ['model', '=', 'mail.channel'], ['res_id', '=', X]] with X the channel id
const channel_model = args.kwargs.domain[1][2];
const channel_id_field = args.kwargs.domain[2][0];
const channel_id = args.kwargs.domain[2][2];
assert.strictEqual(channel_model, "mail.channel", "messages should be on channel thread model");
assert.strictEqual(channel_id_field, "res_id", "messages should be fetched channel per channel using res_id field");
assert.step(`rpc:message_fetch:${channel_id}`);
if (route === '/mail/channel/messages') {
const { channel_id } = args;
assert.step(`rpc:/mail/channel/messages:${channel_id}`);
}
return this._super(...arguments);
},
@@ -2109,7 +2104,7 @@ QUnit.test('chat window does not fetch messages if hidden', async function (asse
"chat window hidden menu should be displayed"
);
assert.verifySteps(
['rpc:message_fetch:10', 'rpc:message_fetch:11'],
['rpc:/mail/channel/messages:10', 'rpc:/mail/channel/messages:11'],
"messages should be fetched for the two visible chat windows"
);
@@ -2142,7 +2137,7 @@ QUnit.test('chat window does not fetch messages if hidden', async function (asse
"chat window for Channel #12 should now be visible"
);
assert.verifySteps(
['rpc:message_fetch:12'],
['rpc:/mail/channel/messages:12'],
"messages should now be fetched for Channel #12"
);
});
@@ -383,11 +383,11 @@ QUnit.test("suggested recipients should not be notified when posting an internal
});
await this.start({
async mockRPC(route, args) {
if (args.model === 'res.fake' && args.method === 'message_post') {
if (route === '/mail/message/post') {
assert.strictEqual(
args.kwargs.partner_ids.length,
args.post_data.partner_ids.length,
0,
"message_post should not contain suggested recipients when posting an internal note"
"post data should not contain suggested recipients when posting an internal note"
);
}
return this._super(...arguments);
@@ -22,7 +22,9 @@
</t>
<FileUploader
attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)"
isPending="true"
newAttachmentExtraData="newAttachmentExtraData"
threadLocalId="composer.thread.localId"
t-ref="fileUploader"
/>
<t t-if="hasHeader">
@@ -54,7 +56,12 @@
</t>
<t t-if="props.hasCurrentPartnerAvatar">
<div class="o_Composer_sidebarMain">
<img class="o_Composer_currentPartnerAvatar rounded-circle" t-att-src="currentPartnerAvatar" alt=""/>
<t t-if="!messaging.currentGuest or composer.thread.model !== 'mail.channel'">
<img class="o_Composer_currentPartnerAvatar rounded-circle" t-att-src="currentPartnerAvatar" alt="Avatar of user"/>
</t>
<t t-if="messaging.currentGuest and composer.thread.model === 'mail.channel'">
<img class="o_Composer_currentPartnerAvatar rounded-circle" t-attf-src="/mail/channel/{{ composer.thread.id }}/guest/{{ messaging.currentGuest.id }}/avatar_128" alt="Avatar of guest"/>
</t>
</div>
</t>
<div
@@ -1361,7 +1361,7 @@ QUnit.test('send message when enter is pressed while holding ctrl key (this shor
this.data['mail.channel'].records.push({ id: 20 });
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_post') {
if (route === '/mail/message/post') {
assert.step('message_post');
}
return this._super(...arguments);
@@ -1416,7 +1416,7 @@ QUnit.test('send message when enter is pressed while holding meta key (this shor
this.data['mail.channel'].records.push({ id: 20 });
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_post') {
if (route === '/mail/message/post') {
assert.step('message_post');
}
return this._super(...arguments);
@@ -1470,7 +1470,7 @@ QUnit.test('composer text input cleared on message post', async function (assert
this.data['mail.channel'].records.push({ id: 20 });
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_post') {
if (route === '/mail/message/post') {
assert.step('message_post');
}
return this._super(...arguments);
@@ -1694,7 +1694,7 @@ QUnit.test('composer: send button is disabled if attachment upload is not finish
await this.start({
async mockFetch(resource, init) {
const res = this._super(...arguments);
if (resource === '/web/binary/upload_attachment') {
if (resource === '/mail/attachment/upload') {
await attachmentUploadedPromise;
}
return res;
@@ -1765,7 +1765,7 @@ QUnit.test('warning on send with shortcut when attempting to post message with s
await this.start({
async mockFetch(resource, init) {
const res = this._super(...arguments);
if (resource === '/web/binary/upload_attachment') {
if (resource === '/mail/attachment/upload') {
// simulates attachment is never finished uploading
await new Promise(() => {});
}
@@ -1887,7 +1887,7 @@ QUnit.test('remove an uploading attachment', async function (assert) {
await this.start({
async mockFetch(resource, init) {
const res = this._super(...arguments);
if (resource === '/web/binary/upload_attachment') {
if (resource === '/mail/attachment/upload') {
// simulates uploading indefinitely
await new Promise(() => {});
}
@@ -1944,7 +1944,7 @@ QUnit.test('remove an uploading attachment aborts upload', async function (asser
await this.start({
async mockFetch(resource, init) {
const res = this._super(...arguments);
if (resource === '/web/binary/upload_attachment') {
if (resource === '/mail/attachment/upload') {
// simulates uploading indefinitely
await new Promise(() => {});
}
@@ -2027,7 +2027,7 @@ QUnit.test('send message only once when button send is clicked twice quickly', a
this.data['mail.channel'].records.push({ id: 20 });
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_post') {
if (route === '/mail/message/post') {
assert.step('message_post');
}
return this._super(...arguments);
@@ -2060,7 +2060,7 @@ QUnit.test('send message only once when enter is pressed twice quickly', async f
this.data['mail.channel'].records.push({ id: 20 });
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_post') {
if (route === '/mail/message/post') {
assert.step('message_post');
}
return this._super(...arguments);
@@ -2105,7 +2105,7 @@ QUnit.test('[technical] does not crash when an attachment is removed before its
await this.start({
async mockFetch(resource) {
const _super = this._super.bind(this, ...arguments);
if (resource === '/web/binary/upload_attachment') {
if (resource === '/mail/attachment/upload') {
await uploadPromise;
}
return _super();
@@ -378,15 +378,15 @@ QUnit.test('"reply to" composer should log note if message replied to is a note'
});
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_post') {
assert.step('message_post');
if (route === '/mail/message/post') {
assert.step('/mail/message/post');
assert.strictEqual(
args.kwargs.message_type,
args.post_data.message_type,
"comment",
"should set message type as 'comment'"
);
assert.strictEqual(
args.kwargs.subtype_xmlid,
args.post_data.subtype_xmlid,
"mail.mt_note",
"should set subtype_xmlid as 'note'"
);
@@ -426,7 +426,7 @@ QUnit.test('"reply to" composer should log note if message replied to is a note'
await afterNextRender(() =>
document.querySelector('.o_Composer_buttonSend').click()
);
assert.verifySteps(['message_post']);
assert.verifySteps(['/mail/message/post']);
});
QUnit.test('"reply to" composer should send message if message replied to is not a note', async function (assert) {
@@ -449,15 +449,15 @@ QUnit.test('"reply to" composer should send message if message replied to is not
});
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_post') {
assert.step('message_post');
if (route === '/mail/message/post') {
assert.step('/mail/message/post');
assert.strictEqual(
args.kwargs.message_type,
args.post_data.message_type,
"comment",
"should set message type as 'comment'"
);
assert.strictEqual(
args.kwargs.subtype_xmlid,
args.post_data.subtype_xmlid,
"mail.mt_comment",
"should set subtype_xmlid as 'comment'"
);
@@ -497,7 +497,7 @@ QUnit.test('"reply to" composer should send message if message replied to is not
await afterNextRender(() =>
document.querySelector('.o_Composer_buttonSend').click()
);
assert.verifySteps(['message_post']);
assert.verifySteps(['/mail/message/post']);
});
QUnit.test('error notifications should not be shown in Inbox', async function (assert) {
@@ -542,7 +542,7 @@ QUnit.test('show subject of message in Inbox', async function (assert) {
body: "not empty",
id: 100,
model: 'mail.channel', // random existing model
needaction: true, // message_fetch domain
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId], // not needed, for consistency
subject: "Salutations, voyageur", // will be asserted in the test
});
@@ -1031,27 +1031,22 @@ QUnit.test('default thread rendering', async function (assert) {
});
QUnit.test('initially load messages from inbox', async function (assert) {
assert.expect(4);
assert.expect(3);
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_fetch') {
assert.step('message_fetch');
if (route === '/mail/inbox/messages') {
assert.step('/mail/channel/messages');
assert.strictEqual(
args.kwargs.limit,
args.limit,
30,
"should fetch up to 30 messages"
);
assert.deepEqual(
args.kwargs.domain,
[["needaction", "=", true]],
"should fetch needaction messages"
);
}
return this._super(...arguments);
},
});
assert.verifySteps(['message_fetch']);
assert.verifySteps(['/mail/channel/messages']);
});
QUnit.test('default select thread in discuss params', async function (assert) {
@@ -1095,7 +1090,7 @@ QUnit.test('auto-select thread in discuss context', async function (assert) {
});
QUnit.test('load single message from channel initially', async function (assert) {
assert.expect(7);
assert.expect(6);
// channel expected to be rendered, with a random unique id that will be referenced in the test
this.data['mail.channel'].records.push({ id: 20 });
@@ -1113,21 +1108,12 @@ QUnit.test('load single message from channel initially', async function (assert)
},
},
async mockRPC(route, args) {
if (args.method === 'message_fetch') {
if (route === '/mail/channel/messages') {
assert.strictEqual(
args.kwargs.limit,
args.limit,
30,
"should fetch up to 30 messages"
);
assert.deepEqual(
args.kwargs.domain,
[
["message_type", "!=", "user_notification"],
["model", "=", "mail.channel"],
["res_id", "=", 20]
],
"should fetch messages from channel"
);
}
return this._super(...arguments);
},
@@ -1229,7 +1215,7 @@ QUnit.test('basic rendering of message', async function (assert) {
);
assert.strictEqual(
message.querySelector(`:scope .o_Message_authorAvatar`).dataset.src,
"/web/image/res.partner/11/avatar_128",
"/mail/channel/20/partner/11/avatar_128",
"should have url of message in author avatar sidebar"
);
assert.strictEqual(
@@ -1409,7 +1395,7 @@ QUnit.test('inbox messages are never squashed', async function (assert) {
id: 100, // random unique id, will be referenced in the test
message_type: 'comment', // must be a squash-able type-
model: 'mail.channel', // to link message to channel
needaction: true, // necessary for message_fetch domain
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId], // for consistency
res_id: 20, // id of related channel
},
@@ -1420,7 +1406,7 @@ QUnit.test('inbox messages are never squashed', async function (assert) {
id: 101, // random unique id, will be referenced in the test
message_type: 'comment', // must be a squash-able type
model: 'mail.channel', // to link message to channel
needaction: true, // necessary for message_fetch domain
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId], // for consistency
res_id: 20, // id of related channel
}
@@ -1506,8 +1492,8 @@ QUnit.test('load all messages from channel initially, less than fetch limit (29
},
},
async mockRPC(route, args) {
if (args.method === 'message_fetch') {
assert.strictEqual(args.kwargs.limit, 30, "should fetch up to 30 messages");
if (route === '/mail/channel/messages') {
assert.strictEqual(args.limit, 30, "should fetch up to 30 messages");
}
return this._super(...arguments);
},
@@ -2299,8 +2285,6 @@ QUnit.test('inbox: mark all messages as read', async function (assert) {
body: "not empty",
id: 100, // random unique id, useful to link notification
model: 'mail.channel',
// needaction needs to be set here for message_fetch domain, because
// mocked models don't have computed fields
needaction: true,
res_id: 20,
},
@@ -2309,8 +2293,6 @@ QUnit.test('inbox: mark all messages as read', async function (assert) {
body: "not empty",
id: 101, // random unique id, useful to link notification
model: 'mail.channel',
// needaction needs to be set here for message_fetch domain, because
// mocked models don't have computed fields
needaction: true,
res_id: 20,
}
@@ -2709,7 +2691,7 @@ QUnit.test('composer state: attachments save and restore', async function (asser
});
QUnit.test('post a simple message', async function (assert) {
assert.expect(15);
assert.expect(16);
// channel expected to be found in the sidebar
// with a random unique id that will be referenced in the test
@@ -2723,29 +2705,34 @@ QUnit.test('post a simple message', async function (assert) {
},
async mockRPC(route, args) {
const res = await this._super(...arguments);
if (args.method === 'message_post') {
if (route === '/mail/message/post') {
assert.step('message_post');
assert.strictEqual(
args.args[0],
args.thread_model,
'mail.channel',
"should post message to channel"
);
assert.strictEqual(
args.thread_id,
20,
"should post message to channel Id 20"
);
assert.strictEqual(
args.kwargs.body,
args.post_data.body,
"Test",
"should post with provided content in composer input"
);
assert.strictEqual(
args.kwargs.message_type,
args.post_data.message_type,
"comment",
"should set message type as 'comment'"
);
assert.strictEqual(
args.kwargs.subtype_xmlid,
args.post_data.subtype_xmlid,
"mail.mt_comment",
"should set subtype_xmlid as 'comment'"
);
postedMessageId = res;
postedMessageId = res.id;
}
return res;
},
@@ -2888,7 +2875,7 @@ QUnit.test('rendering of inbox message', async function (assert) {
body: "not empty",
id: 100,
model: 'res.partner', // random existing model
needaction: true, // for message_fetch domain
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId], // for consistency
record_name: 'Refactoring', // random name, will be asserted in the test
res_id: 20, // random related id
@@ -3154,8 +3141,6 @@ QUnit.test('reply to message from inbox (message linked to document)', async fun
date: "2019-04-20 11:00:00",
id: 100, // random unique id, will be used to link notification to message
message_type: 'comment',
// needaction needs to be set here for message_fetch domain, because
// mocked models don't have computed fields
needaction: true,
model: 'res.partner',
record_name: 'Refactoring',
@@ -3168,25 +3153,25 @@ QUnit.test('reply to message from inbox (message linked to document)', async fun
});
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_post') {
if (route === '/mail/message/post') {
assert.step('message_post');
assert.strictEqual(
args.model,
args.thread_model,
'res.partner',
"should post message to record with model 'res.partner'"
);
assert.strictEqual(
args.args[0],
args.thread_id,
20,
"should post message to record with Id 20"
);
assert.strictEqual(
args.kwargs.body,
args.post_data.body,
"Test",
"should post with provided content in composer input"
);
assert.strictEqual(
args.kwargs.message_type,
args.post_data.message_type,
"comment",
"should set message type as 'comment'"
);
@@ -3286,8 +3271,6 @@ QUnit.test('messages marked as read move to "History" mailbox', async function (
body: "not empty",
id: 100, // random unique id, useful to link notification
model: 'mail.channel', // value to link message to channel
// needaction needs to be set here for message_fetch domain, because
// mocked models don't have computed fields
needaction: true,
res_id: 20, // id of related channel
},
@@ -3295,8 +3278,6 @@ QUnit.test('messages marked as read move to "History" mailbox', async function (
body: "not empty",
id: 101, // random unique id, useful to link notification
model: 'mail.channel', // value to link message to channel
// needaction needs to be set here for message_fetch domain, because
// mocked models don't have computed fields
needaction: true,
res_id: 20, // id of related channel
}
@@ -3535,8 +3516,6 @@ QUnit.test('all messages in "Inbox" in "History" after marked all as read', asyn
this.data['mail.message'].records.push({
body: "not empty",
id, // will be used to link notification to message
// needaction needs to be set here for message_fetch domain, because
// mocked models don't have computed fields
needaction: true,
});
// notification to have message in Inbox
@@ -16,15 +16,6 @@ export class FileUploader extends Component {
super(...args);
this._fileInputRef = useRef('fileInput');
this._fileUploadId = _.uniqueId('o_FileUploader_fileupload');
this._onAttachmentUploaded = this._onAttachmentUploaded.bind(this);
}
mounted() {
$(window).on(this._fileUploadId, this._onAttachmentUploaded);
}
willUnmount() {
$(window).off(this._fileUploadId);
}
//--------------------------------------------------------------------------
@@ -36,7 +27,7 @@ export class FileUploader extends Component {
* @returns {Promise}
*/
async uploadFiles(files) {
await this._unlinkExistingAttachments(files);
this._unlinkExistingAttachments(files);
this._createUploadingAttachments(files);
await this._performUpload(files);
this._fileInputRef.el.value = '';
@@ -46,6 +37,10 @@ export class FileUploader extends Component {
this._fileInputRef.el.click();
}
get thread() {
return this.messaging.models['mail.thread'].get(this.props.threadLocalId);
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -70,11 +65,11 @@ export class FileUploader extends Component {
* @returns {FormData}
*/
_createFormData(file) {
let formData = new window.FormData();
formData.append('callback', this._fileUploadId);
const formData = new window.FormData();
formData.append('csrf_token', core.csrf_token);
formData.append('id', this.props.uploadId);
formData.append('model', this.props.uploadModel);
formData.append('is_pending', this.props.isPending);
formData.append('thread_id', this.thread && this.thread.id);
formData.append('thread_model', this.thread && this.thread.model);
formData.append('ufile', file, file.name);
return formData;
}
@@ -114,15 +109,13 @@ export class FileUploader extends Component {
continue;
}
try {
const response = await this.env.browser.fetch('/web/binary/upload_attachment', {
const response = await this.env.browser.fetch('/mail/attachment/upload', {
method: 'POST',
body: this._createFormData(file),
signal: uploadingAttachment.uploadingAbortController.signal,
});
let html = await response.text();
const template = document.createElement('template');
template.innerHTML = html.trim();
window.eval(template.content.firstChild.textContent);
const attachmentData = await response.json();
this._onAttachmentUploaded(attachmentData);
} catch (e) {
if (e.name !== 'AbortError') {
throw e;
@@ -136,7 +129,7 @@ export class FileUploader extends Component {
* @param {FileList|Array} files
* @returns {Promise}
*/
async _unlinkExistingAttachments(files) {
_unlinkExistingAttachments(files) {
for (const file of files) {
const attachment = this.props.attachmentLocalIds
.map(attachmentLocalId => this.messaging.models['mail.attachment'].get(attachmentLocalId))
@@ -154,41 +147,38 @@ export class FileUploader extends Component {
/**
* @private
* @param {jQuery.Event} ev
* @param {...Object} filesData
* @param {Object} filesData
*/
_onAttachmentUploaded(ev, ...filesData) {
for (const fileData of filesData) {
const { error, filename, id, mimetype, name, size } = fileData;
if (error || !id) {
this.env.services['notification'].notify({
type: 'danger',
message: error,
});
const relatedUploadingAttachments = this.messaging.models['mail.attachment']
.find(attachment =>
attachment.filename === filename &&
attachment.isUploading
);
for (const attachment of relatedUploadingAttachments) {
attachment.delete();
}
return;
_onAttachmentUploaded({ accessToken, error, filename, id, mimetype, name, size }) {
if (error || !id) {
this.env.services['notification'].notify({
type: 'danger',
message: error,
});
const relatedUploadingAttachments = this.messaging.models['mail.attachment']
.find(attachment =>
attachment.filename === filename &&
attachment.isUploading
);
for (const attachment of relatedUploadingAttachments) {
attachment.delete();
}
const attachment = this.messaging.models['mail.attachment'].insert(
Object.assign(
{
filename,
id,
mimetype,
name,
size,
},
this.props.newAttachmentExtraData
),
);
this.trigger('o-attachment-created', { attachment });
return;
}
const attachment = this.messaging.models['mail.attachment'].insert(
Object.assign(
{
accessToken,
filename,
id,
mimetype,
name,
size,
},
this.props.newAttachmentExtraData
),
);
this.trigger('o-attachment-created', { attachment });
}
/**
@@ -207,20 +197,22 @@ export class FileUploader extends Component {
Object.assign(FileUploader, {
defaultProps: {
uploadId: 0,
uploadModel: 'mail.compose.message'
isPending: false,
},
props: {
attachmentLocalIds: {
type: Array,
element: String,
},
isPending: Boolean,
newAttachmentExtraData: {
type: Object,
optional: true,
},
uploadId: Number,
uploadModel: String,
threadLocalId: {
type: String,
optional: true,
},
},
template: 'mail.FileUploader',
});
@@ -76,11 +76,17 @@ export class Message extends Component {
* @returns {string}
*/
get avatar() {
if (this.message.author) {
if (this.message.author && (!this.message.originThread || this.message.originThread.model !== 'mail.channel')) {
// TODO FIXME for public user this might not be accessible. task-2223236
// we should probably use the correspondig attachment id + access token
// or create a dedicated route to get message image, checking the access right of the message
return this.message.author.avatarUrl;
} else if (this.message.author && this.message.originThread && this.message.originThread.model === 'mail.channel') {
return `/mail/channel/${this.message.originThread.id}/partner/${this.message.author.id}/avatar_128`;
} else if (this.message.guestAuthor && (!this.message.originThread || this.message.originThread.model !== 'mail.channel')) {
return this.message.guestAuthor.avatarUrl;
} else if (this.message.guestAuthor && this.message.originThread && this.message.originThread.model === 'mail.channel') {
return `/mail/channel/${this.message.originThread.id}/guest/${this.message.guestAuthor.id}/avatar_128`;
} else if (this.message.message_type === 'email') {
return '/mail/static/src/img/email_icon.png';
}
@@ -102,6 +108,9 @@ export class Message extends Component {
* @returns {boolean}
*/
get hasAuthorOpenChat() {
if (this.messaging.currentGuest) {
return false;
}
if (!this.message.author) {
return false;
}
@@ -67,6 +67,11 @@
<t t-esc="message.author.nameOrDisplayName"/>
</div>
</t>
<t t-elif="message.guestAuthor">
<div class="o_Message_authorName text-truncate">
<t t-esc="message.guestAuthor.name"/>
</div>
</t>
<t t-elif="message.email_from">
<a class="o_Message_authorName text-truncate" t-attf-href="mailto:{{ message.email_from }}?subject=Re: {{ message.subject ? message.subject : '' }}">
<t t-esc="message.email_from"/>
@@ -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(
@@ -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();
}
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="mail.ThreadView" owl="1">
<div class="o_ThreadView" t-att-data-correspondent-id="threadView and threadView.thread and threadView.thread.correspondent and threadView.thread.correspondent.id" t-att-data-thread-local-id="threadView and threadView.thread and threadView.thread.localId">
<div class="o_ThreadView" t-att-class="threadView and threadView.extraClass" t-att-data-correspondent-id="threadView and threadView.thread and threadView.thread.correspondent and threadView.thread.correspondent.id" t-att-data-thread-local-id="threadView and threadView.thread and threadView.thread.localId">
<t t-if="threadView">
<t t-if="threadView.topbar">
<ThreadViewTopbar class="border-bottom" localId="threadView.topbar.localId"/>
@@ -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}.`);
@@ -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;
}
@@ -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&amp;signature=${this.checksum}&amp;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',
}),
@@ -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');
}
}
@@ -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);
}
@@ -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(/&nbsp;/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)
@@ -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);
@@ -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,
}),
@@ -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', {
@@ -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)
))
)),
});
}
@@ -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,
@@ -13,6 +13,9 @@ function factory(dependencies) {
//----------------------------------------------------------------------
computeGroups() {
if (!this.messaging.currentPartner) {
return;
}
for (const group of this.groups) {
group.delete();
}
+80 -29
View File
@@ -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(),
@@ -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 ||
@@ -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',
}),
@@ -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
@@ -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 };
}
+152 -64
View File
@@ -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 `
<script language="javascript" type="text/javascript">
var win = window.top.window;
win.jQuery(win).trigger('${callback}', ${JSON.stringify(formattedAttachments)});
</script>
`;
},
};
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`.
@@ -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 [];
}
+2 -2
View File
@@ -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,
@@ -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)."""
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="mail.discuss_public_layout">&lt;!DOCTYPE html&gt;
<html class="h-100">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
<title t-esc="title or 'Odoo'"/>
<link type="image/x-icon" rel="shortcut icon" t-att-href="x_icon or '/web/static/img/favicon.ico'"/>
<script>
window.odoo = {
__session_info__: <t t-out="json.dumps(session_info)"/>,
csrf_token: "<t t-out="request.csrf_token(None)"/>",
debug: "<t t-out="debug"/>",
};
odoo.loadTemplatesPromise = fetch(`/web/webclient/qweb/${odoo.__session_info__.cache_hashes.assets_discuss_public}?bundle=mail.assets_discuss_public`).then(doc => doc.text());
{
const { user_context, cache_hashes } = odoo.__session_info__;
fetch(`/web/webclient/translations/${cache_hashes.translations}?lang=${user_context.lang}`);
}
</script>
<t t-call-assets="mail.assets_common_discuss_public"/>
<t t-call-assets="mail.assets_discuss_public"/>
<t t-out="head or ''"/>
</head>
<body class="d-flex flex-column h-100">
<t t-out="body or ''"/>
</body>
</html>
</template>
<template id="mail.discuss_public_channel_template" name="Discuss Channel Template">
<t t-call="mail.discuss_public_layout">
<t t-set="head">
<script>
odoo.define('mail.discuss_public_boot_channel', async function(require) {
const { boot } = require('@mail/public/discuss_public_boot');
const { createThreadViewFromChannelData } = await boot();
const channelData = <t t-out="json.dumps(channel_sudo.channel_info()[0])"/>;
createThreadViewFromChannelData(channelData);
});
</script>
</t>
</t>
</template>
</odoo>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0"?>
<odoo>
<record id="mail_guest_view_tree" model="ir.ui.view">
<field name="name">mail.guest.tree</field>
<field name="model">mail.guest</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<tree string="Guest">
<field name="id"/>
<field name="name"/>
<field name="country_id"/>
<field name="lang"/>
<field name="timezone"/>
</tree>
</field>
</record>
<record id="mail_guest_action" model="ir.actions.act_window">
<field name="name">User Settings</field>
<field name="res_model">mail.guest</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>
+5
View File
@@ -98,6 +98,11 @@
action="res_users_settings_action"
parent="mail.mail_menu_technical"
sequence="50"/>
<menuitem id="mail_guest_menu"
name="Guests"
action="mail_guest_action"
parent="mail.mail_menu_technical"
sequence="51"/>
<!--
This menuitem will be activated by integrations modules (like github, twitter, ...). It
+5 -2
View File
@@ -20,15 +20,18 @@
'data/mailbot_demo.xml',
],
'assets': {
'mail.assets_discuss_public': [
'mail_bot/static/src/models/*/*.js',
],
'web.assets_backend': [
'mail_bot/static/src/models/messaging_initializer/messaging_initializer.js',
'mail_bot/static/src/models/*/*.js',
'mail_bot/static/src/scss/odoobot_style.scss',
],
'web.tests_assets': [
'mail_bot/static/tests/**/*',
],
'web.qunit_suite_tests': [
'mail_bot/static/src/models/messaging_initializer/messaging_initializer_tests.js',
'mail_bot/static/src/models/*/tests/*.js',
],
},
'license': 'LGPL-3',
+1 -2
View File
@@ -12,7 +12,6 @@ from odoo.tools import image_process
from odoo.tools.translate import _
from odoo.addons.portal.controllers import portal
from odoo.addons.portal.controllers.portal import pager as portal_pager
from odoo.addons.web.controllers.main import Binary
class CustomerPortal(portal.CustomerPortal):
@@ -91,7 +90,7 @@ class CustomerPortal(portal.CustomerPortal):
#
def resize_to_48(b64source):
if not b64source:
b64source = base64.b64encode(Binary.placeholder())
b64source = base64.b64encode(request.env['ir.http']._placeholder())
return image_process(b64source, size=(48, 48))
values = {
+8 -7
View File
@@ -40,21 +40,22 @@ The service is provided by the In App Purchase Odoo platform.
'installable': True,
'auto_install': True,
'assets': {
'mail.assets_discuss_public': [
'sms/static/src/components/*/*',
'sms/static/src/models/*/*.js',
],
'web.assets_backend': [
'sms/static/src/js/fields_phone_widget.js',
'sms/static/src/js/fields_sms_widget.js',
'sms/static/src/components/notification_group/notification_group.js',
'sms/static/src/models/message/message.js',
'sms/static/src/models/notification_group/notification_group.js',
'sms/static/src/components/*/*.js',
'sms/static/src/models/*/*.js',
],
'web.qunit_suite_tests': [
'sms/static/tests/sms_widget_test.js',
'sms/static/src/components/message/message_tests.js',
'sms/static/src/components/notification_list/notification_list_notification_group_tests.js',
'sms/static/src/components/*/tests/*.js',
],
'web.assets_qweb': [
'sms/static/src/components/notification_group/notification_group.xml',
'sms/static/src/components/message/message.xml',
'sms/static/src/components/*/*.xml',
],
},
'license': 'LGPL-3',
+9 -14
View File
@@ -23,6 +23,10 @@ Allows users to send documents by post
],
'auto_install': True,
'assets': {
'mail.assets_discuss_public': [
'snailmail/static/src/components/*/*',
'snailmail/static/src/models/*/*.js',
],
'snailmail.report_assets_snailmail': [
('include', 'web._assets_helpers'),
'web/static/lib/bootstrap/scss/_variables.scss',
@@ -30,27 +34,18 @@ Allows users to send documents by post
'snailmail/static/src/js/snailmail_external_layout.js',
],
'web.assets_backend': [
'snailmail/static/src/components/message/message.js',
'snailmail/static/src/components/notification_group/notification_group.js',
'snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.js',
'snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.js',
'snailmail/static/src/models/message/message.js',
'snailmail/static/src/models/messaging/messaging.js',
'snailmail/static/src/models/notification_group/notification_group.js',
'snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.scss',
'snailmail/static/src/components/*/*.js',
'snailmail/static/src/components/*/*.scss',
'snailmail/static/src/models/*/*.js',
],
'web.tests_assets': [
'snailmail/static/tests/**/*',
],
'web.qunit_suite_tests': [
'snailmail/static/src/components/message/message_tests.js',
'snailmail/static/src/components/notification_list/notification_list_notification_group_tests.js',
'snailmail/static/src/components/*/tests/*.js',
],
'web.assets_qweb': [
'snailmail/static/src/components/message/message.xml',
'snailmail/static/src/components/notification_group/notification_group.xml',
'snailmail/static/src/components/snailmail_error_dialog/snailmail_error_dialog.xml',
'snailmail/static/src/components/snailmail_notification_popover/snailmail_notification_popover.xml',
'snailmail/static/src/components/*/*.xml',
],
},
'license': 'LGPL-3',
+2 -4
View File
@@ -15,8 +15,6 @@ from odoo.http import request, content_disposition
from odoo.osv import expression
from odoo.tools import format_datetime, format_date, is_html_empty
from odoo.addons.web.controllers.main import Binary
_logger = logging.getLogger(__name__)
@@ -377,7 +375,7 @@ class Survey(http.Controller):
model='survey.survey', id=survey_sudo.id, field='background_image',
default_mimetype='image/png')
return Binary._content_image_get_response(status, headers, image_base64)
return request.env['ir.http']._content_image_get_response(status, headers, image_base64)
@http.route('/survey/get_question_image/<string:survey_token>/<string:answer_token>/<int:question_id>/<int:suggested_answer_id>', type='http', auth="public", website=True, sitemap=False)
def survey_get_question_image(self, survey_token, answer_token, question_id, suggested_answer_id):
@@ -395,7 +393,7 @@ class Survey(http.Controller):
model='survey.question.answer', id=suggested_answer_id, field='value_image',
default_mimetype='image/png')
return Binary._content_image_get_response(status, headers, image_base64)
return request.env['ir.http']._content_image_get_response(status, headers, image_base64)
# ----------------------------------------------------------------
# JSON ROUTES to begin / continue survey (ajax navigation) + Tools
@@ -717,6 +717,7 @@ class TestDiscussFullPerformance(TransactionCase):
'out_of_office_date_end': False,
'user_id': self.env.ref('base.public_user').id,
}],
'currentGuest': False,
'current_partner': {
'active': True,
'display_name': 'Ernest Employee',
@@ -214,17 +214,17 @@ class TestDiscuss(TestMailCommon, TestRecipients):
message2 = self.test_record.with_user(self.user_admin).message_post(body='Message 2', partner_ids=[user1.partner_id.id, user2.partner_id.id])
# both notified users should have the 2 messages in Inbox initially
messages = self.env['mail.message'].with_user(user1).message_fetch(domain=[['needaction', '=', True]])
messages = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(messages), 2)
messages = self.env['mail.message'].with_user(user2).message_fetch(domain=[['needaction', '=', True]])
messages = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(messages), 2)
# first user is marking one message as done: the other message is still Inbox, while the other user still has the 2 messages in Inbox
message1.with_user(user1).set_message_done()
messages = self.env['mail.message'].with_user(user1).message_fetch(domain=[['needaction', '=', True]])
messages = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0].get('id'), message2.id)
messages = self.env['mail.message'].with_user(user2).message_fetch(domain=[['needaction', '=', True]])
messages = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(messages), 2)
def test_notification_has_error_filter(self):
+8 -75
View File
@@ -31,14 +31,14 @@ from werkzeug.urls import url_encode, url_decode, iri_to_uri
import odoo
import odoo.modules.registry
from odoo.api import call_kw, Environment
from odoo.modules import get_module_path, get_resource_path, module
from odoo.tools import image_process, html_escape, pycompat, ustr, apply_inheritance_specs, lazy_property, float_repr, osutil
from odoo.api import call_kw
from odoo.modules import get_resource_path, module
from odoo.tools import html_escape, pycompat, ustr, apply_inheritance_specs, lazy_property, float_repr, osutil
from odoo.tools.mimetypes import guess_mimetype
from odoo.tools.translate import _
from odoo.tools.misc import str2bool, xlsxwriter, file_open, file_path
from odoo.tools.safe_eval import safe_eval, time
from odoo import http, tools
from odoo import http
from odoo.http import content_disposition, dispatch_rpc, request, serialize_exception as _serialize_exception
from odoo.exceptions import AccessError, UserError, AccessDenied
from odoo.models import check_method_name
@@ -1376,14 +1376,6 @@ class View(http.Controller):
class Binary(http.Controller):
@staticmethod
def placeholder(image=False):
if not image:
image = 'placeholder.png'
image_path = image.lstrip('/').split('/') if '/' in image else ['web', 'static', 'img', image]
with tools.file_open(get_resource_path(*image_path), 'rb') as fd:
return fd.read()
@http.route(['/web/content',
'/web/content/<string:xmlid>',
'/web/content/<string:xmlid>/<string:filename>',
@@ -1395,22 +1387,9 @@ class Binary(http.Controller):
filename=None, filename_field='name', unique=None, mimetype=None,
download=None, data=None, token=None, access_token=None, **kw):
return self._get_content_common(xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
return request.env['ir.http']._get_content_common(xmlid=xmlid, model=model, res_id=id, field=field, unique=unique, filename=filename,
filename_field=filename_field, download=download, mimetype=mimetype, access_token=access_token, token=token)
def _get_content_common(self, xmlid, model, id, field, unique, filename, filename_field, download, mimetype, access_token, token):
status, headers, content = request.env['ir.http'].binary_content(
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
filename_field=filename_field, download=download, mimetype=mimetype, access_token=access_token)
if status != 200:
return request.env['ir.http']._response_by_status(status, headers, content)
else:
content_base64 = base64.b64decode(content)
headers.append(('Content-Length', len(content_base64)))
response = request.make_response(content_base64, headers)
return response
@http.route(['/web/assets/debug/<string:filename>',
'/web/assets/debug/<path:extra>/<string:filename>',
'/web/assets/<int:id>/<string:filename>',
@@ -1421,7 +1400,7 @@ class Binary(http.Controller):
[('url', '=like', f'/web/assets/%/{extra}/{filename}' if extra else f'/web/assets/%/{filename}')],
fields=['id'], limit=1)[0]['id']
return self._get_content_common(xmlid=None, model='ir.attachment', id=id, field='datas', unique=unique, filename=filename,
return request.env['ir.http']._get_content_common(xmlid=None, model='ir.attachment', res_id=id, field='datas', unique=unique, filename=filename,
filename_field='name', download=None, mimetype=None, access_token=None, token=None)
@http.route(['/web/image',
@@ -1446,56 +1425,11 @@ class Binary(http.Controller):
download=None, width=0, height=0, crop=False, access_token=None,
**kwargs):
# other kwargs are ignored on purpose
return self._content_image(xmlid=xmlid, model=model, id=id, field=field,
return request.env['ir.http']._content_image(xmlid=xmlid, model=model, res_id=id, field=field,
filename_field=filename_field, unique=unique, filename=filename, mimetype=mimetype,
download=download, width=width, height=height, crop=crop,
quality=int(kwargs.get('quality', 0)), access_token=access_token)
def _content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas',
filename_field='name', unique=None, filename=None, mimetype=None,
download=None, width=0, height=0, crop=False, quality=0, access_token=None,
**kwargs):
status, headers, image_base64 = request.env['ir.http'].binary_content(
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
filename_field=filename_field, download=download, mimetype=mimetype,
default_mimetype='image/png', access_token=access_token)
return Binary._content_image_get_response(
status, headers, image_base64, model=model, id=id, field=field, download=download,
width=width, height=height, crop=crop, quality=quality)
@staticmethod
def _content_image_get_response(
status, headers, image_base64, model='ir.attachment', id=None,
field='datas', download=None, width=0, height=0, crop=False,
quality=0):
if status in [301, 304] or (status != 200 and download):
return request.env['ir.http']._response_by_status(status, headers, image_base64)
if not image_base64:
placeholder_filename = False
if model in request.env:
placeholder_filename = request.env[model]._get_placeholder_filename(field)
placeholder_content = Binary.placeholder(image=placeholder_filename)
# Since we set a placeholder for any missing image, the status must be 200. In case one
# wants to configure a specific 404 page (e.g. though nginx), a 404 status will cause
# troubles.
status = 200
image_base64 = base64.b64encode(placeholder_content)
if not (width or height):
width, height = odoo.tools.image_guess_size_from_field_name(field)
try:
image_base64 = image_process(image_base64, size=(int(width), int(height)), crop=crop, quality=int(quality))
except Exception:
return request.not_found()
content = base64.b64decode(image_base64)
headers = http.set_safe_image_headers(headers, content)
response = request.make_response(content, headers)
response.status_code = status
return response
# backward compatibility
@http.route(['/web/binary/image'], type='http', auth="public")
def content_image_backward_compatibility(self, model, id, field, resize=None, **kw):
@@ -1503,8 +1437,7 @@ class Binary(http.Controller):
height = None
if resize:
width, height = resize.split(",")
return self.content_image(model=model, id=id, field=field, width=width, height=height)
return request.env['ir.http']._content_image(model=model, res_id=id, field=field, width=width, height=height)
@http.route('/web/binary/upload', type='http', auth="user")
@serialize_exception
+77 -13
View File
@@ -1,16 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import hashlib
import json
from odoo import api, models
import odoo
from odoo import api, http, models
from odoo.http import request
from odoo.tools import ustr
from odoo.tools import file_open, image_process, ustr
from odoo.addons.web.controllers.main import HomeStaticTemplateHelpers
import odoo
class Http(models.AbstractModel):
_inherit = 'ir.http'
@@ -31,7 +32,9 @@ class Http(models.AbstractModel):
'web.max_file_upload_size',
default=128 * 1024 * 1024, # 128MiB
))
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)
session_info = {
"uid": request.session.uid,
"is_system": user._is_system() if request.session.uid else False,
@@ -53,26 +56,26 @@ class Http(models.AbstractModel):
'profile_params': request.session.profile_params,
"max_file_upload_size": max_file_upload_size,
"home_action_id": user.action_id.id,
"cache_hashes": {
"translations": translation_hash,
},
"currencies": self.sudo().get_currencies(),
}
if self.env.user.has_group('base.group_user'):
# the following is only useful in the context of a webclient bootstrapping
# but is still included in some other calls (e.g. '/web/session/authenticate')
# to avoid access errors and unnecessary information, it is only included for users
# with access to the backend ('internal'-type users)
mods = odoo.conf.server_wide_modules or []
if request.db:
mods = list(request.registry._init_modules) + mods
qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle="web.assets_qweb")
lang = user_context.get("lang")
translation_hash = request.env['ir.translation'].get_web_translations_hash(mods, lang)
menus = request.env['ir.ui.menu'].load_menus(request.session.debug)
ordered_menus = {str(k): v for k, v in menus.items()}
menu_json_utf8 = json.dumps(ordered_menus, default=ustr, sort_keys=True).encode()
cache_hashes = {
session_info['cache_hashes'].update({
"load_menus": hashlib.sha512(menu_json_utf8).hexdigest()[:64], # sha512/256
"qweb": qweb_checksum,
"translations": translation_hash,
}
})
session_info.update({
# current_company should be default_company
"user_companies": {
@@ -84,10 +87,8 @@ class Http(models.AbstractModel):
} for comp in user.company_ids
},
},
"currencies": self.get_currencies(),
"show_effect": True,
"display_switch_company_menu": user.has_group('base.group_multi_company') and len(user.company_ids) > 1,
"cache_hashes": cache_hashes,
})
return session_info
@@ -116,3 +117,66 @@ class Http(models.AbstractModel):
Currency = request.env['res.currency']
currencies = Currency.search([]).read(['symbol', 'position', 'decimal_places'])
return {c['id']: {'symbol': c['symbol'], 'position': c['position'], 'digits': [69,c['decimal_places']]} for c in currencies}
@api.model
def _get_content_common(self, xmlid=None, model='ir.attachment', res_id=None, field='datas',
unique=None, filename=None, filename_field='name', download=None, mimetype=None,
access_token=None, token=None):
status, headers, content = self.binary_content(
xmlid=xmlid, model=model, id=res_id, field=field, unique=unique, filename=filename,
filename_field=filename_field, download=download, mimetype=mimetype, access_token=access_token
)
if status != 200:
return self._response_by_status(status, headers, content)
else:
content_base64 = base64.b64decode(content)
headers.append(('Content-Length', len(content_base64)))
response = request.make_response(content_base64, headers)
return response
@api.model
def _content_image(self, xmlid=None, model='ir.attachment', res_id=None, field='datas',
filename_field='name', unique=None, filename=None, mimetype=None, download=None,
width=0, height=0, crop=False, quality=0, access_token=None, **kwargs):
status, headers, image_base64 = self.binary_content(
xmlid=xmlid, model=model, id=res_id, field=field, unique=unique, filename=filename,
filename_field=filename_field, download=download, mimetype=mimetype,
default_mimetype='image/png', access_token=access_token
)
return self._content_image_get_response(
status, headers, image_base64, model=model, field=field, download=download,
width=width, height=height, crop=crop, quality=quality)
@api.model
def _content_image_get_response(self, status, headers, image_base64, model='ir.attachment',
field='datas', download=None, width=0, height=0, crop=False, quality=0):
if status in [301, 304] or (status != 200 and download):
return self._response_by_status(status, headers, image_base64)
if not image_base64:
placeholder_filename = False
if model in self.env:
placeholder_filename = self.env[model]._get_placeholder_filename(field)
placeholder_content = self._placeholder(image=placeholder_filename)
# Since we set a placeholder for any missing image, the status must be 200. In case one
# wants to configure a specific 404 page (e.g. though nginx), a 404 status will cause
# troubles.
status = 200
image_base64 = base64.b64encode(placeholder_content)
if not (width or height):
width, height = odoo.tools.image_guess_size_from_field_name(field)
try:
image_base64 = image_process(image_base64, size=(int(width), int(height)), crop=crop, quality=int(quality))
except Exception:
return request.not_found()
content = base64.b64decode(image_base64)
headers = http.set_safe_image_headers(headers, content)
response = request.make_response(content, headers)
response.status_code = status
return response
@api.model
def _placeholder(self, image=False):
if not image:
image = 'web/static/img/placeholder.png'
with file_open(image, 'rb', filter_ext=('.png', '.jpg')) as fd:
return fd.read()
+2 -4
View File
@@ -16,11 +16,9 @@ from odoo.http import request
from odoo import http, tools, _, SUPERUSER_ID
from odoo.addons.http_routing.models.ir_http import slug, unslug
from odoo.exceptions import UserError
from odoo.modules.module import get_module_path, get_resource_path
from odoo.tools.misc import file_open
from odoo.modules.module import get_resource_path
from odoo.tools.mimetypes import guess_mimetype
from odoo.tools.image import image_data_uri, base64_to_image
from odoo.addons.web.controllers.main import Binary
from odoo.addons.base.models.assetsbundle import AssetsBundle
from ..models.ir_attachment import SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_IMAGE_MIMETYPES
@@ -616,7 +614,7 @@ class Web_Editor(http.Controller):
_, _, image_base64 = request.env['ir.http'].binary_content(
xmlid=img_key, model='ir.attachment', field='datas', default_mimetype='image/png')
if not image_base64:
image_base64 = b64encode(Binary.placeholder())
image_base64 = b64encode(request.env['ir.http']._placeholder())
image = base64_to_image(image_base64)
width, height = tuple(str(size) for size in image.size)
root = etree.fromstring(svg)
+2 -2
View File
@@ -21,7 +21,6 @@ from odoo.http import request
from odoo.osv import expression
from odoo.tools import OrderedSet, escape_psql
from odoo.addons.http_routing.models.ir_http import slug, slugify, _guess_mimetype
from odoo.addons.web.controllers.main import Binary
from odoo.addons.portal.controllers.portal import pager as portal_pager
from odoo.addons.portal.controllers.web import Home
@@ -619,7 +618,8 @@ class WebsiteBinary(http.Controller):
kw['id'] = int(id)
if unique:
kw['unique'] = unique
return Binary().content_image(**kw)
kw['res_id'] = kw.pop('id', None)
return request.env['ir.http']._content_image(**kw)
# if not icon provided in DOM, browser tries to access /favicon.ico, eg when opening an order pdf
@http.route(['/favicon.ico'], type='http', auth='public', website=True, multilang=False, sitemap=False)
+9 -8
View File
@@ -294,14 +294,15 @@ class WebsiteVisitor(models.Model):
def _update_visitor_timezone(self, timezone):
""" We need to do this part here to avoid concurrent updates error. """
try:
with self.env.cr.savepoint():
query_lock = "SELECT * FROM website_visitor where id = %s FOR NO KEY UPDATE NOWAIT"
self.env.cr.execute(query_lock, (self.id,), log_exceptions=False)
query = "UPDATE website_visitor SET timezone = %s WHERE id = %s"
self.env.cr.execute(query, (timezone, self.id), log_exceptions=False)
except Exception:
pass
query = """
UPDATE website_visitor
SET timezone = %s
WHERE id IN (
SELECT id FROM website_visitor WHERE id = %s
FOR NO KEY UPDATE SKIP LOCKED
)
"""
self.env.cr.execute(query, (timezone, self.id))
def _update_visitor_last_visit(self):
""" We need to do this part here to avoid concurrent updates error. """
+10 -9
View File
@@ -21,6 +21,10 @@ Allow website visitors to chat with the collaborators. This module also brings a
'data/website_livechat_data.xml',
],
'assets': {
'mail.assets_discuss_public': [
'website_livechat/static/src/components/*/*',
'website_livechat/static/src/models/*/*.js',
],
'web.assets_frontend': [
'mail/static/src/js/utils.js',
'im_livechat/static/src/legacy/public_livechat.js',
@@ -32,24 +36,21 @@ Allow website visitors to chat with the collaborators. This module also brings a
'website_livechat/static/src/js/**/*',
],
'web.assets_backend': [
'website_livechat/static/src/components/visitor_banner/visitor_banner.js',
'website_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js',
'website_livechat/static/src/models/thread/thread.js',
'website_livechat/static/src/models/visitor/visitor.js',
'website_livechat/static/src/components/visitor_banner/visitor_banner.scss',
'website_livechat/static/src/components/*/*.js',
'website_livechat/static/src/components/*/*.scss',
'website_livechat/static/src/models/*/*.js',
],
'web.assets_tests': [
'website_livechat/static/tests/tours/**/*',
],
'web.qunit_suite_tests': [
'website_livechat/static/src/components/discuss/discuss_tests.js',
'website_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler_tests.js',
'website_livechat/static/src/components/*/tests/*.js',
'website_livechat/static/src/models/*/tests/*.js',
'website_livechat/static/tests/helpers/mock_models.js',
'website_livechat/static/tests/helpers/mock_server.js',
],
'web.assets_qweb': [
'website_livechat/static/src/components/discuss/discuss.xml',
'website_livechat/static/src/components/visitor_banner/visitor_banner.xml',
'website_livechat/static/src/components/*/*.xml',
],
},
'license': 'LGPL-3',
+1 -1
View File
@@ -6469,7 +6469,7 @@ Fields:
set on web/static/img by default, or the
complete path to access it (eg: module/path/to/image.png).
If a falsy value is returned, Binary.placeholder() will use
If a falsy value is returned, "ir.http"._placeholder() will use
the default placeholder 'web/static/img/placeholder.png'.
"""
return False