[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:
committed by
Sébastien Theys
parent
8d6bd85d2b
commit
80d74e7ee0
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
|
||||
+6
-11
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
+3
-3
@@ -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&signature=${this.checksum}&model=ir.attachment`;
|
||||
const unique = this.checksum ? `?unique=${this.checksum}` : '';
|
||||
return `/web/image/${this.id}${unique}`;
|
||||
}
|
||||
if (this.fileType === 'application/pdf') {
|
||||
return `/web/static/lib/pdfjs/web/viewer.html?file=/web/content/${this.id}?model%3Dir.attachment`;
|
||||
return `/web/static/lib/pdfjs/web/viewer.html?file=/web/content/${this.id}`;
|
||||
}
|
||||
if (this.fileType && this.fileType.includes('text')) {
|
||||
return `/web/content/${this.id}?model%3Dir.attachment`;
|
||||
return `/web/content/${this.id}`;
|
||||
}
|
||||
if (this.fileType === 'youtu') {
|
||||
const urlArr = this.url.split('/');
|
||||
@@ -192,6 +195,18 @@ function factory(dependencies) {
|
||||
return clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_computeDownloadUrl() {
|
||||
if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') {
|
||||
return `/mail/channel/${this.originThread.id}/attachment/${this.id}`;
|
||||
}
|
||||
const accessToken = this.accessToken ? `?access_token=${this.accessToken}` : '';
|
||||
return `/web/content/ir.attachment/${this.id}/datas${accessToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string|undefined}
|
||||
@@ -249,6 +264,54 @@ function factory(dependencies) {
|
||||
return clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_computeImageLargeUrl() {
|
||||
if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') {
|
||||
return `/mail/channel/${this.originThread.id}/image/${this.id}/400x400`;
|
||||
}
|
||||
const accessToken = this.accessToken ? `?access_token=${this.accessToken}` : '';
|
||||
return `/web/image/${this.id}/400x400${accessToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_computeImageMediumUrl() {
|
||||
if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') {
|
||||
return `/mail/channel/${this.originThread.id}/image/${this.id}/200x200`;
|
||||
}
|
||||
const accessToken = this.accessToken ? `?access_token=${this.accessToken}` : '';
|
||||
return `/web/image/${this.id}/200x200${accessToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_computeImageSmallUrl() {
|
||||
if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') {
|
||||
return `/mail/channel/${this.originThread.id}/image/${this.id}/100x100`;
|
||||
}
|
||||
const accessToken = this.accessToken ? `?access_token=${this.accessToken}` : '';
|
||||
return `/web/image/${this.id}/100x100${accessToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_computeImageTinyUrl() {
|
||||
if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') {
|
||||
return `/mail/channel/${this.originThread.id}/image/${this.id}/38x38`;
|
||||
}
|
||||
const accessToken = this.accessToken ? `?access_token=${this.accessToken}` : '';
|
||||
return `/web/image/${this.id}/38x38${accessToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
@@ -328,6 +391,7 @@ function factory(dependencies) {
|
||||
}
|
||||
|
||||
Attachment.fields = {
|
||||
accessToken: attr(),
|
||||
activities: many2many('mail.activity', {
|
||||
inverse: 'attachments',
|
||||
}),
|
||||
@@ -344,6 +408,9 @@ function factory(dependencies) {
|
||||
displayName: attr({
|
||||
compute: '_computeDisplayName',
|
||||
}),
|
||||
downloadUrl: attr({
|
||||
compute: '_computeDownloadUrl',
|
||||
}),
|
||||
extension: attr({
|
||||
compute: '_computeExtension',
|
||||
}),
|
||||
@@ -354,6 +421,18 @@ function factory(dependencies) {
|
||||
id: attr({
|
||||
required: true,
|
||||
}),
|
||||
imageLargeUrl: attr({
|
||||
compute: '_computeImageLargeUrl',
|
||||
}),
|
||||
imageMediumUrl: attr({
|
||||
compute: '_computeImageMediumUrl',
|
||||
}),
|
||||
imageSmallUrl: attr({
|
||||
compute: '_computeImageSmallUrl',
|
||||
}),
|
||||
imageTinyUrl: attr({
|
||||
compute: '_computeImageTinyUrl',
|
||||
}),
|
||||
isLinkedToComposer: attr({
|
||||
compute: '_computeIsLinkedToComposer',
|
||||
}),
|
||||
|
||||
@@ -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(/ /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)
|
||||
))
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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"><!DOCTYPE html>
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. """
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user