diff --git a/addons/mail/controllers/__init__.py b/addons/mail/controllers/__init__.py index 10fb0480208..0ae7eeab10f 100644 --- a/addons/mail/controllers/__init__.py +++ b/addons/mail/controllers/__init__.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -* # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import discuss from . import mail + +# after mail specifically as discuss module depends on mail +from . import discuss diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py index 80c11ba7681..b520bdcd34e 100644 --- a/addons/mail/models/__init__.py +++ b/addons/mail/models/__init__.py @@ -34,10 +34,6 @@ from . import mail_tracking_value from . import mail_template # discuss -from . import discuss_channel_member -from . import discuss_channel_rtc_session -from . import discuss_channel -from . import mail_guest from . import mail_ice_server from . import mail_shortcode from . import res_users_settings @@ -55,10 +51,13 @@ from . import ir_model from . import ir_model_fields from . import ir_ui_view from . import ir_qweb -from . import ir_websocket from . import res_company from . import res_config_settings -from . import res_partner from . import res_users -from . import res_groups from . import update + +# after mail specifically as discuss module depends on mail +from . import discuss + +# discuss_channel_member must be loaded first +from . import res_partner diff --git a/addons/mail/models/discuss/__init__.py b/addons/mail/models/discuss/__init__.py new file mode 100644 index 00000000000..3dd130faf9d --- /dev/null +++ b/addons/mail/models/discuss/__init__.py @@ -0,0 +1,17 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# mail +from . import mail_message + +# discuss +from . import discuss_channel_member +from . import discuss_channel_rtc_session +from . import discuss_channel +from . import mail_guest + +# odoo models +from . import ir_attachment +from . import ir_websocket +from . import res_groups +from . import res_partner +from . import res_users diff --git a/addons/mail/models/discuss_channel.py b/addons/mail/models/discuss/discuss_channel.py similarity index 99% rename from addons/mail/models/discuss_channel.py rename to addons/mail/models/discuss/discuss_channel.py index 14ce2625372..d1438fb31d4 100644 --- a/addons/mail/models/discuss_channel.py +++ b/addons/mail/models/discuss/discuss_channel.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 @@ -260,7 +259,7 @@ class Channel(models.Model): failing_channels = self.sudo().filtered(lambda channel: channel.channel_type != vals.get('channel_type')) if failing_channels: raise UserError(_('Cannot change the channel type of: %(channel_names)s'), channel_names=', '.join(failing_channels.mapped('name'))) - result = super(Channel, self).write(vals) + result = super().write(vals) if vals.get('group_ids'): self._subscribe_users_automatically() if 'image_128' in vals: @@ -561,7 +560,7 @@ class Channel(models.Model): def _notify_thread(self, message, msg_vals=False, **kwargs): # link message to channel - rdata = super(Channel, self)._notify_thread(message, msg_vals=msg_vals, **kwargs) + rdata = super()._notify_thread(message, msg_vals=msg_vals, **kwargs) message_format_values = message.message_format()[0] bus_notifications = self._channel_message_notifications(message, message_format_values) @@ -583,7 +582,7 @@ class Channel(models.Model): for p in partner: if p.message_bounce >= self.MAX_BOUNCE_LIMIT: self._action_unfollow(p) - return super(Channel, self)._message_receive_bounce(email, partner) + return super()._message_receive_bounce(email, partner) def _message_compute_author(self, author_id=None, email_from=None, raise_on_email=True): return super()._message_compute_author(author_id=author_id, email_from=email_from, raise_on_email=False) @@ -1163,7 +1162,7 @@ class Channel(models.Model): @api.model def get_mention_suggestions(self, search, limit=8): - """ Return 'limit'-first channels' id, name, channel_type and authorizedGroupFullName fields such that the + """ Return 'limit'-first channels' id, name, channel_type and authorizedGroupFullName fields such that the name matches a 'search' string. Exclude channels of type chat (DM) and group. """ domain = expression.AND([ diff --git a/addons/mail/models/discuss_channel_member.py b/addons/mail/models/discuss/discuss_channel_member.py similarity index 100% rename from addons/mail/models/discuss_channel_member.py rename to addons/mail/models/discuss/discuss_channel_member.py diff --git a/addons/mail/models/discuss_channel_rtc_session.py b/addons/mail/models/discuss/discuss_channel_rtc_session.py similarity index 99% rename from addons/mail/models/discuss_channel_rtc_session.py rename to addons/mail/models/discuss/discuss_channel_rtc_session.py index 88e25c39afe..d76133db806 100644 --- a/addons/mail/models/discuss_channel_rtc_session.py +++ b/addons/mail/models/discuss/discuss_channel_rtc_session.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from collections import defaultdict diff --git a/addons/mail/models/discuss/ir_attachment.py b/addons/mail/models/discuss/ir_attachment.py new file mode 100644 index 00000000000..c61cb4ae4ca --- /dev/null +++ b/addons/mail/models/discuss/ir_attachment.py @@ -0,0 +1,16 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class IrAttachment(models.Model): + _inherit = "ir.attachment" + + def _bus_notification_target(self): + self.ensure_one() + if self.res_model == "discuss.channel" and self.res_id: + return self.env["discuss.channel"].browse(self.res_id) + guest = self.env["mail.guest"]._get_guest_from_context() + if self.env.user._is_public() and guest: + return guest + return super()._bus_notification_target() diff --git a/addons/mail/models/ir_websocket.py b/addons/mail/models/discuss/ir_websocket.py similarity index 70% rename from addons/mail/models/ir_websocket.py rename to addons/mail/models/discuss/ir_websocket.py index 8b5c99ac203..84ea5faf383 100644 --- a/addons/mail/models/ir_websocket.py +++ b/addons/mail/models/discuss/ir_websocket.py @@ -4,14 +4,16 @@ from odoo.addons.bus.websocket import wsrequest class IrWebsocket(models.AbstractModel): - _inherit = 'ir.websocket' + _inherit = "ir.websocket" def _get_im_status(self, data): im_status = super()._get_im_status(data) - if 'mail.guest' in data: - im_status['Guest'] = self.env['mail.guest'].sudo().with_context(active_test=False).search_read( - [('id', 'in', data['mail.guest'])], - ['im_status'] + if "mail.guest" in data: + im_status["Guest"] = ( + self.env["mail.guest"] + .sudo() + .with_context(active_test=False) + .search_read([("id", "in", data["mail.guest"])], ["im_status"]) ) return im_status @@ -22,8 +24,8 @@ class IrWebsocket(models.AbstractModel): # request. req = request or wsrequest channels = list(channels) # do not alter original list - guest_sudo = self.env['mail.guest']._get_guest_from_request(req).sudo() - discuss_channels = self.env['discuss.channel'] + guest_sudo = self.env["mail.guest"]._get_guest_from_request(req).sudo() + discuss_channels = self.env["discuss.channel"] if req.session.uid: discuss_channels = self.env.user.partner_id.channel_ids elif guest_sudo: @@ -41,7 +43,11 @@ class IrWebsocket(models.AbstractModel): # retrieve the current guest. Let's retrieve the proper # request. req = request or wsrequest - guest_sudo = self.env['mail.guest']._get_guest_from_request(req).sudo() + guest_sudo = self.env["mail.guest"]._get_guest_from_request(req).sudo() if not guest_sudo: return - guest_sudo.env['bus.presence'].update_presence(inactivity_period, identity_field='guest_id', identity_value=guest_sudo.id) + guest_sudo.env["bus.presence"].update_presence( + inactivity_period, + identity_field="guest_id", + identity_value=guest_sudo.id, + ) diff --git a/addons/mail/models/mail_guest.py b/addons/mail/models/discuss/mail_guest.py similarity index 99% rename from addons/mail/models/mail_guest.py rename to addons/mail/models/discuss/mail_guest.py index af37c40504a..f5465b2f225 100644 --- a/addons/mail/models/mail_guest.py +++ b/addons/mail/models/discuss/mail_guest.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import pytz diff --git a/addons/mail/models/discuss/mail_message.py b/addons/mail/models/discuss/mail_message.py new file mode 100644 index 00000000000..93c6cce3de9 --- /dev/null +++ b/addons/mail/models/discuss/mail_message.py @@ -0,0 +1,23 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class MailMessage(models.Model): + _inherit = "mail.message" + + def _message_format_extras(self, format_reply): + self.ensure_one() + vals = super()._message_format_extras(format_reply) + if format_reply and self.model == "discuss.channel" and self.parent_id: + vals["parentMessage"] = self.parent_id.message_format(format_reply=False)[0] + return vals + + def _bus_notification_target(self): + self.ensure_one() + if self.model == "discuss.channel" and self.res_id: + return self.env["discuss.channel"].browse(self.res_id) + guest = self.env["mail.guest"]._get_guest_from_context() + if self.env.user._is_public() and guest: + return guest + return super()._bus_notification_target() diff --git a/addons/mail/models/discuss/res_groups.py b/addons/mail/models/discuss/res_groups.py new file mode 100644 index 00000000000..745979d2127 --- /dev/null +++ b/addons/mail/models/discuss/res_groups.py @@ -0,0 +1,16 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class ResGroups(models.Model): + _inherit = "res.groups" + + def write(self, vals): + res = super().write(vals) + if vals.get("users"): + # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]} + user_ids = [command[1] for command in vals["users"] if command[0] == 4] + user_ids += [id for command in vals["users"] if command[0] == 6 for id in command[2]] + self.env["discuss.channel"].search([("group_ids", "in", self._ids)])._subscribe_users_automatically() + return res diff --git a/addons/mail/models/discuss/res_partner.py b/addons/mail/models/discuss/res_partner.py new file mode 100644 index 00000000000..cc84e89053b --- /dev/null +++ b/addons/mail/models/discuss/res_partner.py @@ -0,0 +1,128 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models +from odoo.osv import expression + + +class ResPartner(models.Model): + _inherit = "res.partner" + + channel_ids = fields.Many2many( + "discuss.channel", + "discuss_channel_member", + "partner_id", + "channel_id", + string="Channels", + copy=False, + ) + + def _get_channels_as_member(self): + """Returns the channels of the partner.""" + self.ensure_one() + channels = self.env["discuss.channel"] + # get the channels and groups + channels |= self.env["discuss.channel"].search( + [ + ("channel_type", "in", ("channel", "group")), + ("channel_partner_ids", "in", [self.id]), + ] + ) + # get the pinned direct messages + channels |= self.env["discuss.channel"].search( + [ + ("channel_type", "=", "chat"), + ( + "channel_member_ids", + "in", + self.env["discuss.channel.member"] + .sudo() + ._search( + [ + ("partner_id", "=", self.id), + ("is_pinned", "=", True), + ] + ), + ), + ] + ) + return channels + + @api.model + def search_for_channel_invite(self, search_term, channel_id=None, limit=30): + """Returns partners matching search_term that can be invited to a channel. + If the channel_id is specified, only partners that can actually be invited to the channel + are returned (not already members, and in accordance to the channel configuration). + """ + domain = expression.AND( + [ + expression.OR( + [ + [("name", "ilike", search_term)], + [("email", "ilike", search_term)], + ] + ), + [("active", "=", True)], + [("type", "!=", "private")], + [("user_ids", "!=", False)], + [("user_ids.active", "=", True)], + [("user_ids.share", "=", False)], + ] + ) + if channel_id: + channel = self.env["discuss.channel"].search([("id", "=", int(channel_id))]) + domain = expression.AND([domain, [("channel_ids", "not in", channel.id)]]) + if channel.group_public_id: + domain = expression.AND([domain, [("user_ids.groups_id", "in", channel.group_public_id.id)]]) + query = self.env["res.partner"]._search(domain, order="name, id") + query.order = 'LOWER("res_partner"."name"), "res_partner"."id"' # bypass lack of support for case insensitive order in search() + query.limit = int(limit) + return { + "count": self.env["res.partner"].search_count(domain), + "partners": list(self.env["res.partner"].browse(query).mail_partner_format().values()), + } + + @api.model + def get_mention_suggestions_from_channel(self, channel_id, search, limit=8): + """Return 'limit'-first partners' such that the name or email matches a 'search' string. + Prioritize partners that are also (internal) users, and then extend the research to all partners. + Only members of the given channel are returned. + The return format is a list of partner data (as per returned by `mail_partner_format()`). + """ + channel = self.env["discuss.channel"].search([("id", "=", channel_id)]) + if not channel: + return [] + domain = expression.AND( + [ + self._get_mention_suggestions_domain(search), + [("channel_ids", "in", channel.id)], + ] + ) + partners = self._search_mention_suggestions(domain, limit) + member_by_partner = { + member.partner_id: member + for member in self.env["discuss.channel.member"].search( + [ + ("channel_id", "=", channel.id), + ("partner_id", "in", partners.ids), + ] + ) + } + partners_format = partners.mail_partner_format() + for partner in partners: + partners_format.get(partner)["persona"] = { + "channelMembers": [ + ( + "insert", + member_by_partner.get(partner) + ._discuss_channel_member_format( + fields={ + "id": True, + "channel": {"id"}, + "persona": {"partner": {"id"}}, + } + ) + .get(member_by_partner.get(partner)), + ) + ], + } + return list(partners_format.values()) diff --git a/addons/mail/models/discuss/res_users.py b/addons/mail/models/discuss/res_users.py new file mode 100644 index 00000000000..cbb04975404 --- /dev/null +++ b/addons/mail/models/discuss/res_users.py @@ -0,0 +1,49 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models +from odoo.addons.base.models.res_users import is_selection_groups + + +class ResUsers(models.Model): + _inherit = "res.users" + + @api.model_create_multi + def create(self, vals_list): + users = super().create(vals_list) + # Auto-subscribe to channels unless skip explicitly requested + if not self.env.context.get("discuss_channel_nosubscribe"): + self.env["discuss.channel"].search( + [("group_ids", "in", users.groups_id.ids)] + )._subscribe_users_automatically() + return users + + def write(self, vals): + res = super().write(vals) + if "active" in vals and not vals["active"]: + self._unsubscribe_from_non_public_channels() + sel_groups = [vals[k] for k in vals if is_selection_groups(k) and vals[k]] + if vals.get("groups_id"): + # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]} + user_group_ids = [command[1] for command in vals["groups_id"] if command[0] == 4] + user_group_ids += [id for command in vals["groups_id"] if command[0] == 6 for id in command[2]] + self.env["discuss.channel"].search([("group_ids", "in", user_group_ids)])._subscribe_users_automatically() + elif sel_groups: + self.env["discuss.channel"].search([("group_ids", "in", sel_groups)])._subscribe_users_automatically() + return res + + def unlink(self): + self._unsubscribe_from_non_public_channels() + return super().unlink() + + def _unsubscribe_from_non_public_channels(self): + """This method un-subscribes users from group restricted channels. Main purpose + of this method is to prevent sending internal communication to archived / deleted users. + We do not un-subscribes users from public channels because in most common cases, + public channels are mailing list (e-mail based) and so users should always receive + updates from public channels until they manually un-subscribe themselves. + """ + domain = [("partner_id", "in", self.partner_id.ids)] + current_cm = self.env["discuss.channel.member"].sudo().search(domain) + current_cm.filtered( + lambda cm: (cm.channel_id.channel_type == "channel" and cm.channel_id.group_public_id) + ).unlink() diff --git a/addons/mail/models/ir_attachment.py b/addons/mail/models/ir_attachment.py index 79145b7d993..ea9a6158c35 100644 --- a/addons/mail/models/ir_attachment.py +++ b/addons/mail/models/ir_attachment.py @@ -39,16 +39,15 @@ class IrAttachment(models.Model): related_record.message_main_attachment_id = self def _delete_and_notify(self): - for attachment in self: - if attachment.res_model == 'discuss.channel' and attachment.res_id: - target = self.env['discuss.channel'].browse(attachment.res_id) - else: - target = self.env.user.partner_id - self.env['bus.bus']._sendone(target, 'ir.attachment/delete', { - 'id': attachment.id, - }) + self.env['bus.bus']._sendmany((attachment._bus_notification_target(), 'ir.attachment/delete', { + 'id': attachment.id, + }) for attachment in self) self.unlink() + def _bus_notification_target(self): + self.ensure_one() + return self.env.user.partner_id + def _attachment_format(self, legacy=False): safari = request and request.httprequest.user_agent and request.httprequest.user_agent.browser == 'safari' res_list = [] diff --git a/addons/mail/models/mail_link_preview.py b/addons/mail/models/mail_link_preview.py index 438ef226d0f..3dfaca131a1 100644 --- a/addons/mail/models/mail_link_preview.py +++ b/addons/mail/models/mail_link_preview.py @@ -42,14 +42,7 @@ class LinkPreview(models.Model): link_previews |= self.env['mail.link.preview']._create_link_preview(url, message.id, requests_session) if not link_previews: return - guest = self.env['mail.guest']._get_guest_from_context() - if message.model == 'discuss.channel' and message.res_id: - target = self.env['discuss.channel'].browse(message.res_id) - elif self.env.user._is_public() and guest: - target = guest - else: - target = self.env.user.partner_id - self.env['bus.bus']._sendone(target, 'mail.record/insert', { + self.env['bus.bus']._sendone(message._bus_notification_target(), 'mail.record/insert', { 'LinkPreview': link_previews._link_preview_format() }) @@ -67,13 +60,7 @@ class LinkPreview(models.Model): notifications = [] guest = self.env['mail.guest']._get_guest_from_context() for link_preview in self: - if link_preview.message_id.model == 'discuss.channel' and link_preview.message_id.res_id: - target = self.env['discuss.channel'].browse(link_preview.message_id.res_id) - elif self.env.user._is_public() and guest: - target = guest - else: - target = self.env.user.partner_id - notifications.append((target, 'mail.link.preview/delete', { + notifications.append((link_preview.message_id._bus_notification_target(), 'mail.link.preview/delete', { 'id': link_preview.id, 'message_id': link_preview.message_id.id, })) diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 507ec888a57..7f1e47b1329 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -856,12 +856,10 @@ class Message(models.Model): def _message_format(self, fnames, format_reply=True, legacy=False): """Reads values from messages and formats them for the web client.""" vals_list = self._read_format(fnames) - thread_ids_by_model_name = defaultdict(set) for message in self: if message.model and message.res_id: thread_ids_by_model_name[message.model].add(message.res_id) - for vals in vals_list: message_sudo = self.browse(vals['id']).sudo().with_prefetch(self.ids) author = { @@ -890,9 +888,8 @@ class Message(models.Model): 'message': {'id': message_sudo.id}, 'partners': [{'id': partner.id, 'name': partner.name} for partner in reactions.partner_id], } for content, reactions in reactions_per_content.items()] - if format_reply and message_sudo.model == 'discuss.channel' and message_sudo.parent_id: - vals['parentMessage'] = message_sudo.parent_id.message_format(format_reply=False)[0] allowed_tracking_ids = message_sudo.tracking_value_ids.filtered(lambda tracking: not tracking.field_groups or self.env.is_superuser() or self.user_has_groups(tracking.field_groups)) + vals.update(message_sudo._message_format_extras(format_reply)) vals.update({ 'author': author, 'default_subject': default_subject, @@ -905,9 +902,12 @@ class Message(models.Model): 'pinned_at': message_sudo.pinned_at, 'record_name': record_name, }) - return vals_list + def _message_format_extras(self, format_reply): + self.ensure_one() + return {} + @api.model def _message_fetch(self, domain, before=None, after=None, around=None, limit=30): """ Get a limited amount of formatted messages with provided domain. @@ -1075,6 +1075,10 @@ class Message(models.Model): ] self.env['bus.bus']._sendmany(updates) + def _bus_notification_target(self): + self.ensure_one() + return self.env.user.partner_id + # ------------------------------------------------------ # TOOLS # ------------------------------------------------------ diff --git a/addons/mail/models/res_groups.py b/addons/mail/models/res_groups.py deleted file mode 100644 index 4fa3654a72c..00000000000 --- a/addons/mail/models/res_groups.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import models - - -class ResGroups(models.Model): - """ Update of res.groups class - - if adding users from a group, check discuss.channels linked to this user - group and subscribe them. This is done by overriding the write method. - """ - _name = 'res.groups' - _inherit = 'res.groups' - _description = 'Access Groups' - - def write(self, vals): - res = super(ResGroups, self).write(vals) - if vals.get('users'): - # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]} - user_ids = [command[1] for command in vals['users'] if command[0] == 4] - user_ids += [id for command in vals['users'] if command[0] == 6 for id in command[2]] - self.env['discuss.channel'].search([('group_ids', 'in', self._ids)])._subscribe_users_automatically() - return res diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index 5359b0b190e..dc6685d8dff 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -20,8 +20,6 @@ class Partner(models.Model): parent_id = fields.Many2one(tracking=3) user_id = fields.Many2one(tracking=4) vat = fields.Char(tracking=5) - # channels - channel_ids = fields.Many2many('discuss.channel', 'discuss_channel_member', 'partner_id', 'channel_id', string='Channels', copy=False) # tracked field used for chatter logging purposes # we need this to be readable inline as tracking messages use inline HTML nodes contact_address_inline = fields.Char(compute='_compute_contact_address_inline', string='Inlined Complete Address', tracking=True) @@ -264,71 +262,34 @@ class Partner(models.Model): ], limit=100) return notifications.mail_message_id._message_notification_format() - def _get_channels_as_member(self): - """Returns the channels of the partner.""" - self.ensure_one() - channels = self.env['discuss.channel'] - # get the channels and groups - channels |= self.env['discuss.channel'].search([ - ('channel_type', 'in', ('channel', 'group')), - ('channel_partner_ids', 'in', [self.id]), - ]) - # get the pinned direct messages - channels |= self.env['discuss.channel'].search([ - ('channel_type', '=', 'chat'), - ('channel_member_ids', 'in', self.env['discuss.channel.member'].sudo()._search([ - ('partner_id', '=', self.id), - ('is_pinned', '=', True), - ])), - ]) - return channels + @api.model + def get_mention_suggestions(self, search, limit=8): + """ Return 'limit'-first partners' such that the name or email matches a 'search' string. + Prioritize partners that are also (internal) users, and then extend the research to all partners. + The return format is a list of partner data (as per returned by `mail_partner_format()`). + """ + domain = self._get_mention_suggestions_domain(search) + partners = self._search_mention_suggestions(domain, limit) + return list(partners.mail_partner_format().values()) @api.model - def search_for_channel_invite(self, search_term, channel_id=None, limit=30): - """ Returns partners matching search_term that can be invited to a channel. - If the channel_id is specified, only partners that can actually be invited to the channel - are returned (not already members, and in accordance to the channel configuration). - """ - domain = expression.AND([ + def _get_mention_suggestions_domain(self, search): + return expression.AND([ expression.OR([ - [('name', 'ilike', search_term)], - [('email', 'ilike', search_term)], + [('name', 'ilike', search)], + [('email', 'ilike', search)], ]), [('active', '=', True)], [('type', '!=', 'private')], - [('user_ids', '!=', False)], - [('user_ids.active', '=', True)], - [('user_ids.share', '=', False)], ]) - if channel_id: - channel = self.env['discuss.channel'].search([('id', '=', int(channel_id))]) - domain = expression.AND([domain, [('channel_ids', 'not in', channel.id)]]) - if channel.group_public_id: - domain = expression.AND([domain, [('user_ids.groups_id', 'in', channel.group_public_id.id)]]) - query = self.env['res.partner']._search(domain, order='name, id') - query.order = 'LOWER("res_partner"."name"), "res_partner"."id"' # bypass lack of support for case insensitive order in search() - query.limit = int(limit) - return { - 'count': self.env['res.partner'].search_count(domain), - 'partners': list(self.env['res.partner'].browse(query).mail_partner_format().values()), - } @api.model - 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 (internal) 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), ('type', '!=', 'private')], search_dom]) - if channel_id: - search_dom = expression.AND([[('channel_ids', 'in', channel_id)], search_dom]) - domain_is_user = expression.AND([[('user_ids', '!=', False), ('user_ids.active', '=', True)], search_dom]) + def _search_mention_suggestions(self, domain, limit): + domain_is_user = expression.AND([[('user_ids', '!=', False)], [('user_ids.active', '=', True)], domain]) priority_conditions = [ expression.AND([domain_is_user, [('partner_share', '=', False)]]), # Search partners that are internal users domain_is_user, # Search partners that are users - search_dom, # Search partners that are not users + domain, # Search partners that are not users ] partners = self.env['res.partner'] for domain in priority_conditions: @@ -340,14 +301,7 @@ class Partner(models.Model): # really slow. query = self._search(expression.AND([[('id', 'not in', partners.ids)], domain]), limit=remaining_limit) partners |= self.browse(query) - partners_format = partners.mail_partner_format() - if channel_id: - member_by_partner = {member.partner_id: member for member in self.env['discuss.channel.member'].search([('channel_id', '=', channel_id), ('partner_id', 'in', partners.ids)])} - for partner in partners: - partners_format.get(partner)['persona'] = { - 'channelMembers': [('insert', member_by_partner.get(partner)._discuss_channel_member_format(fields={'id': True, 'channel': {'id'}, 'persona': {'partner': {'id'}}}).get(member_by_partner.get(partner)))], - } - return list(partners_format.values()) + return partners @api.model def im_search(self, name, limit=20, excluded_ids=None): diff --git a/addons/mail/models/res_users.py b/addons/mail/models/res_users.py index 53320276b50..4f3be5bf0cb 100644 --- a/addons/mail/models/res_users.py +++ b/addons/mail/models/res_users.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import _, api, exceptions, fields, models, modules, tools -from odoo.addons.base.models.res_users import is_selection_groups from odoo.tools import email_normalize @@ -12,8 +11,6 @@ class Users(models.Model): - make a new user follow itself - add a welcome message - add suggestion preference - - if adding groups to a user, check discuss.channels linked to this user - group, and the user. This is done by overriding the write method. """ _name = 'res.users' _inherit = ['res.users'] @@ -101,9 +98,6 @@ class Users(models.Model): message_type='notification', subtype_xmlid='mail.mt_note' ) - # Auto-subscribe to channels unless skip explicitly requested - if not self.env.context.get('discuss_channel_nosubscribe'): - self.env['discuss.channel'].search([('group_ids', 'in', users.groups_id.ids)])._subscribe_users_automatically() return users def write(self, vals): @@ -159,23 +153,8 @@ class Users(models.Model): mail_values={'email_to': previous_email}, suggest_password_reset=False, ) - - if 'active' in vals and not vals['active']: - self._unsubscribe_from_non_public_channels() - sel_groups = [vals[k] for k in vals if is_selection_groups(k) and vals[k]] - if vals.get('groups_id'): - # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]} - user_group_ids = [command[1] for command in vals['groups_id'] if command[0] == 4] - user_group_ids += [id for command in vals['groups_id'] if command[0] == 6 for id in command[2]] - self.env['discuss.channel'].search([('group_ids', 'in', user_group_ids)])._subscribe_users_automatically() - elif sel_groups: - self.env['discuss.channel'].search([('group_ids', 'in', sel_groups)])._subscribe_users_automatically() return write_res - def unlink(self): - self._unsubscribe_from_non_public_channels() - return super().unlink() - def _notify_security_setting_update(self, subject, content, mail_values=None, **kwargs): """ This method is meant to be called whenever a sensitive update is done on the user's account. It will send an email to the concerned user warning him about this change and making some security suggestions. @@ -239,20 +218,6 @@ class Users(models.Model): 'update_datetime': fields.Datetime.now(), } - def _unsubscribe_from_non_public_channels(self): - """ This method un-subscribes users from group restricted channels. Main purpose - of this method is to prevent sending internal communication to archived / deleted users. - We do not un-subscribes users from public channels because in most common cases, - public channels are mailing list (e-mail based) and so users should always receive - updates from public channels until they manually un-subscribe themselves. - """ - current_cm = self.env['discuss.channel.member'].sudo().search([ - ('partner_id', 'in', self.partner_id.ids), - ]) - current_cm.filtered( - lambda cm: (cm.channel_id.channel_type == 'channel' and cm.channel_id.group_public_id) - ).unlink() - def _get_portal_access_update_body(self, access_granted): body = _('Portal Access Granted') if access_granted else _('Portal Access Revoked') if self.partner_id.email: diff --git a/addons/mail/static/src/composer/suggestion_service.js b/addons/mail/static/src/composer/suggestion_service.js index 24161b11785..ef01657c10c 100644 --- a/addons/mail/static/src/composer/suggestion_service.js +++ b/addons/mail/static/src/composer/suggestion_service.js @@ -37,19 +37,20 @@ export class SuggestionService { } } + /** + * @param {string} term + * @param {import("@mail/core/thread_model").Thread} thread + */ async fetchPartners(term, thread) { const kwargs = { search: term }; - const isNonPublicChannel = - thread && - (thread.type === "group" || - thread.type === "chat" || - (thread.type === "channel" && thread.group_based_subscription)); - if (isNonPublicChannel) { + if (thread.model === "discuss.channel") { kwargs.channel_id = thread.id; } const suggestedPartners = await this.orm.call( "res.partner", - "get_mention_suggestions", + thread.model === "discuss.channel" + ? "get_mention_suggestions_from_channel" + : "get_mention_suggestions", [], kwargs ); diff --git a/addons/mail/static/tests/helpers/mock_server/models/res_partner.js b/addons/mail/static/tests/helpers/mock_server/models/res_partner.js index a8e215dce37..6ac1959c914 100644 --- a/addons/mail/static/tests/helpers/mock_server/models/res_partner.js +++ b/addons/mail/static/tests/helpers/mock_server/models/res_partner.js @@ -20,6 +20,12 @@ patch(MockServer.prototype, "mail/models/res_partner", { if (args.model === "res.partner" && args.method === "get_mention_suggestions") { return this._mockResPartnerGetMentionSuggestions(args); } + if ( + args.model === "res.partner" && + args.method === "get_mention_suggestions_from_channel" + ) { + return this._mockResPartnerGetMentionSuggestionsFromChannel(args); + } return this._super(route, args); }, @@ -30,6 +36,69 @@ patch(MockServer.prototype, "mail/models/res_partner", { * @returns {Array[]} */ _mockResPartnerGetMentionSuggestions(args) { + const search = (args.args[0] || args.kwargs.search || "").toLowerCase(); + const limit = args.args[1] || args.kwargs.limit || 8; + /** + * Returns the given list of partners after filtering it according to + * the logic of the Python method `get_mention_suggestions` for the + * given search term. The result is truncated to the given limit and + * formatted as expected by the original method. + * + * @param {Object[]} partners + * @param {string} search + * @param {integer} limit + * @returns {Object[]} + */ + const mentionSuggestionsFilter = (partners, search, limit) => { + const matchingPartners = [ + ...this._mockResPartnerMailPartnerFormat( + partners + .filter((partner) => { + // no search term is considered as return all + if (!search) { + return true; + } + // otherwise name or email must match search term + if (partner.name && partner.name.toLowerCase().includes(search)) { + return true; + } + if (partner.email && partner.email.toLowerCase().includes(search)) { + return true; + } + return false; + }) + .map((partner) => partner.id) + ).values(), + ]; + // reduce results to max limit + matchingPartners.length = Math.min(matchingPartners.length, limit); + return matchingPartners; + }; + + // add main suggestions based on users + const partnersFromUsers = this.getRecords("res.users", []) + .map((user) => this.getRecords("res.partner", [["id", "=", user.partner_id]])[0]) + .filter((partner) => partner); + const mainMatchingPartners = mentionSuggestionsFilter(partnersFromUsers, search, limit); + + let extraMatchingPartners = []; + // if not enough results add extra suggestions based on partners + const remainingLimit = limit - mainMatchingPartners.length; + if (mainMatchingPartners.length < limit) { + const partners = this.getRecords("res.partner", [ + ["id", "not in", mainMatchingPartners.map((partner) => partner.id)], + ]); + extraMatchingPartners = mentionSuggestionsFilter(partners, search, remainingLimit); + } + return mainMatchingPartners.concat(extraMatchingPartners); + }, + /** + * Simulates `get_channel_mention_suggestions` on `res.partner`. + * + * @private + * @returns {Array[]} + */ + _mockResPartnerGetMentionSuggestionsFromChannel(args) { const search = (args.args[0] || args.kwargs.search || "").toLowerCase(); const limit = args.args[1] || args.kwargs.limit || 8; const channel_id = args.args[2] || args.kwargs.channel_id; @@ -50,14 +119,12 @@ patch(MockServer.prototype, "mail/models/res_partner", { ...this._mockResPartnerMailPartnerFormat( partners .filter((partner) => { - if (channel_id) { - const [member] = this.getRecords("discuss.channel.member", [ - ["channel_id", "=", channel_id], - ["partner_id", "=", partner.id], - ]); - if (!member) { - return false; - } + const [member] = this.getRecords("discuss.channel.member", [ + ["channel_id", "=", channel_id], + ["partner_id", "=", partner.id], + ]); + if (!member) { + return false; } // no search term is considered as return all if (!search) { @@ -75,22 +142,20 @@ patch(MockServer.prototype, "mail/models/res_partner", { .map((partner) => partner.id) ).values(), ].map((partnerFormat) => { - if (channel_id) { - const [member] = this.getRecords("discuss.channel.member", [ - ["channel_id", "=", channel_id], - ["partner_id", "=", partnerFormat.id], - ]); - partnerFormat["persona"] = { - channelMembers: [ - [ - "insert", - this._mockDiscussChannelMember_DiscussChannelMemberFormat([ - member.id, - ]), - ], + const [member] = this.getRecords("discuss.channel.member", [ + ["channel_id", "=", channel_id], + ["partner_id", "=", partnerFormat.id], + ]); + partnerFormat["persona"] = { + channelMembers: [ + [ + "insert", + this._mockDiscussChannelMember_DiscussChannelMemberFormat([ + member.id, + ])[0], ], - }; - } + ], + }; return partnerFormat; }); // reduce results to max limit diff --git a/addons/mail/tests/test_discuss_channel.py b/addons/mail/tests/test_discuss_channel.py index 67313f6d2ff..0211fa2d761 100644 --- a/addons/mail/tests/test_discuss_channel.py +++ b/addons/mail/tests/test_discuss_channel.py @@ -6,7 +6,7 @@ from datetime import datetime from unittest.mock import patch from odoo import Command, fields -from odoo.addons.mail.models.discuss_channel import channel_avatar, group_avatar +from odoo.addons.mail.models.discuss.discuss_channel import channel_avatar, group_avatar from odoo.addons.mail.tests.common import mail_new_test_user from odoo.addons.mail.tests.common import MailCommon from odoo.exceptions import AccessError, UserError