[MERGE] Forward-port of latest 7.0 bugfixes, up to rev. 9846 revid:dle@openerp.com...
[odoo/odoo.git] / addons / mail / mail_thread.py
index 9692b94..4c4a60b 100644 (file)
@@ -33,6 +33,7 @@ from openerp import tools
 from openerp import SUPERUSER_ID
 from openerp.addons.mail.mail_message import decode
 from openerp.osv import fields, osv, orm
+from openerp.osv.orm import browse_record, browse_null
 from openerp.tools.safe_eval import safe_eval as eval
 from openerp.tools.translate import _
 
@@ -246,13 +247,9 @@ class mail_thread(osv.AbstractModel):
                 new = set(command[2])
 
         # remove partners that are no longer followers
-        fol_ids = fol_obj.search(cr, SUPERUSER_ID,
-            [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
-        fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
-
+        self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
         # add new followers
-        for partner_id in new - old:
-            fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
+        self.message_subscribe(cr, uid, [id], list(new-old), context=context)
 
     def _search_followers(self, cr, uid, obj, name, args, context):
         """Search function for message_follower_ids
@@ -288,7 +285,7 @@ class mail_thread(osv.AbstractModel):
         'message_is_follower': fields.function(_get_followers, type='boolean',
             fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
         'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
-            fnct_search=_search_followers, type='many2many',
+            fnct_search=_search_followers, type='many2many', priority=-10,
             obj='res.partner', string='Followers', multi='_get_followers'),
         'message_ids': fields.one2many('mail.message', 'res_id',
             domain=lambda self: [('model', '=', self._name)],
@@ -318,46 +315,66 @@ class mail_thread(osv.AbstractModel):
         """
         if context is None:
             context = {}
-        thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
+
+        # subscribe uid unless asked not to
+        if not context.get('mail_create_nosubscribe'):
+            pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
+            message_follower_ids = values.get('message_follower_ids') or []  # webclient can send None or False
+            message_follower_ids.append([4, pid])
+            values['message_follower_ids'] = message_follower_ids
+            # add operation to ignore access rule checking for subscription
+            context_operation = dict(context, operation='create')
+        else:
+            context_operation = context
+        thread_id = super(mail_thread, self).create(cr, uid, values, context=context_operation)
 
         # automatic logging unless asked not to (mainly for various testing purpose)
         if not context.get('mail_create_nolog'):
             self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
 
-        # subscribe uid unless asked not to
-        if not context.get('mail_create_nosubscribe'):
-            self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
         # auto_subscribe: take values and defaults into account
-        create_values = set(values.keys())
+        create_values = dict(values)
         for key, val in context.iteritems():
             if key.startswith('default_'):
-                create_values.add(key[8:])
-        self.message_auto_subscribe(cr, uid, [thread_id], list(create_values), context=context)
+                create_values[key[8:]] = val
+        self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
 
         # track values
-        tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
-        if tracked_fields:
-            initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
-            self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=context)
-
+        track_ctx = dict(context)
+        if 'lang' not in track_ctx:
+            track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
+        if not context.get('mail_notrack'):
+            tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
+            if tracked_fields:
+                initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
+                self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
         return thread_id
 
     def write(self, cr, uid, ids, values, context=None):
+        if context is None:
+            context = {}
         if isinstance(ids, (int, long)):
             ids = [ids]
         # Track initial values of tracked fields
-        tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
+        track_ctx = dict(context)
+        if 'lang' not in track_ctx:
+            track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
+        tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
         if tracked_fields:
-            records = self.browse(cr, uid, ids, context=context)
+            records = self.browse(cr, uid, ids, context=track_ctx)
             initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
 
         # Perform write, update followers
         result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
-        self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
+        self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
 
-        # Perform the tracking
+        if not context.get('mail_notrack'):
+            # Perform the tracking
+            tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
+        else:
+            tracked_fields = None
         if tracked_fields:
-            self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
+            self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
         return result
 
     def unlink(self, cr, uid, ids, context=None):
@@ -376,6 +393,9 @@ class mail_thread(osv.AbstractModel):
         return res
 
     def copy(self, cr, uid, id, default=None, context=None):
+        # avoid tracking multiple temporary changes during copy
+        context = dict(context or {}, mail_notrack=True)
+
         default = default or {}
         default['message_ids'] = []
         default['message_follower_ids'] = []
@@ -577,7 +597,8 @@ class mail_thread(osv.AbstractModel):
             return action
         if msg_id and not (model and res_id):
             msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
-            model, res_id = msg.model, msg.res_id
+            if msg.exists():
+                model, res_id = msg.model, msg.res_id
 
         # if model + res_id found: try to redirect to the document or fallback on the Inbox
         if model and res_id:
@@ -687,13 +708,15 @@ class mail_thread(osv.AbstractModel):
         # Private message: should not contain any thread_id
         if not model and thread_id:
             if assert_model:
-                assert thread_id == 0, 'Routing: posting a message without model should be with a null res_id (private message).'
+                if thread_id: 
+                    raise ValueError('Routing: posting a message without model should be with a null res_id (private message).')
             _warn('posting a message without model should be with a null res_id (private message), resetting thread_id')
             thread_id = 0
         # Private message: should have a parent_id (only answers)
         if not model and not message_dict.get('parent_id'):
             if assert_model:
-                assert message_dict.get('parent_id'), 'Routing: posting a message without model should be with a parent_id (private mesage).'
+                if not message_dict.get('parent_id'):
+                    raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
             _warn('posting a message without model should be with a parent_id (private mesage), skipping')
             return ()
 
@@ -722,7 +745,10 @@ class mail_thread(osv.AbstractModel):
         # New Document: check model accepts the mailgateway
         if not thread_id and model and not hasattr(model_pool, 'message_new'):
             if assert_model:
-                assert hasattr(model_pool, 'message_new'), 'Model %s does not accept document creation, crashing' % model
+                if not hasattr(model_pool, 'message_new'):
+                    raise ValueError(
+                        'Model %s does not accept document creation, crashing' % model
+                    )
             _warn('model %s does not accept document creation, skipping' % model)
             return ()
 
@@ -783,8 +809,11 @@ class mail_thread(osv.AbstractModel):
                to which this mail should be attached. Only used if the message
                does not reply to an existing thread and does not match any mail alias.
            :return: list of [model, thread_id, custom_values, user_id, alias]
+
+        :raises: ValueError, TypeError
         """
-        assert isinstance(message, Message), 'message must be an email.message.Message at this point'
+        if not isinstance(message, Message):
+            raise TypeError('message must be an email.message.Message at this point')
         fallback_model = model
 
         # Get email.message.Message variables for future processing
@@ -878,9 +907,11 @@ class mail_thread(osv.AbstractModel):
             return [route]
 
         # AssertionError if no routes found and if no bounce occured
-        assert False, \
-            "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
-            "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
+        raise ValueError(
+                'No possible route found for incoming message from %s to %s (Message-Id %s:). '
+                'Create an appropriate mail.alias or force the destination model.' %
+                (email_from, email_to, message_id)
+            )
 
     def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
         # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
@@ -891,9 +922,11 @@ class mail_thread(osv.AbstractModel):
                 context.update({'thread_model': model})
             if model:
                 model_pool = self.pool[model]
-                assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
-                    "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
-                    (message_dict['message_id'], model)
+                if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
+                    raise ValueError(
+                        "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
+                        (message_dict['message_id'], model)
+                    )
 
                 # disabled subscriptions during message_new/update to avoid having the system user running the
                 # email gateway become a follower of all inbound messages
@@ -903,7 +936,8 @@ class mail_thread(osv.AbstractModel):
                 else:
                     thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
             else:
-                assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
+                if thread_id:
+                    raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
                 model_pool = self.pool.get('mail.thread')
             if not hasattr(model_pool, 'message_post'):
                 context['thread_model'] = model
@@ -1056,7 +1090,19 @@ class mail_thread(osv.AbstractModel):
                     alternative = True
                 if part.get_content_maintype() == 'multipart':
                     continue  # skip container
-                filename = part.get_filename()  # None if normal part
+                # part.get_filename returns decoded value if able to decode, coded otherwise.
+                # original get_filename is not able to decode iso-8859-1 (for instance).
+                # therefore, iso encoded attachements are not able to be decoded properly with get_filename
+                # code here partially copy the original get_filename method, but handle more encoding
+                filename=part.get_param('filename', None, 'content-disposition')
+                if not filename:
+                    filename=part.get_param('name', None)
+                if filename:
+                    if isinstance(filename, tuple):
+                        # RFC2231
+                        filename=email.utils.collapse_rfc2231_value(filename).strip()
+                    else:
+                        filename=decode(filename)
                 encoding = part.get_content_charset()  # None if attachment
                 # 1) Explicit Attachments -> attachments
                 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
@@ -1285,6 +1331,40 @@ class mail_thread(osv.AbstractModel):
                     mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
         return result
 
+    def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
+        """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
+
+        :param list attachments: list of attachment tuples in the form ``(name,content)``,
+                                 where content is NOT base64 encoded
+        :param list attachment_ids: a list of attachment ids, not in tomany command form
+        :param str attach_model: the model of the attachments parent record
+        :param integer attach_res_id: the id of the attachments parent record
+        """
+        Attachment = self.pool['ir.attachment']
+        m2m_attachment_ids = []
+        if attachment_ids:
+            filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
+                ('res_model', '=', 'mail.compose.message'),
+                ('create_uid', '=', uid),
+                ('id', 'in', attachment_ids)], context=context)
+            if filtered_attachment_ids:
+                Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
+            m2m_attachment_ids += [(4, id) for id in attachment_ids]
+        # Handle attachments parameter, that is a dictionary of attachments
+        for name, content in attachments:
+            if isinstance(content, unicode):
+                content = content.encode('utf-8')
+            data_attach = {
+                'name': name,
+                'datas': base64.b64encode(str(content)),
+                'datas_fname': name,
+                'description': name,
+                'res_model': attach_model,
+                'res_id': attach_res_id,
+            }
+            m2m_attachment_ids.append((0, 0, data_attach))
+        return m2m_attachment_ids
+
     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
                      subtype=None, parent_id=False, attachments=None, context=None,
                      content_subtype='html', **kwargs):
@@ -1363,28 +1443,7 @@ class mail_thread(osv.AbstractModel):
 
         # 3. Attachments
         #   - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
-        attachment_ids = kwargs.pop('attachment_ids', []) or []  # because we could receive None (some old code sends None)
-        if attachment_ids:
-            filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
-                ('res_model', '=', 'mail.compose.message'),
-                ('create_uid', '=', uid),
-                ('id', 'in', attachment_ids)], context=context)
-            if filtered_attachment_ids:
-                ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
-        attachment_ids = [(4, id) for id in attachment_ids]
-        # Handle attachments parameter, that is a dictionary of attachments
-        for name, content in attachments:
-            if isinstance(content, unicode):
-                content = content.encode('utf-8')
-            data_attach = {
-                'name': name,
-                'datas': base64.b64encode(str(content)),
-                'datas_fname': name,
-                'description': name,
-                'res_model': model,
-                'res_id': thread_id,
-            }
-            attachment_ids.append((0, 0, data_attach))
+        attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
 
         # 4: mail.message.subtype
         subtype_id = False
@@ -1460,6 +1519,12 @@ class mail_thread(osv.AbstractModel):
 
     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
         """ Add partners to the records followers. """
+        if context is None:
+            context = {}
+        # not necessary for computation, but saves an access right check
+        if not partner_ids:
+            return True
+
         mail_followers_obj = self.pool.get('mail.followers')
         subtype_obj = self.pool.get('mail.message.subtype')
 
@@ -1467,40 +1532,43 @@ class mail_thread(osv.AbstractModel):
         if set(partner_ids) == set([user_pid]):
             try:
                 self.check_access_rights(cr, uid, 'read')
+                if context.get('operation', '') == 'create':
+                    self.check_access_rule(cr, uid, ids, 'create')
+                else:
+                    self.check_access_rule(cr, uid, ids, 'read')
             except (osv.except_osv, orm.except_orm):
-                return
+                return False
         else:
             self.check_access_rights(cr, uid, 'write')
+            self.check_access_rule(cr, uid, ids, 'write')
+
+        existing_pids_dict = {}
+        fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
+        for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
+            existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
+
+        # subtype_ids specified: update already subscribed partners
+        if subtype_ids and fol_ids:
+            mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
+        # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
+        if subtype_ids is None:
+            subtype_ids = subtype_obj.search(
+                cr, uid, [
+                    ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
 
-        for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
-            existing_pids = set([f.id for f in record.message_follower_ids
-                                            if f.id in partner_ids])
+        for id in ids:
+            existing_pids = existing_pids_dict.get(id, set())
             new_pids = set(partner_ids) - existing_pids
 
-            # subtype_ids specified: update already subscribed partners
-            if subtype_ids and existing_pids:
-                fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, [
-                                                        ('res_model', '=', self._name),
-                                                        ('res_id', '=', record.id),
-                                                        ('partner_id', 'in', list(existing_pids)),
-                                                    ], context=context)
-                mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
-            # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
-            elif subtype_ids is None:
-                subtype_ids = subtype_obj.search(cr, uid, [
-                                                        ('default', '=', True),
-                                                        '|',
-                                                        ('res_model', '=', self._name),
-                                                        ('res_model', '=', False)
-                                                    ], context=context)
             # subscribe new followers
             for new_pid in new_pids:
-                mail_followers_obj.create(cr, SUPERUSER_ID, {
-                                                'res_model': self._name,
-                                                'res_id': record.id,
-                                                'partner_id': new_pid,
-                                                'subtype_ids': [(6, 0, subtype_ids)],
-                                            }, context=context)
+                mail_followers_obj.create(
+                    cr, SUPERUSER_ID, {
+                        'res_model': self._name,
+                        'res_id': id,
+                        'partner_id': new_pid,
+                        'subtype_ids': [(6, 0, subtype_ids)],
+                    }, context=context)
 
         return True
 
@@ -1514,12 +1582,24 @@ class mail_thread(osv.AbstractModel):
 
     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
         """ Remove partners from the records followers. """
+        # not necessary for computation, but saves an access right check
+        if not partner_ids:
+            return True
         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
         if set(partner_ids) == set([user_pid]):
             self.check_access_rights(cr, uid, 'read')
+            self.check_access_rule(cr, uid, ids, 'read')
         else:
             self.check_access_rights(cr, uid, 'write')
-        return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
+            self.check_access_rule(cr, uid, ids, 'write')
+        fol_obj = self.pool['mail.followers']
+        fol_ids = fol_obj.search(
+            cr, SUPERUSER_ID, [
+                ('res_model', '=', self._name),
+                ('res_id', 'in', ids),
+                ('partner_id', 'in', partner_ids)
+            ], context=context)
+        return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
 
     def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
         """ Returns the list of relational fields linking to res.users that should
@@ -1538,75 +1618,100 @@ class mail_thread(osv.AbstractModel):
                 user_field_lst.append(name)
         return user_field_lst
 
-    def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
-        """
-            1. fetch project subtype related to task (parent_id.res_model = 'project.task')
-            2. for each project subtype: subscribe the follower to the task
+    def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
+        """ Handle auto subscription. Two methods for auto subscription exist:
+
+         - tracked res.users relational fields, such as user_id fields. Those fields
+           must be relation fields toward a res.users record, and must have the
+           track_visilibity attribute set.
+         - using subtypes parent relationship: check if the current model being
+           modified has an header record (such as a project for tasks) whose followers
+           can be added as followers of the current records. Example of structure
+           with project and task:
+
+          - st_project_1.parent_id = st_task_1
+          - st_project_1.res_model = 'project.project'
+          - st_project_1.relation_field = 'project_id'
+          - st_task_1.model = 'project.task'
+
+        :param list updated_fields: list of updated fields to track
+        :param dict values: updated values; if None, the first record will be browsed
+                            to get the values. Added after releasing 7.0, therefore
+                            not merged with updated_fields argumment.
         """
         subtype_obj = self.pool.get('mail.message.subtype')
         follower_obj = self.pool.get('mail.followers')
+        new_followers = dict()
 
-        # fetch auto_follow_fields
+        # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
         user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
 
-        # fetch related record subtypes
-        related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
-        subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
-        default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
-        related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
-        relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
-        if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
+        # fetch header subtypes
+        header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
+        subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
+
+        # if no change in tracked field or no change in tracked relational field: quit
+        relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
+        if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
             return True
 
-        for record in self.browse(cr, uid, ids, context=context):
-            new_followers = dict()
-            parent_res_id = False
-            parent_model = False
-            for subtype in related_subtypes:
-                if not subtype.relation_field or not subtype.parent_id:
-                    continue
-                if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
-                    continue
-                parent_res_id = getattr(record, subtype.relation_field).id
-                parent_model = subtype.res_model
-                follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
-                    ('res_model', '=', parent_model),
-                    ('res_id', '=', parent_res_id),
-                    ('subtype_ids', 'in', [subtype.id])
-                    ], context=context)
-                for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
-                    new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
-
-            if parent_res_id and parent_model:
-                for subtype in default_subtypes:
-                    follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
-                        ('res_model', '=', parent_model),
-                        ('res_id', '=', parent_res_id),
-                        ('subtype_ids', 'in', [subtype.id])
-                        ], context=context)
-                    for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
-                        new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
-
-            # add followers coming from res.users relational fields that are tracked
-            user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
-            user_id_partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
-            for partner_id in user_id_partner_ids:
-                new_followers.setdefault(partner_id, None)
-
-            for pid, subtypes in new_followers.items():
-                subtypes = list(subtypes) if subtypes is not None else None
-                self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
-
-            # find first email message, set it as unread for auto_subscribe fields for them to have a notification
-            if user_id_partner_ids:
-                msg_ids = self.pool.get('mail.message').search(cr, uid, [
-                                ('model', '=', self._name),
-                                ('res_id', '=', record.id),
-                                ('type', '=', 'email')], limit=1, context=context)
-                if not msg_ids and record.message_ids:
-                    msg_ids = [record.message_ids[-1].id]
+        # legacy behavior: if values is not given, compute the values by browsing
+        # @TDENOTE: remove me in 8.0
+        if values is None:
+            record = self.browse(cr, uid, ids[0], context=context)
+            for updated_field in updated_fields:
+                field_value = getattr(record, updated_field)
+                if isinstance(field_value, browse_record):
+                    field_value = field_value.id
+                elif isinstance(field_value, browse_null):
+                    field_value = False
+                values[updated_field] = field_value
+
+        # find followers of headers, update structure for new followers
+        headers = set()
+        for subtype in subtypes:
+            if subtype.relation_field and values.get(subtype.relation_field):
+                headers.add((subtype.res_model, values.get(subtype.relation_field)))
+        if headers:
+            header_domain = ['|'] * (len(headers) - 1)
+            for header in headers:
+                header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
+            header_follower_ids = follower_obj.search(
+                cr, SUPERUSER_ID,
+                header_domain,
+                context=context
+            )
+            for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
+                for subtype in header_follower.subtype_ids:
+                    if subtype.parent_id and subtype.parent_id.res_model == self._name:
+                        new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
+                    elif subtype.res_model is False:
+                        new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
+
+        # add followers coming from res.users relational fields that are tracked
+        user_ids = [values[name] for name in user_field_lst if values.get(name)]
+        user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
+        for partner_id in user_pids:
+            new_followers.setdefault(partner_id, None)
+
+        for pid, subtypes in new_followers.items():
+            subtypes = list(subtypes) if subtypes is not None else None
+            self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
+
+        # find first email message, set it as unread for auto_subscribe fields for them to have a notification
+        if user_pids:
+            for record_id in ids:
+                message_obj = self.pool.get('mail.message')
+                msg_ids = message_obj.search(cr, SUPERUSER_ID, [
+                    ('model', '=', self._name),
+                    ('res_id', '=', record_id),
+                    ('type', '=', 'email')], limit=1, context=context)
+                if not msg_ids:
+                    msg_ids = message_obj.search(cr, SUPERUSER_ID, [
+                        ('model', '=', self._name),
+                        ('res_id', '=', record_id)], limit=1, context=context)
                 if msg_ids:
-                    self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_id_partner_ids, context=context)
+                    self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
 
         return True