summaryrefslogtreecommitdiff
path: root/addons/mail/models/mail_followers.py
diff options
context:
space:
mode:
Diffstat (limited to 'addons/mail/models/mail_followers.py')
-rw-r--r--addons/mail/models/mail_followers.py397
1 files changed, 397 insertions, 0 deletions
diff --git a/addons/mail/models/mail_followers.py b/addons/mail/models/mail_followers.py
new file mode 100644
index 00000000..e584b87d
--- /dev/null
+++ b/addons/mail/models/mail_followers.py
@@ -0,0 +1,397 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import defaultdict
+import itertools
+
+from odoo import api, fields, models
+
+
+class Followers(models.Model):
+ """ mail_followers holds the data related to the follow mechanism inside
+ Odoo. Partners can choose to follow documents (records) of any kind
+ that inherits from mail.thread. Following documents allow to receive
+ notifications for new messages. A subscription is characterized by:
+
+ :param: res_model: model of the followed objects
+ :param: res_id: ID of resource (may be 0 for every objects)
+ """
+ _name = 'mail.followers'
+ _rec_name = 'partner_id'
+ _log_access = False
+ _description = 'Document Followers'
+
+ # Note. There is no integrity check on model names for performance reasons.
+ # However, followers of unlinked models are deleted by models themselves
+ # (see 'ir.model' inheritance).
+ res_model = fields.Char(
+ 'Related Document Model Name', required=True, index=True)
+ res_id = fields.Many2oneReference(
+ 'Related Document ID', index=True, help='Id of the followed resource', model_field='res_model')
+ partner_id = fields.Many2one(
+ 'res.partner', string='Related Partner', ondelete='cascade', index=True, domain=[('type', '!=', 'private')])
+ channel_id = fields.Many2one(
+ 'mail.channel', string='Listener', ondelete='cascade', index=True)
+ subtype_ids = fields.Many2many(
+ 'mail.message.subtype', string='Subtype',
+ help="Message subtypes followed, meaning subtypes that will be pushed onto the user's Wall.")
+ name = fields.Char('Name', compute='_compute_related_fields',
+ help="Name of the related partner (if exist) or the related channel")
+ email = fields.Char('Email', compute='_compute_related_fields',
+ help="Email of the related partner (if exist) or False")
+ is_active = fields.Boolean('Is Active', compute='_compute_related_fields',
+ help="If the related partner is active (if exist) or if related channel exist")
+
+ def _invalidate_documents(self, vals_list=None):
+ """ Invalidate the cache of the documents followed by ``self``.
+
+ Modifying followers change access rights to individual documents. As the
+ cache may contain accessible/inaccessible data, one has to refresh it.
+ """
+ to_invalidate = defaultdict(list)
+ for record in (vals_list or [{'res_model': rec.res_model, 'res_id': rec.res_id} for rec in self]):
+ if record.get('res_id'):
+ to_invalidate[record.get('res_model')].append(record.get('res_id'))
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ res = super(Followers, self).create(vals_list)
+ res._invalidate_documents(vals_list)
+ return res
+
+ def write(self, vals):
+ if 'res_model' in vals or 'res_id' in vals:
+ self._invalidate_documents()
+ res = super(Followers, self).write(vals)
+ if any(x in vals for x in ['res_model', 'res_id', 'partner_id']):
+ self._invalidate_documents()
+ return res
+
+ def unlink(self):
+ self._invalidate_documents()
+ return super(Followers, self).unlink()
+
+ _sql_constraints = [
+ ('mail_followers_res_partner_res_model_id_uniq', 'unique(res_model,res_id,partner_id)', 'Error, a partner cannot follow twice the same object.'),
+ ('mail_followers_res_channel_res_model_id_uniq', 'unique(res_model,res_id,channel_id)', 'Error, a channel cannot follow twice the same object.'),
+ ('partner_xor_channel', 'CHECK((partner_id IS NULL) != (channel_id IS NULL))', 'Error: A follower must be either a partner or a channel (but not both).')
+ ]
+
+ # --------------------------------------------------
+ # Private tools methods to fetch followers data
+ # --------------------------------------------------
+
+ @api.depends('partner_id', 'channel_id')
+ def _compute_related_fields(self):
+ for follower in self:
+ if follower.partner_id:
+ follower.name = follower.partner_id.name
+ follower.email = follower.partner_id.email
+ follower.is_active = follower.partner_id.active
+ else:
+ follower.name = follower.channel_id.name
+ follower.is_active = bool(follower.channel_id)
+ follower.email = False
+
+ def _get_recipient_data(self, records, message_type, subtype_id, pids=None, cids=None):
+ """ Private method allowing to fetch recipients data based on a subtype.
+ Purpose of this method is to fetch all data necessary to notify recipients
+ in a single query. It fetches data from
+
+ * followers (partners and channels) of records that follow the given
+ subtype if records and subtype are set;
+ * partners if pids is given;
+ * channels if cids is given;
+
+ :param records: fetch data from followers of records that follow subtype_id;
+ :param message_type: mail.message.message_type in order to allow custom behavior depending on it (SMS for example);
+ :param subtype_id: mail.message.subtype to check against followers;
+ :param pids: additional set of partner IDs from which to fetch recipient data;
+ :param cids: additional set of channel IDs from which to fetch recipient data;
+
+ :return: list of recipient data which is a tuple containing
+ partner ID (void if channel ID),
+ channel ID (void if partner ID),
+ active value (always True for channels),
+ share status of partner (void as irrelevant if channel ID),
+ notification status of partner or channel (email or inbox),
+ user groups of partner (void as irrelevant if channel ID),
+ """
+ self.env['mail.followers'].flush(['partner_id', 'channel_id', 'subtype_ids'])
+ self.env['mail.message.subtype'].flush(['internal'])
+ self.env['res.users'].flush(['notification_type', 'active', 'partner_id', 'groups_id'])
+ self.env['res.partner'].flush(['active', 'partner_share'])
+ self.env['res.groups'].flush(['users'])
+ self.env['mail.channel'].flush(['email_send', 'channel_type'])
+ if records and subtype_id:
+ query = """
+SELECT DISTINCT ON(pid, cid) * FROM (
+ WITH sub_followers AS (
+ SELECT fol.id, fol.partner_id, fol.channel_id, subtype.internal
+ FROM mail_followers fol
+ RIGHT JOIN mail_followers_mail_message_subtype_rel subrel
+ ON subrel.mail_followers_id = fol.id
+ RIGHT JOIN mail_message_subtype subtype
+ ON subtype.id = subrel.mail_message_subtype_id
+ WHERE subrel.mail_message_subtype_id = %%s AND fol.res_model = %%s AND fol.res_id IN %%s
+ )
+ SELECT partner.id as pid, NULL::int AS cid,
+ partner.active as active, partner.partner_share as pshare, NULL as ctype,
+ users.notification_type AS notif, array_agg(groups.id) AS groups
+ FROM res_partner partner
+ LEFT JOIN res_users users ON users.partner_id = partner.id AND users.active
+ LEFT JOIN res_groups_users_rel groups_rel ON groups_rel.uid = users.id
+ LEFT JOIN res_groups groups ON groups.id = groups_rel.gid
+ WHERE EXISTS (
+ SELECT partner_id FROM sub_followers
+ WHERE sub_followers.channel_id IS NULL
+ AND sub_followers.partner_id = partner.id
+ AND (coalesce(sub_followers.internal, false) <> TRUE OR coalesce(partner.partner_share, false) <> TRUE)
+ ) %s
+ GROUP BY partner.id, users.notification_type
+ UNION
+ SELECT NULL::int AS pid, channel.id AS cid,
+ TRUE as active, NULL AS pshare, channel.channel_type AS ctype,
+ CASE WHEN channel.email_send = TRUE THEN 'email' ELSE 'inbox' END AS notif, NULL AS groups
+ FROM mail_channel channel
+ WHERE EXISTS (
+ SELECT channel_id FROM sub_followers WHERE partner_id IS NULL AND sub_followers.channel_id = channel.id
+ ) %s
+) AS x
+ORDER BY pid, cid, notif
+""" % ('OR partner.id IN %s' if pids else '', 'OR channel.id IN %s' if cids else '')
+ params = [subtype_id, records._name, tuple(records.ids)]
+ if pids:
+ params.append(tuple(pids))
+ if cids:
+ params.append(tuple(cids))
+ self.env.cr.execute(query, tuple(params))
+ res = self.env.cr.fetchall()
+ elif pids or cids:
+ params, query_pid, query_cid = [], '', ''
+ if pids:
+ query_pid = """
+SELECT partner.id as pid, NULL::int AS cid,
+ partner.active as active, partner.partner_share as pshare, NULL as ctype,
+ users.notification_type AS notif, NULL AS groups
+FROM res_partner partner
+LEFT JOIN res_users users ON users.partner_id = partner.id AND users.active
+WHERE partner.id IN %s"""
+ params.append(tuple(pids))
+ if cids:
+ query_cid = """
+SELECT NULL::int AS pid, channel.id AS cid,
+ TRUE as active, NULL AS pshare, channel.channel_type AS ctype,
+ CASE when channel.email_send = TRUE then 'email' else 'inbox' end AS notif, NULL AS groups
+FROM mail_channel channel WHERE channel.id IN %s """
+ params.append(tuple(cids))
+ query = ' UNION'.join(x for x in [query_pid, query_cid] if x)
+ query = 'SELECT DISTINCT ON(pid, cid) * FROM (%s) AS x ORDER BY pid, cid, notif' % query
+ self.env.cr.execute(query, tuple(params))
+ res = self.env.cr.fetchall()
+ else:
+ res = []
+ return res
+
+ def _get_subscription_data(self, doc_data, pids, cids, include_pshare=False, include_active=False):
+ """ Private method allowing to fetch follower data from several documents of a given model.
+ Followers can be filtered given partner IDs and channel IDs.
+
+ :param doc_data: list of pair (res_model, res_ids) that are the documents from which we
+ want to have subscription data;
+ :param pids: optional partner to filter; if None take all, otherwise limitate to pids
+ :param cids: optional channel to filter; if None take all, otherwise limitate to cids
+ :param include_pshare: optional join in partner to fetch their share status
+ :param include_active: optional join in partner to fetch their active flag
+
+ :return: list of followers data which is a list of tuples containing
+ follower ID,
+ document ID,
+ partner ID (void if channel_id),
+ channel ID (void if partner_id),
+ followed subtype IDs,
+ share status of partner (void id channel_id, returned only if include_pshare is True)
+ active flag status of partner (void id channel_id, returned only if include_active is True)
+ """
+ # base query: fetch followers of given documents
+ where_clause = ' OR '.join(['fol.res_model = %s AND fol.res_id IN %s'] * len(doc_data))
+ where_params = list(itertools.chain.from_iterable((rm, tuple(rids)) for rm, rids in doc_data))
+
+ # additional: filter on optional pids / cids
+ sub_where = []
+ if pids:
+ sub_where += ["fol.partner_id IN %s"]
+ where_params.append(tuple(pids))
+ elif pids is not None:
+ sub_where += ["fol.partner_id IS NULL"]
+ if cids:
+ sub_where += ["fol.channel_id IN %s"]
+ where_params.append(tuple(cids))
+ elif cids is not None:
+ sub_where += ["fol.channel_id IS NULL"]
+ if sub_where:
+ where_clause += "AND (%s)" % " OR ".join(sub_where)
+
+ query = """
+SELECT fol.id, fol.res_id, fol.partner_id, fol.channel_id, array_agg(subtype.id)%s%s
+FROM mail_followers fol
+%s
+LEFT JOIN mail_followers_mail_message_subtype_rel fol_rel ON fol_rel.mail_followers_id = fol.id
+LEFT JOIN mail_message_subtype subtype ON subtype.id = fol_rel.mail_message_subtype_id
+WHERE %s
+GROUP BY fol.id%s%s""" % (
+ ', partner.partner_share' if include_pshare else '',
+ ', partner.active' if include_active else '',
+ 'LEFT JOIN res_partner partner ON partner.id = fol.partner_id' if (include_pshare or include_active) else '',
+ where_clause,
+ ', partner.partner_share' if include_pshare else '',
+ ', partner.active' if include_active else ''
+ )
+ self.env.cr.execute(query, tuple(where_params))
+ return self.env.cr.fetchall()
+
+ # --------------------------------------------------
+ # Private tools methods to generate new subscription
+ # --------------------------------------------------
+
+ def _insert_followers(self, res_model, res_ids, partner_ids, partner_subtypes, channel_ids, channel_subtypes,
+ customer_ids=None, check_existing=True, existing_policy='skip'):
+ """ Main internal method allowing to create or update followers for documents, given a
+ res_model and the document res_ids. This method does not handle access rights. This is the
+ role of the caller to ensure there is no security breach.
+
+ :param partner_subtypes: optional subtypes for new partner followers. If not given, default
+ ones are computed;
+ :param channel_subtypes: optional subtypes for new channel followers. If not given, default
+ ones are computed;
+ :param customer_ids: see ``_add_default_followers``
+ :param check_existing: see ``_add_followers``;
+ :param existing_policy: see ``_add_followers``;
+ """
+ sudo_self = self.sudo().with_context(default_partner_id=False, default_channel_id=False)
+ if not partner_subtypes and not channel_subtypes: # no subtypes -> default computation, no force, skip existing
+ new, upd = self._add_default_followers(
+ res_model, res_ids,
+ partner_ids, channel_ids,
+ customer_ids=customer_ids,
+ check_existing=check_existing,
+ existing_policy=existing_policy)
+ else:
+ new, upd = self._add_followers(
+ res_model, res_ids,
+ partner_ids, partner_subtypes,
+ channel_ids, channel_subtypes,
+ check_existing=check_existing,
+ existing_policy=existing_policy)
+ if new:
+ sudo_self.create([
+ dict(values, res_id=res_id)
+ for res_id, values_list in new.items()
+ for values in values_list
+ ])
+ for fol_id, values in upd.items():
+ sudo_self.browse(fol_id).write(values)
+
+ def _add_default_followers(self, res_model, res_ids, partner_ids, channel_ids=None, customer_ids=None,
+ check_existing=True, existing_policy='skip'):
+ """ Shortcut to ``_add_followers`` that computes default subtypes. Existing
+ followers are skipped as their subscription is considered as more important
+ compared to new default subscription.
+
+ :param customer_ids: optional list of partner ids that are customers. It is used if computing
+ default subtype is necessary and allow to avoid the check of partners being customers (no
+ user or share user). It is just a matter of saving queries if the info is already known;
+ :param check_existing: see ``_add_followers``;
+ :param existing_policy: see ``_add_followers``;
+
+ :return: see ``_add_followers``
+ """
+ if not partner_ids and not channel_ids:
+ return dict(), dict()
+
+ default, _, external = self.env['mail.message.subtype'].default_subtypes(res_model)
+ if partner_ids and customer_ids is None:
+ customer_ids = self.env['res.partner'].sudo().search([('id', 'in', partner_ids), ('partner_share', '=', True)]).ids
+
+ c_stypes = dict.fromkeys(channel_ids or [], default.ids)
+ p_stypes = dict((pid, external.ids if pid in customer_ids else default.ids) for pid in partner_ids)
+
+ return self._add_followers(res_model, res_ids, partner_ids, p_stypes, channel_ids, c_stypes, check_existing=check_existing, existing_policy=existing_policy)
+
+ def _add_followers(self, res_model, res_ids, partner_ids, partner_subtypes, channel_ids, channel_subtypes,
+ check_existing=False, existing_policy='skip'):
+ """ Internal method that generates values to insert or update followers. Callers have to
+ handle the result, for example by making a valid ORM command, inserting or updating directly
+ follower records, ... This method returns two main data
+
+ * first one is a dict which keys are res_ids. Value is a list of dict of values valid for
+ creating new followers for the related res_id;
+ * second one is a dict which keys are follower ids. Value is a dict of values valid for
+ updating the related follower record;
+
+ :param check_existing: if True, check for existing followers for given documents and handle
+ them according to existing_policy parameter. Setting to False allows to save some computation
+ if caller is sure there are no conflict for followers;
+ :param existing policy: if check_existing, tells what to do with already-existing followers:
+
+ * skip: simply skip existing followers, do not touch them;
+ * force: update existing with given subtypes only;
+ * replace: replace existing with new subtypes (like force without old / new follower);
+ * update: gives an update dict allowing to add missing subtypes (no subtype removal);
+ """
+ _res_ids = res_ids or [0]
+ data_fols, doc_pids, doc_cids = dict(), dict((i, set()) for i in _res_ids), dict((i, set()) for i in _res_ids)
+
+ if check_existing and res_ids:
+ for fid, rid, pid, cid, sids in self._get_subscription_data([(res_model, res_ids)], partner_ids or None, channel_ids or None):
+ if existing_policy != 'force':
+ if pid:
+ doc_pids[rid].add(pid)
+ elif cid:
+ doc_cids[rid].add(cid)
+ data_fols[fid] = (rid, pid, cid, sids)
+
+ if existing_policy == 'force':
+ self.sudo().browse(data_fols.keys()).unlink()
+
+ new, update = dict(), dict()
+ for res_id in _res_ids:
+ for partner_id in set(partner_ids or []):
+ if partner_id not in doc_pids[res_id]:
+ new.setdefault(res_id, list()).append({
+ 'res_model': res_model,
+ 'partner_id': partner_id,
+ 'subtype_ids': [(6, 0, partner_subtypes[partner_id])],
+ })
+ elif existing_policy in ('replace', 'update'):
+ fol_id, sids = next(((key, val[3]) for key, val in data_fols.items() if val[0] == res_id and val[1] == partner_id), (False, []))
+ new_sids = set(partner_subtypes[partner_id]) - set(sids)
+ old_sids = set(sids) - set(partner_subtypes[partner_id])
+ update_cmd = []
+ if fol_id and new_sids:
+ update_cmd += [(4, sid) for sid in new_sids]
+ if fol_id and old_sids and existing_policy == 'replace':
+ update_cmd += [(3, sid) for sid in old_sids]
+ if update_cmd:
+ update[fol_id] = {'subtype_ids': update_cmd}
+
+ for channel_id in set(channel_ids or []):
+ if channel_id not in doc_cids[res_id]:
+ new.setdefault(res_id, list()).append({
+ 'res_model': res_model,
+ 'channel_id': channel_id,
+ 'subtype_ids': [(6, 0, channel_subtypes[channel_id])],
+ })
+ elif existing_policy in ('replace', 'update'):
+ fol_id, sids = next(((key, val[3]) for key, val in data_fols.items() if val[0] == res_id and val[2] == channel_id), (False, []))
+ new_sids = set(channel_subtypes[channel_id]) - set(sids)
+ old_sids = set(sids) - set(channel_subtypes[channel_id])
+ update_cmd = []
+ if fol_id and new_sids:
+ update_cmd += [(4, sid) for sid in new_sids]
+ if fol_id and old_sids and existing_policy == 'replace':
+ update_cmd += [(3, sid) for sid in old_sids]
+ if update_cmd:
+ update[fol_id] = {'subtype_ids': update_cmd}
+
+ return new, update