Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 97 additions & 3 deletions mail_activity_team/models/mail_activity.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Copyright 2018-22 ForgeFlow S.L.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).

from odoo import SUPERUSER_ID, _, api, fields, models
from odoo import SUPERUSER_ID, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.misc import get_lang


class MailActivity(models.Model):
Expand Down Expand Up @@ -47,7 +48,26 @@ def create(self, vals_list):
# so if we don't have a user_team_id we don't want user_id too
if "user_id" in vals and not vals.get("team_user_id", False):
del vals["user_id"]
return super().create(vals_list)

activities = super().create(vals_list)

# when creating activities for other: send a notification to assigned user;
if self.env.context.get("mail_activity_quick_update"):
activities.action_notify_team()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be removed since you are already calling action_notify_team inside action_notify, I am getting double notifications.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above action_notify is not called, when user_id is the current user. Therefore it needs to be added.

Copy link

@lorenzomorandini lorenzomorandini Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I as User A assign an activity to user B that is in a team with user C and D, 6 notifications are sent (2 for B, 2 for C and 2 for D).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am still struggling with the duplicate notifications. I've added a test for this case, which is currently failing. The problem is that the write method is called during the create process, which triggers the action_notify from the mail code... I have to postpone this to next week, but feel free to send an PR.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am currently using a custom module I made that contains the changes from both of your PRs. I'll leave you my current mail_activity.py that contains both but should fix the duplication mail problem (your test passes).

# Copyright 2026 MyNet
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import SUPERUSER_ID, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.misc import get_lang


class MailActivity(models.Model):
    _inherit = 'mail.activity'

    @api.model
    def _get_default_team_id(self, user_id=None, model_id=None):
        if not user_id:
            user_id = self.env.uid
        if not model_id:
            res_model = self.env.context.get('default_res_model')
            model = (
                self.sudo().env['ir.model'].search([('model', '=', res_model)], limit=1)
            )
            model_id = model.id if model else None
        domain = [('member_ids', 'in', [user_id])]
        if model_id:
            domain.extend(
                [
                    '|',
                    ('res_model_ids', '=', False),
                    ('res_model_ids', 'in', [model_id]),
                ]
            )
        results = self.env['mail.activity.team'].search(domain)
        if model_id:
            result_with_model = results.filtered(
                lambda team: model_id in team.res_model_ids.ids
            )
            if result_with_model:
                return result_with_model[0]
        return results[0] if results else self.env['mail.activity.team']

    user_id = fields.Many2one(string='User', required=False, default=False)
    team_user_id = fields.Many2one(
        string='Team user', related='user_id', readonly=False
    )
    team_id = fields.Many2one(
        comodel_name='mail.activity.team', default=False, index=True
    )

    @api.model_create_multi
    def create(self, vals_list):
        for vals in vals_list:
            # Ensure explicit team assignee is already reflected on user_id in the
            # initial create payload. This avoids intermediate notification flows
            # with empty assignee that may trigger duplicates.
            if vals.get('team_user_id') and not vals.get('user_id'):
                vals['user_id'] = vals['team_user_id']

            if vals.get('team_id'):
                if 'user_id' in vals and not vals.get('team_user_id'):
                    del vals['user_id']
            elif (
                'team_id' not in vals
                and 'user_id' in vals
                and 'team_user_id' not in vals
            ):
                team_id = self._get_default_team_id(
                    vals['user_id'], vals.get('res_model_id')
                )
                if team_id:
                    vals['team_id'] = team_id.id
                    if vals.get('user_id'):
                        # Keep explicit assignee when team is inferred automatically.
                        vals['team_user_id'] = vals['user_id']

        activities = super().create(vals_list)
        if self.env.context.get('mail_activity_quick_update'):
            return activities

        # Core create() triggers action_notify() only for activities assigned to
        # users different from the current one. Notify team members here only for
        # activities not covered by that path to avoid duplicate notifications.
        activities_without_core_notify = activities.filtered(
            lambda activity: activity.user_id == self.env.user
        )
        activities_without_core_notify.action_notify_team()
        return activities

    def write(self, values):
        team_notify_activities = self.env['mail.activity']
        core_notified_activities = self.env['mail.activity']

        if not self.env.context.get('mail_activity_quick_update', False):
            if 'team_id' in values:
                new_team_id = values.get('team_id') or False
                team_notify_activities |= self.filtered(
                    lambda activity, new_team_id=new_team_id: activity.team_id.id
                    != new_team_id
                )

            if 'user_id' in values:
                new_user_id = values.get('user_id') or False
                user_changed_activities = self.filtered(
                    lambda activity, new_user_id=new_user_id: activity.user_id.id
                    != new_user_id
                )
                team_notify_activities |= user_changed_activities

                # Core write() calls action_notify() for user changes except when
                # assigning to the current user; avoid re-sending team notifications.
                if values.get('user_id') != self.env.uid:
                    core_notified_activities = user_changed_activities

        res = super().write(values)

        if not self.env.context.get('mail_activity_quick_update', False):
            (team_notify_activities - core_notified_activities).action_notify_team()
        return res

    @api.onchange('user_id')
    def _onchange_user_id(self):
        if not self.user_id or (
            self.team_id and self.user_id in self.team_id.member_ids
        ):
            return
        self.team_id = self._get_default_team_id(
            self.user_id.id, self.sudo().res_model_id.id
        )

    @api.constrains('team_id', 'user_id')
    def _check_team_and_user(self):
        for activity in self:
            if (
                activity.user_id.id != SUPERUSER_ID
                and activity.team_id
                and activity.user_id
                and activity.user_id
                not in activity.team_id.with_context(active_test=False).member_ids
            ):
                raise ValidationError(
                    self.env._(
                        'The assigned user %(user_name)s is '
                        'not member of the team %(team_name)s.',
                        user_name=activity.user_id.name,
                        team_name=activity.team_id.name,
                    )
                )

    def action_notify_team(self):
        classified = self._classify_by_model()
        for model, activity_data in classified.items():
            records_sudo = self.env[model].sudo().browse(activity_data['record_ids'])
            activity_data['record_ids'] = records_sudo.exists().ids

        for activity in self:
            if activity.res_id not in classified[activity.res_model]['record_ids']:
                continue
            if not (activity.team_id and activity.team_id.notify_members):
                continue

            model_description = (
                activity.env['ir.model']._get(activity.res_model).display_name
            )
            record = activity.env[activity.res_model].browse(activity.res_id)
            members = activity.team_id.member_ids.filtered(
                lambda member, assigned_user_id=activity.user_id: self.env.uid
                not in member.user_ids.ids
                and (
                    not assigned_user_id
                    or (assigned_user_id and assigned_user_id not in member.user_ids)
                )
            )
            for member in members:
                activity_ctx = (
                    activity.with_context(lang=member.lang) if member.lang else activity
                )
                body = activity_ctx.env['ir.qweb']._render(
                    'mail.message_activity_assigned',
                    {
                        'activity': activity_ctx,
                        'model_description': model_description,
                        'is_html_empty': lambda value: not value
                        or value == '<p><br></p>',
                    },
                    minimal_qcontext=True,
                )
                record.message_notify(
                    partner_ids=member.sudo().partner_id.ids,
                    body=body,
                    record_name=activity_ctx.res_name,
                    model_description=model_description,
                    email_layout_xmlid='mail.mail_notification_layout',
                    subject=self.env._(
                        '%(activity_name)s: %(summary)s (Team Activity)',
                        activity_name=activity_ctx.res_name,
                        summary=activity_ctx.summary
                        or activity_ctx.activity_type_id.name,
                    ),
                    subtitles=[
                        self.env._('Activity: %s', activity_ctx.activity_type_id.name),
                        self.env._('Team: %s', activity_ctx.team_id.name),
                        self.env._(
                            'Deadline: %s',
                            (
                                activity_ctx.date_deadline.strftime(
                                    get_lang(activity_ctx.env).date_format
                                )
                                if hasattr(activity_ctx.date_deadline, 'strftime')
                                else str(activity_ctx.date_deadline)
                            ),
                        ),
                    ],
                )

    def action_notify(self):
        result = super().action_notify()
        self.action_notify_team()
        return result

return activities

def write(self, values):
new_team_activities = self.env["mail.activity"]
if values.get("team_id"):
new_team_activities = self.filtered(
lambda activity: activity.user_id.id != values.get("team_id")
)
res = super().write(values)
# notify new responsibles
if "team_id" in values:
if self.env.context.get("mail_activity_quick_update", False):
new_team_activities.action_notify_team()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be removed since you are already calling action_notify_team inside action_notify, I am getting double notifications.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment above.

return res

@api.onchange("user_id")
def _onchange_user_id(self):
Expand Down Expand Up @@ -88,7 +108,7 @@ def _check_team_and_user(self):
not in activity.team_id.with_context(active_test=False).member_ids
):
raise ValidationError(
_(
self.env._(
"The assigned user %(user_name)s is "
"not member of the team %(team_name)s.",
user_name=activity.user_id.name,
Expand All @@ -105,3 +125,77 @@ def _onchange_activity_type_id(self):
if self.user_id not in members and members:
self.user_id = members[:1]
return res

def action_notify_team(self):
# Check if any activity has a team with notify_members enabled
classified = self._classify_by_model()
for model, activity_data in classified.items():
records_sudo = self.env[model].sudo().browse(activity_data["record_ids"])
activity_data["record_ids"] = (
records_sudo.exists().ids
) # in case record was cascade-deleted in DB, skipping unlink override
for activity in self:
if activity.res_id not in classified[activity.res_model]["record_ids"]:
continue
if activity.team_id and activity.team_id.notify_members:
# Get the record and model description
model_description = (
activity.env["ir.model"]._get(activity.res_model).display_name
)
record = activity.env[activity.res_model].browse(activity.res_id)
# Notify each team member except the assigned user and the current user
for member in activity.team_id.member_ids.filtered(
lambda m, assigned_user_id=activity.user_id: self.env.uid
not in m.user_ids.ids
and (
not assigned_user_id
or (assigned_user_id and assigned_user_id not in m.user_ids)
)
):
if member.lang:
# Send notification in member's language
activity_ctx = activity.with_context(lang=member.lang)
else:
activity_ctx = activity
body = activity_ctx.env["ir.qweb"]._render(
"mail.message_activity_assigned",
{
"activity": activity_ctx,
"model_description": model_description,
"is_html_empty": lambda x: not x or x == "<p><br></p>",
},
minimal_qcontext=True,
)
record.message_notify(
partner_ids=member.sudo().partner_id.ids,
body=body,
record_name=activity_ctx.res_name,
model_description=model_description,
email_layout_xmlid="mail.mail_notification_layout",
subject=self.env._(
"%(activity_name)s: %(summary)s (Team Activity)",
activity_name=activity_ctx.res_name,
summary=activity_ctx.summary
or activity_ctx.activity_type_id.name,
),
subtitles=[
self.env._(
"Activity: %s", activity_ctx.activity_type_id.name
),
self.env._("Team: %s", activity_ctx.team_id.name),
self.env._(
"Deadline: %s",
activity_ctx.date_deadline.strftime(
get_lang(activity_ctx.env).date_format
)
if hasattr(activity_ctx.date_deadline, "strftime")
else str(activity_ctx.date_deadline),
),
],
)

def action_notify(self):
"""Override to notify team members when notify_members is enabled."""
result = super().action_notify()
self.action_notify_team()
return result
5 changes: 5 additions & 0 deletions mail_activity_team/models/mail_activity_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ def _compute_missing_activities(self):
string="Team Members",
)
user_id = fields.Many2one(comodel_name="res.users", string="Team Leader")
notify_members = fields.Boolean(
default=False,
help="When enabled, all team members will be notified "
"when an activity is assigned to this team.",
)
count_missing_activities = fields.Integer(
string="Missing Activities", compute="_compute_missing_activities", default=0
)
Expand Down
Loading
Loading