diff --git a/mail_activity_team/README.rst b/mail_activity_team/README.rst index 2450f3d64..bb2700b5e 100644 --- a/mail_activity_team/README.rst +++ b/mail_activity_team/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ================== Mail Activity Team ================== @@ -17,7 +13,7 @@ Mail Activity Team .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fmail-lightgray.png?logo=github @@ -84,35 +80,38 @@ Authors Contributors ------------ -- `ForgeFlow `__: +- `ForgeFlow `__: + + - Jordi Ballester Alomar (jordi.ballester@forgeflow.com) + - Miquel Raïch (miquel.raich@forgeflow.com) + - Bernat Puig Font (bernat.puig@forgeflow.com) - - Jordi Ballester Alomar (jordi.ballester@forgeflow.com) - - Miquel Raïch (miquel.raich@forgeflow.com) - - Bernat Puig Font (bernat.puig@forgeflow.com) +- Pedro Gonzalez (pedro.gonzalez@pesol.es) +- `Tecnativa `__: -- Pedro Gonzalez (pedro.gonzalez@pesol.es) -- `Tecnativa `__: + - David Vidal - - David Vidal +- `Dynapps `__: -- `Dynapps `__: + - Raf Ven - - Raf Ven +- [Trobz] (https://trobz.com): -- [Trobz] (https://trobz.com): + - Son Ho sonhd@trobz.com - - Son Ho sonhd@trobz.com +- [Camptocamp] (https://camptocamp.com): -- [Camptocamp] (https://camptocamp.com): + - Vincent Van Rossem vincent.vanrossem@camptocamp.com + - Italo Lopes italo.lopes@camptocamp.com - - Vincent Van Rossem vincent.vanrossem@camptocamp.com - - Italo Lopes italo.lopes@camptocamp.com +- `CorporateHub `__ -- `CorporateHub `__ + - Alexey Pelykh alexey.pelykh@corphub.eu - - Alexey Pelykh alexey.pelykh@corphub.eu +- Stefan Rijnhart (stefan@opener.amsterdam) +- `glueckkanja AG `__ -- Stefan Rijnhart (stefan@opener.amsterdam) + - Christopher Rogos (crogos@gmail.com) Other credits ------------- diff --git a/mail_activity_team/models/mail_activity.py b/mail_activity_team/models/mail_activity.py index eb8cf3af3..a02dd4ad5 100644 --- a/mail_activity_team/models/mail_activity.py +++ b/mail_activity_team/models/mail_activity.py @@ -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): @@ -73,15 +74,70 @@ def create(self, vals_list): # odoo import api, fields, models the default one linked to the activity type. # We don't want this behavior because using the team_id, we want to assign the # activity to the whole team. + new_vals_list = [] for vals in vals_list: + new_vals = vals.copy() # we need to be sure that we are in a context where the team_id is set, # and we don't want to use user_id - if vals.get("team_id"): + if new_vals.get("team_id"): # using team, we have user_id = team_user_id, # 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"): - del vals["user_id"] - return super().create(vals_list) + if "user_id" in new_vals and not new_vals.get("team_user_id"): + del new_vals["user_id"] + # team_user_id is a related field pointing to user_id (readonly=False). + # If left in vals, the ORM places it in the 'inversed' bucket and + # calls _inverse_related *after* the initial INSERT while user_id is + # still NULL. That triggers Model.write({'user_id': …}) which in turn + # fires action_notify() → action_notify_team() a first time, and then + # core mail.activity.create()'s post-hook fires action_notify() a + # second time — causing duplicate notifications for team members. + # Eagerly resolving team_user_id to user_id here ensures user_id is + # stored in the INSERT so no inverse write occurs. + if "team_user_id" in new_vals: + team_user_id_val = new_vals.pop("team_user_id") + new_vals.setdefault("user_id", team_user_id_val) + new_vals_list.append(new_vals) + activities = super().create(new_vals_list) + if not self.env.context.get("mail_activity_quick_update"): + # 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): + # Notify the new team, but prevent duplicate notifications by excluding + # activities that will be notified by core write()->action_notify() + # when the user changes. + 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): + new_team_id = values.get("team_id", False) + team_notify_activities = self.filtered( + lambda activity, new_team_id=new_team_id: new_team_id + and activity.team_id.id != new_team_id + ) + new_user_id = values.get("user_id", False) + user_changed_activities = self.filtered( + lambda activity, new_user_id=new_user_id: new_user_id + and 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 new_user_id != self.env.uid: + core_notified_activities = user_changed_activities + + res = super().write(values) + # notify new responsibles + + if not self.env.context.get("mail_activity_quick_update", False): + (team_notify_activities - core_notified_activities).action_notify_team() + return res @api.onchange("team_id") def _onchange_team_id(self): @@ -112,7 +168,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, @@ -129,3 +185,80 @@ 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): + # Like action_notify(), but for team members. + classified = self._classify_by_model() + for model, activity_data in classified.items(): + records_sudo = self.env[model].sudo().browse(activity_data["record_ids"]) + # in case record was cascade-deleted in DB, skipping unlink override + 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.notify_members: + continue + + record = activity.env[activity.res_model].browse(activity.res_id) + # Notify each team member except the assigned user and the current user + 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 + ) + model_description = ( + activity_ctx.env["ir.model"] + ._get(activity_ctx.res_model) + .display_name + ) + 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 == "


", + }, + 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 diff --git a/mail_activity_team/models/mail_activity_team.py b/mail_activity_team/models/mail_activity_team.py index 88cd01cd6..0021f5983 100644 --- a/mail_activity_team/models/mail_activity_team.py +++ b/mail_activity_team/models/mail_activity_team.py @@ -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 ) diff --git a/mail_activity_team/readme/CONTRIBUTORS.md b/mail_activity_team/readme/CONTRIBUTORS.md index b1d175340..ba6097434 100644 --- a/mail_activity_team/readme/CONTRIBUTORS.md +++ b/mail_activity_team/readme/CONTRIBUTORS.md @@ -15,3 +15,5 @@ - [CorporateHub](https://corporatehub.eu/) - Alexey Pelykh - Stefan Rijnhart () +- [glueckkanja AG](https://glueckkanja.com/) + - Christopher Rogos () \ No newline at end of file diff --git a/mail_activity_team/static/description/index.html b/mail_activity_team/static/description/index.html index 6c3938cf2..b4502cc63 100644 --- a/mail_activity_team/static/description/index.html +++ b/mail_activity_team/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Mail Activity Team -
+
+

Mail Activity Team

- - -Odoo Community Association - -
-

Mail Activity Team

-

Beta License: AGPL-3 OCA/mail Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/mail Translate me on Weblate Try me on Runboat

This module adds the possibility to assign teams to activities.

Table of contents

@@ -391,7 +386,7 @@

Mail Activity Team

-

Usage

+

Usage

To set up new teams:

  1. Go to Settings / Activate developer mode
  2. @@ -410,7 +405,7 @@

    Usage

    / Activities, and then filter by a specific team or group by teams.

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -418,16 +413,16 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • ForgeFlow
  • Sodexis
-

Contributors

+

Contributors

-

Other credits

+

Other credits

The migration of this module from 16.0 to 17.0 was financially supported by Camptocamp

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -479,6 +478,5 @@

Maintainers

-
diff --git a/mail_activity_team/tests/test_mail_activity_team.py b/mail_activity_team/tests/test_mail_activity_team.py index 1b1e2d06d..5916ffb9f 100644 --- a/mail_activity_team/tests/test_mail_activity_team.py +++ b/mail_activity_team/tests/test_mail_activity_team.py @@ -99,6 +99,7 @@ def setUpClass(cls): "name": "Team 2", "res_model_ids": [Command.set([cls.partner_ir_model.id])], "member_ids": [Command.set([cls.employee.id, cls.employee2.id])], + "notify_members": True, } ) cls.act2 = ( @@ -612,6 +613,334 @@ def test_migration(self): self.assertFalse(rule.perm_create) + def test_notify_members_disabled(self): + """Test that when notify_members is False, only assigned user is notified.""" + # Create an activity for the team + self.team1.member_ids = [ + (6, 0, [self.employee.id, self.employee2.id, self.employee3.id]) + ] + activity = self.env["mail.activity"].create( + { + "activity_type_id": self.activity1.id, + "note": "Test activity without notify_members.", + "res_id": self.partner_client.id, + "res_model_id": self.partner_ir_model.id, + "team_user_id": self.employee.id, + "team_id": self.team1.id, + } + ) + + # Count initial messages for team members + initial_msg_count_emp = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee.partner_id.id])] + ) + ) + initial_msg_count_emp2 = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee2.partner_id.id])] + ) + ) + initial_msg_count_emp3 = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee3.partner_id.id])] + ) + ) + + # Call action_notify + activity.action_notify() + + # Verify only the assigned user (employee) received a notification + final_msg_count_emp = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee.partner_id.id])] + ) + ) + final_msg_count_emp2 = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee2.partner_id.id])] + ) + ) + final_msg_count_emp3 = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee3.partner_id.id])] + ) + ) + + self.assertGreater( + final_msg_count_emp, + initial_msg_count_emp, + "Assigned user should receive notification", + ) + self.assertEqual( + final_msg_count_emp2, + initial_msg_count_emp2, + "Non-assigned team member should not receive notification", + ) + self.assertEqual( + final_msg_count_emp3, + initial_msg_count_emp3, + "Non-assigned team member should not receive notification", + ) + + def test_notify_members_enabled(self): + """Test that when notify_members is True, all team members are notified.""" + # Create an activity for the team + self.team2.member_ids = [ + (6, 0, [self.employee.id, self.employee2.id, self.employee3.id]) + ] + activity = self.env["mail.activity"].create( + { + "activity_type_id": self.activity1.id, + "note": "Test activity with notify_members enabled.", + "res_id": self.partner_client.id, + "res_model_id": self.partner_ir_model.id, + "team_user_id": self.employee.id, + "team_id": self.team2.id, + } + ) + + # Count initial messages for team members + initial_msg_count_emp = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee.partner_id.id])] + ) + ) + initial_msg_count_emp2 = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee2.partner_id.id])] + ) + ) + initial_msg_count_emp3 = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee3.partner_id.id])] + ) + ) + + # Call action_notify + activity.action_notify() + + # Verify all team members received notifications + final_msg_count_emp = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee.partner_id.id])] + ) + ) + final_msg_count_emp2 = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee2.partner_id.id])] + ) + ) + final_msg_count_emp3 = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee3.partner_id.id])] + ) + ) + + self.assertGreater( + final_msg_count_emp, + initial_msg_count_emp, + "Assigned user should receive notification", + ) + self.assertGreater( + final_msg_count_emp2, + initial_msg_count_emp2, + "Team member 2 should receive notification", + ) + self.assertGreater( + final_msg_count_emp3, + initial_msg_count_emp3, + "Team member 3 should receive notification", + ) + + def test_notify_members_no_duplicate_for_assigned_user(self): + """Test that the assigned user doesn't get duplicate notifications.""" + # Create an activity assigned to employee (who is in the team) + activity = self.env["mail.activity"].create( + { + "activity_type_id": self.activity1.id, + "note": "Test no duplicate notifications.", + "res_id": self.partner_client.id, + "res_model_id": self.partner_ir_model.id, + "user_id": self.employee.id, + "team_id": self.team2.id, + } + ) + + # Count initial messages for the assigned user + initial_msg_count = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee.partner_id.id])] + ) + ) + + # Call action_notify + activity.action_notify() + + # Verify the assigned user received exactly one new notification + final_msg_count = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee.partner_id.id])] + ) + ) + + # The assigned user should receive only 1 notification (from parent method) + # not 2 (parent + team member notification) + self.assertEqual( + final_msg_count - initial_msg_count, + 1, + "Assigned user should receive exactly one notification, not duplicates", + ) + + def test_notify_members_activity_without_team(self): + """Test that activities without a team still work correctly.""" + # Create an activity without a team + activity = self.env["mail.activity"].create( + { + "activity_type_id": self.activity1.id, + "note": "Test activity without team.", + "res_id": self.partner_client.id, + "res_model_id": self.partner_ir_model.id, + "user_id": self.employee.id, + } + ) + + # Count initial messages + initial_msg_count = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee.partner_id.id])] + ) + ) + + # Call action_notify - should not raise any error + activity.action_notify() + + # Verify the assigned user received a notification + final_msg_count = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee.partner_id.id])] + ) + ) + + self.assertGreater( + final_msg_count, + initial_msg_count, + "Assigned user should receive notification even without team", + ) + + def test_notify_members_activity_without_assigned_user(self): + """Test that team notifications work when activity has no assigned user.""" + # Create an activity without an assigned user + activity = self.env["mail.activity"].create( + { + "activity_type_id": self.activity1.id, + "note": "Test activity without assigned user.", + "res_id": self.partner_client.id, + "res_model_id": self.partner_ir_model.id, + "team_id": self.team2.id, + } + ) + + # Count initial messages for team members + initial_msg_count_emp = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee.partner_id.id])] + ) + ) + initial_msg_count_emp2 = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee2.partner_id.id])] + ) + ) + + # Call action_notify + activity.action_notify() + + # Verify all team members received notifications + final_msg_count_emp = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee.partner_id.id])] + ) + ) + final_msg_count_emp2 = len( + self.env["mail.message"].search( + [("partner_ids", "in", [self.employee2.partner_id.id])] + ) + ) + + self.assertGreater( + final_msg_count_emp, + initial_msg_count_emp, + "Team member 1 should receive notification", + ) + self.assertGreater( + final_msg_count_emp2, + initial_msg_count_emp2, + "Team member 2 should receive notification", + ) + + def test_notify_no_duplicate_when_team_and_user_assigned_on_create(self): + """ + Activity created by user A, assigned to team A (user B and C, + notify_members=True) and explicitly to user B. + Verifies that B and C are each notified exactly once through the full + create flow. + + Setup: + - employee = user A (creator, not in team) + - team2: notify_members=True, members: employee2 (B) and employee3 (C) + - Activity assigned to team2 AND employee2 (B) via team_user_id + + Expected: B gets 1 notification (from action_notify), C gets 1 notification + (from action_notify_team) — no duplicate for C due to action_notify_team + being called twice in create. + """ + self.team2.write( + {"member_ids": [(6, 0, [self.employee2.id, self.employee3.id])]} + ) + partner = self.env["res.partner"].create( + {"name": "Test No Dup On Create Partner"} + ) + employee_b_pid = self.employee2.partner_id.id + employee_c_pid = self.employee3.partner_id.id + + before_b = self.env["mail.message"].search_count( + [("partner_ids", "in", [employee_b_pid])] + ) + before_c = self.env["mail.message"].search_count( + [("partner_ids", "in", [employee_c_pid])] + ) + + # User A (employee) creates activity for team A (team2) and assigns to user B + self.env["mail.activity"].with_user(self.employee).create( + { + "activity_type_id": self.activity1.id, + "note": "Dedup test: team + assigned user on create.", + "res_id": partner.id, + "res_model_id": self.partner_ir_model.id, + "team_id": self.team2.id, + "team_user_id": self.employee2.id, + } + ) + + after_b = self.env["mail.message"].search_count( + [("partner_ids", "in", [employee_b_pid])] + ) + after_c = self.env["mail.message"].search_count( + [("partner_ids", "in", [employee_c_pid])] + ) + + self.assertEqual( + after_b - before_b, + 1, + "User B (assigned user and team member) should be notified exactly once", + ) + self.assertEqual( + after_c - before_c, + 1, + "User C (team member only) should be notified exactly once, not twice", + ) + def test_mail_activity_plan_ui_logic(self): """Check team/team user consistency in plan template view""" plan = self.env["mail.activity.plan"].create( diff --git a/mail_activity_team/views/mail_activity_team_views.xml b/mail_activity_team/views/mail_activity_team_views.xml index 7ba70cc91..c10e64e3a 100644 --- a/mail_activity_team/views/mail_activity_team_views.xml +++ b/mail_activity_team/views/mail_activity_team_views.xml @@ -33,6 +33,7 @@ +