diff --git a/addons/hr/models/res_partner.py b/addons/hr/models/res_partner.py index 22d315aa68f..e51f0069dd7 100644 --- a/addons/hr/models/res_partner.py +++ b/addons/hr/models/res_partner.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, models +from odoo import models from odoo.exceptions import AccessError @@ -9,19 +9,6 @@ class Partner(models.Model): _inherit = ['res.partner'] - @api.model - def get_static_mention_suggestions(self): - """ Extend the mail's static mention suggestions by adding the employees. """ - suggestions = super(Partner, self).get_static_mention_suggestions() - - try: - employee_group = self.env.ref('base.group_user') - hr_suggestions = [{'id': user.partner_id.id, 'name': user.name, 'email': user.email} for user in employee_group.users] - suggestions.append(hr_suggestions) - return suggestions - except AccessError: - return suggestions - def name_get(self): """ Override to allow an employee to see its private address in his profile. This avoids to relax access rules on `res.parter` and to add an `ir.rule`. diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index fbc56cde19b..d3f6100afe3 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -604,7 +604,14 @@ class Channel(models.Model): self.filtered(lambda channel: channel.is_chat).mapped('channel_last_seen_partner_ids').sudo().write({'is_pinned': True}) - message = super(Channel, self.with_context(mail_create_nosubscribe=True)).message_post(message_type=message_type, moderation_status=moderation_status, **kwargs) + # mail_post_autofollow=False is necessary to prevent adding followers + # when using mentions in channels. Followers should not be added to + # channels, and especially not automatically (because channel membership + # should be managed with channel.partner instead). + # The current client code might be setting the key to True on sending + # message but it is only useful when targeting customers in chatter. + # This value should simply be set to False in channels no matter what. + message = super(Channel, self.with_context(mail_create_nosubscribe=True, mail_post_autofollow=False)).message_post(message_type=message_type, moderation_status=moderation_status, **kwargs) # Notifies the message author when his message is pending moderation if required on channel. # The fields "email_from" and "reply_to" are filled in automatically by method create in model mail.message. @@ -1165,7 +1172,7 @@ class Channel(models.Model): [('channel_partner_ids', 'in', [self.env.user.partner_id.id])] ]) ]) - return self.search_read(domain, ['id', 'name', 'public'], limit=limit) + return self.search_read(domain, ['id', 'name', 'public', 'channel_type'], limit=limit) @api.model def channel_fetch_listeners(self, uuid): diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index 2167c2d5354..0fbad5d9a5e 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -6,6 +6,7 @@ import logging from odoo import _, api, fields, models, tools from odoo.addons.bus.models.bus_presence import AWAY_TIMER from odoo.addons.bus.models.bus_presence import DISCONNECTION_TIMER +from odoo.exceptions import AccessError from odoo.osv import expression _logger = logging.getLogger(__name__) @@ -71,13 +72,20 @@ class Partner(models.Model): def mail_partner_format(self): self.ensure_one() - return { - "id": self.name_get()[0][0], - "display_name": self.name_get()[0][1], + internal_users = self.user_ids - self.user_ids.filtered('share') + main_user = internal_users[0] if len(internal_users) > 0 else self.user_ids[0] if len(self.user_ids) > 0 else self.env['res.users'] + res = { + "id": self.id, + "display_name": self.display_name, "name": self.name, + "email": self.email, "active": self.active, "im_status": self.im_status, + "user_id": main_user.id, } + if main_user: + res["is_internal_user"] = not main_user.share + return res @api.model def get_needaction_count(self): @@ -106,36 +114,39 @@ class Partner(models.Model): @api.model def get_static_mention_suggestions(self): - """ To be overwritten to return the id, name and email of partners used as static mention - suggestions loaded once at webclient initialization and stored client side. """ - return [] + """ Returns static mention suggestions of partners, loaded once at webclient initialization + and stored client side. All the internal users are returned. + The return format is a list of partner data (as per returned by `mail_partner_format()`). + """ + partners = self.env['res.partner'] + try: + partners = self.env.ref('base.group_user').users.partner_id + except AccessError: + pass + return [partner.mail_partner_format() for partner in partners] @api.model - def get_mention_suggestions(self, search, limit=8): - """ Return 'limit'-first partners' id, name and email such that the name or email matches a - 'search' string. Prioritize users, and then extend the research to all partners. """ + def get_mention_suggestions(self, search, limit=8, channel_id=None): + """ Return 'limit'-first partners' such that the name or email matches a 'search' string. + Prioritize partners that are also users, and then extend the research to all partners. + If channel_id is given, only members of this channel are returned. + The return format is a list of partner data (as per returned by `mail_partner_format()`). + """ search_dom = expression.OR([[('name', 'ilike', search)], [('email', 'ilike', search)]]) search_dom = expression.AND([[('active', '=', True)], search_dom]) - fields = ['id', 'name', 'email'] + if channel_id: + search_dom = expression.AND([[('channel_ids', 'in', channel_id)], search_dom]) - # Search users + # Search partners that are also users domain = expression.AND([[('user_ids.id', '!=', False), ('user_ids.active', '=', True)], search_dom]) - users = self.search_read(domain, fields, limit=limit) + partners = self.search(domain, limit=limit) - # Search partners if less than 'limit' users found - partners = [] - if len(users) < limit: - partners = self.search_read(search_dom, fields, limit=limit) - # Remove duplicates - partners = [p for p in partners if not len([u for u in users if u['id'] == p['id']])] + # Search partners that are not users if less than 'limit' partner that are users found + remaining_limit = limit - len(partners) + if remaining_limit > 0: + partners |= self.search(expression.AND([[('id', 'not in', partners.ids)], search_dom]), limit=remaining_limit) - # add OdooBot even if its partner is archived - if len(partners) + len(users) < limit and "odoobot".startswith(search.lower()): - odoobot = self.env.ref("base.partner_root") - if not any(elem['id'] == odoobot.id for elem in partners): - partners.append(odoobot.read(fields)[0]) - - return [users, partners] + return [partner.mail_partner_format() for partner in partners] @api.model def im_search(self, name, limit=20): diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js index ff1c9333acf..d30d2ea3f09 100644 --- a/addons/mail/static/src/components/composer/composer_tests.js +++ b/addons/mail/static/src/components/composer/composer_tests.js @@ -402,8 +402,6 @@ QUnit.test('use a canned response', async function (assert) { await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, ":"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -462,10 +460,8 @@ QUnit.test('use a canned response some text', async function (assert) { "bluhbluh ", "text content of composer should have content" ); - await afterNextRender(() => - document.execCommand('insertText', false, ":") - ); await afterNextRender(() => { + document.execCommand('insertText', false, ":"); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -518,8 +514,6 @@ QUnit.test('add an emoji after a canned response', async function (assert) { await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, ":"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -619,8 +613,6 @@ QUnit.test('mention a channel', async function (assert) { await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, "#"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -675,10 +667,8 @@ QUnit.test('mention a channel after some text', async function (assert) { "bluhbluh ", "text content of composer should have content" ); - await afterNextRender(() => - document.execCommand('insertText', false, "#") - ); await afterNextRender(() => { + document.execCommand('insertText', false, "#"); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -727,8 +717,6 @@ QUnit.test('add an emoji after a channel mention', async function (assert) { await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, "#"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -833,8 +821,6 @@ QUnit.test('use a command for a specific channel type', async function (assert) await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, "/"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -922,10 +908,8 @@ QUnit.test('use a command after some text', async function (assert) { "bluhbluh ", "text content of composer should have content" ); - await afterNextRender(() => - document.execCommand('insertText', false, "/") - ); await afterNextRender(() => { + document.execCommand('insertText', false, "/"); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -977,8 +961,6 @@ QUnit.test('add an emoji after a command', async function (assert) { await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, "/"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -2220,61 +2202,6 @@ QUnit.test('send message only once when enter is pressed twice quickly', async f ); }); -QUnit.test("mentioned partners should not be notified if they are not member of current channel", async function (assert) { - assert.expect(4); - - this.data['res.partner'].records.push({ - email: "testpartner@example.com", - name: "TestPartner", - }); - this.data['mail.channel'].records.push({ - id: 10, - members: [this.data.currentPartnerId], - }); - await this.start({ - async mockRPC(route, args) { - if (args.model === 'mail.channel' && args.method === 'message_post') { - assert.step('message_post'); - assert.strictEqual( - args.kwargs.partner_ids.length, - 0, - "message_post should not contain mentioned partners that are not members of channel" - ); - } - return this._super(...arguments); - }, - }); - const thread = this.env.models['mail.thread'].findFromIdentifyingData({ - id: 10, - model: 'mail.channel', - }); - await this.createComposerComponent(thread.composer); - await afterNextRender(() => { - document.querySelector(`.o_ComposerTextInput_textarea`).focus(); - document.execCommand('insertText', false, "@"); - document.querySelector(`.o_ComposerTextInput_textarea`) - .dispatchEvent(new window.KeyboardEvent('keydown')); - document.querySelector(`.o_ComposerTextInput_textarea`) - .dispatchEvent(new window.KeyboardEvent('keyup')); - }); - await afterNextRender(() => { - document.querySelector(`.o_ComposerTextInput_textarea`).focus(); - document.execCommand('insertText', false, "Test"); - document.querySelector(`.o_ComposerTextInput_textarea`) - .dispatchEvent(new window.KeyboardEvent('keydown')); - document.querySelector(`.o_ComposerTextInput_textarea`) - .dispatchEvent(new window.KeyboardEvent('keyup')); - }); - await afterNextRender(() => document.querySelector('.o_ComposerSuggestion').click()); - assert.strictEqual( - document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), - "@TestPartner ", - "text content of composer should have mentioned partner + additional whitespace afterwards" - ); - await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click()); - assert.verifySteps(['message_post'], "the message should be posted"); -}); - QUnit.test('[technical] does not crash when an attachment is removed before its upload starts', async function (assert) { // Uploading multiple files uploads attachments one at a time, this test // ensures that there is no crash when an attachment is destroyed before its diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js index 4ae48db623b..da54ab54311 100644 --- a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js @@ -116,9 +116,7 @@ class ComposerSuggestion extends Component { */ _onClick(ev) { ev.preventDefault(); - this.composer.update({ - [this.composer.activeSuggestedRecordName]: [['link', this.record]], - }); + this.composer.update({ activeSuggestedRecord: [['link', this.record]] }); this.composer.insertSuggestion(); this.composer.closeSuggestions(); this.trigger('o-composer-suggestion-clicked'); diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js index ea2153dbf6f..23f08399594 100644 --- a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js +++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js @@ -22,23 +22,23 @@ class ComposerSuggestionList extends Component { const activeSuggestedRecord = composer ? composer.activeSuggestedRecord : undefined; - const extraSuggestedRecordsList = composer - ? composer.extraSuggestedRecordsList + const extraSuggestedRecords = composer + ? composer.extraSuggestedRecords : []; - const mainSuggestedRecordsList = composer - ? composer.mainSuggestedRecordsList + const mainSuggestedRecords = composer + ? composer.mainSuggestedRecords : []; return { activeSuggestedRecord, composer, composerSuggestionModelName: composer && composer.suggestionModelName, - extraSuggestedRecordsList: extraSuggestedRecordsList, - mainSuggestedRecordsList: mainSuggestedRecordsList, + extraSuggestedRecords, + mainSuggestedRecords, }; }, { compareDepth: { - extraSuggestedRecordsList: 1, - mainSuggestedRecordsList: 1, + extraSuggestedRecords: 1, + mainSuggestedRecords: 1, }, }); } diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml index 8bcd87c2d07..9747109a994 100644 --- a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml +++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml @@ -5,7 +5,7 @@