diff --git a/mail_activity_team/README.rst b/mail_activity_team/README.rst index 752d566a1..234e5710a 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 @@ -56,6 +52,9 @@ Teams. When you create a new activity the application will propose the user's assigned team. +When creating activity plans, instead of assigning an activity to a +user, there is also the option to assign it to a team instead. + You can report on the activities assigned to a team going to *Dashboards / Activities*, and then filter by a specific team or group by teams. @@ -81,35 +80,35 @@ 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) +- Stefan Rijnhart (stefan@opener.amsterdam) Other credits ------------- diff --git a/mail_activity_team/__manifest__.py b/mail_activity_team/__manifest__.py index fa7d444fd..0bd503755 100644 --- a/mail_activity_team/__manifest__.py +++ b/mail_activity_team/__manifest__.py @@ -18,6 +18,8 @@ "security/mail_activity_team_security.xml", "wizard/mail_activity_schedule.xml", "views/ir_actions_server_views.xml", + "views/mail_activity_plan_template_views.xml", + "views/mail_activity_plan_views.xml", "views/mail_activity_type.xml", "views/mail_activity_team_views.xml", "views/mail_activity_views.xml", diff --git a/mail_activity_team/models/__init__.py b/mail_activity_team/models/__init__.py index 32109baf1..ccd0cec35 100644 --- a/mail_activity_team/models/__init__.py +++ b/mail_activity_team/models/__init__.py @@ -1,6 +1,7 @@ from . import ir_actions_server -from . import mail_activity_team +from . import mail_activity_team # Has to load early from . import mail_activity from . import mail_activity_mixin -from . import res_users +from . import mail_activity_plan_template from . import mail_activity_type +from . import res_users diff --git a/mail_activity_team/models/mail_activity_mixin.py b/mail_activity_team/models/mail_activity_mixin.py index d24c6af9c..0cc1ccb65 100644 --- a/mail_activity_team/models/mail_activity_mixin.py +++ b/mail_activity_team/models/mail_activity_mixin.py @@ -44,6 +44,13 @@ def activity_schedule( user-team missmatch. We can hook onto `act_values` dict as it's passed to the create activity method. """ + # Pick up some defaults from mail.activity.schedule + for field in ("team_id", "team_user_id", "user_id"): + if self.env.context.get(f"schedule_default_{field}") and not act_values.get( + field + ): + act_values[field] = self.env.context[f"schedule_default_{field}"] + if self.env.context.get("force_activity_team"): act_values["team_id"] = self.env.context["force_activity_team"].id if "team_id" not in act_values: diff --git a/mail_activity_team/models/mail_activity_plan_template.py b/mail_activity_team/models/mail_activity_plan_template.py new file mode 100644 index 000000000..d6e8e41d6 --- /dev/null +++ b/mail_activity_team/models/mail_activity_plan_template.py @@ -0,0 +1,95 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class MailActivityPlanTemplate(models.Model): + _inherit = "mail.activity.plan.template" + + activity_team_user_id = fields.Many2one( + comodel_name="res.users", + compute="_compute_activity_team_user_id", + readonly=False, + help="The team member that the activity will be assigned to specifically", + store=True, + string="Team user", + ) + activity_team_id = fields.Many2one( + comodel_name="mail.activity.team", + compute="_compute_activity_team_id", + ondelete="restrict", + readonly=False, + store=True, + string="Team assigned to", + ) + activity_team_required = fields.Boolean( + compute="_compute_activity_team_required", + help="Indicate if this plan template must have an activity team", + ) + # Add compute method to existing field + responsible_id = fields.Many2one( + compute="_compute_responsible_id", + readonly=False, + store=True, + ) + responsible_type = fields.Selection( + ondelete={"team": "set default"}, + selection_add=[("team", "Team")], + ) + + @api.depends("responsible_type") + def _compute_activity_team_required(self): + """Hook to override requiredness of activity team""" + for template in self: + template.activity_team_required = template.responsible_type == "team" + + @api.depends("activity_team_id", "responsible_type") + def _compute_activity_team_user_id(self): + """Ensure consistency between the activity team and the team user""" + for template in self: + user = template.activity_team_user_id + if template.activity_team_required: + team = template.activity_team_id + if team: + if not user or user not in team.member_ids: + if team.user_id: + template.activity_team_user_id = team.user_id + elif len(team.member_ids) == 1: + template.activity_team_user_id = team.member_ids + elif user: + template.activity_team_user_id = False + elif user: + template.activity_team_user_id = False + elif user: + template.activity_team_user_id = False + + @api.depends("activity_type_id", "responsible_type") + def _compute_activity_team_id(self): + """Assign the default team from the activity type""" + for template in self: + if template.activity_team_required: + if template.activity_type_id.default_team_id: + template.activity_team_id = ( + template.activity_type_id.default_team_id + ) + elif template.activity_team_id: + template.activity_team_id = False + + @api.depends("responsible_type") + def _compute_responsible_id(self): + """Wipe responsible if field is not visible (c.q. allowed)""" + for template in self: + if template.activity_team_required and template.responsible_id: + template.responsible_id = False + + @api.constrains("responsible_type", "activity_team_id") + def _check_activity_team(self): + for template in self: + if template.activity_team_required and not template.activity_team_id: + raise ValidationError(self.env._("Please enter an activity team.")) + + def _determine_responsible(self, on_demand_responsible, applied_on_record): + # Avoid signalling an error for a 'team' template without a user. + self.ensure_one() + if self.activity_team_required: + return {"error": False} + return super()._determine_responsible(on_demand_responsible, applied_on_record) diff --git a/mail_activity_team/models/mail_activity_team.py b/mail_activity_team/models/mail_activity_team.py index 6c6a71c50..88cd01cd6 100644 --- a/mail_activity_team/models/mail_activity_team.py +++ b/mail_activity_team/models/mail_activity_team.py @@ -52,9 +52,7 @@ def _compute_missing_activities(self): @api.onchange("user_id") def _onchange_user_id(self): if self.user_id and self.user_id not in self.member_ids: - members_ids = self.member_ids.ids - members_ids.append(self.user_id.id) - self.member_ids = [(4, member) for member in members_ids] + self.member_ids += self.user_id def assign_team_to_unassigned_activities(self): activity_model = self.env["mail.activity"] diff --git a/mail_activity_team/readme/USAGE.md b/mail_activity_team/readme/USAGE.md index 3da67bd8e..e0729e6af 100644 --- a/mail_activity_team/readme/USAGE.md +++ b/mail_activity_team/readme/USAGE.md @@ -12,5 +12,8 @@ Teams. When you create a new activity the application will propose the user's assigned team. +When creating activity plans, instead of assigning an activity to a user, there +is also the option to assign it to a team instead. + You can report on the activities assigned to a team going to *Dashboards / Activities*, and then filter by a specific team or group by teams. diff --git a/mail_activity_team/security/mail_activity_team_security.xml b/mail_activity_team/security/mail_activity_team_security.xml index ece5bff24..f62afb42d 100644 --- a/mail_activity_team/security/mail_activity_team_security.xml +++ b/mail_activity_team/security/mail_activity_team_security.xml @@ -6,7 +6,7 @@ [('team_id', 'in', user.activity_team_ids.ids)] - + diff --git a/mail_activity_team/static/description/index.html b/mail_activity_team/static/description/index.html index 07fbbce1d..6d8bc9d4a 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. @@ -404,11 +399,13 @@

    Usage

    Teams.

    When you create a new activity the application will propose the user’s assigned team.

    +

    When creating activity plans, instead of assigning an activity to a +user, there is also the option to assign it to a team instead.

    You can report on the activities assigned to a team going to Dashboards / 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 @@ -416,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 @@ -477,6 +474,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 4a20b4039..643bd0863 100644 --- a/mail_activity_team/tests/test_mail_activity_team.py +++ b/mail_activity_team/tests/test_mail_activity_team.py @@ -4,6 +4,7 @@ from datetime import date from odoo.exceptions import ValidationError +from odoo.fields import Command from odoo.modules.migration import load_script from odoo.tests import Form from odoo.tests.common import TransactionCase @@ -24,9 +25,7 @@ def setUpClass(cls): "login": "csu", "email": "crmuser@yourcompany.com", "groups_id": [ - ( - 6, - 0, + Command.set( [ cls.env.ref("base.group_user").id, cls.env.ref("base.group_partner_manager").id, @@ -41,7 +40,7 @@ def setUpClass(cls): "name": "Employee 2", "login": "csu2", "email": "crmuser2@yourcompany.com", - "groups_id": [(6, 0, [cls.env.ref("base.group_user").id])], + "groups_id": [Command.set([cls.env.ref("base.group_user").id])], } ) cls.employee3 = cls.env["res.users"].create( @@ -50,7 +49,7 @@ def setUpClass(cls): "name": "Employee 3", "login": "csu3", "email": "crmuser3@yourcompany.com", - "groups_id": [(6, 0, [cls.env.ref("base.group_user").id])], + "groups_id": [Command.set([cls.env.ref("base.group_user").id])], } ) # Create Activity Types @@ -91,15 +90,15 @@ def setUpClass(cls): cls.team1 = cls.env["mail.activity.team"].create( { "name": "Team 1", - "res_model_ids": [(6, 0, [cls.partner_ir_model.id])], - "member_ids": [(6, 0, [cls.employee.id])], + "res_model_ids": [Command.set([cls.partner_ir_model.id])], + "member_ids": [Command.set([cls.employee.id])], } ) cls.team2 = cls.env["mail.activity.team"].create( { "name": "Team 2", - "res_model_ids": [(6, 0, [cls.partner_ir_model.id])], - "member_ids": [(6, 0, [cls.employee.id, cls.employee2.id])], + "res_model_ids": [Command.set([cls.partner_ir_model.id])], + "member_ids": [Command.set([cls.employee.id, cls.employee2.id])], } ) cls.act2 = ( @@ -318,6 +317,62 @@ def test_activity_schedule_next(self): # As we are in a 'team activity' context, the user should not be set self.assertEqual(next_activities.user_id, self.env["res.users"]) + def test_mail_activity_schedule_wizard(self): + self.activity1.default_team_id = self.team1 + wizard_form = Form( + self.env["mail.activity.schedule"].with_context( + active_ids=self.partner_client.ids, + active_model=self.partner_client._name, + ) + ) + wizard_form.activity_type_id = self.activity1 + # The activity's default team is set, and its member is the assigned user + self.assertEqual(wizard_form.activity_team_id, self.team1) + self.assertEqual(wizard_form.activity_team_user_id, self.employee) + + # Assign a team with a default member + self.team2.user_id = self.employee2 + wizard_form.activity_team_id = self.team2 + # Original team user is kept because it is also a member of the new team + self.assertEqual(wizard_form.activity_team_user_id, self.employee) + + # Reset some values and assign the team with the default user again + wizard_form.activity_team_user_id = self.env["res.users"] + wizard_form.activity_team_id = self.team2 + # Now the team user is the default user of the team + self.assertEqual(wizard_form.activity_team_user_id, self.employee2) + + other_team = self.env["mail.activity.team"].create( + { + "name": "Team 3", + "member_ids": [ + Command.link(self.employee.id), + Command.link(self.employee3.id), + ], + }, + ) + wizard_form.activity_team_id = other_team + # Employee 2 is not a member of the new team, so team user is reset + self.assertFalse(wizard_form.activity_team_user_id) + + # Set one of the members of the team and schedule the activity + wizard_form.activity_team_user_id = self.employee3 + + activities = self.partner_client.activity_ids + # Schedule the activity + wizard_form.save().action_schedule_activities() + activity = self.partner_client.activity_ids - activities + self.assertRecordValues( + activity, + [ + { + "activity_type_id": self.activity1.id, + "team_id": other_team.id, + "user_id": self.employee3.id, + }, + ], + ) + def test_schedule_activity_from_server_action(self): partner = self.env["res.partner"].create({"name": "Test Partner"}) action = self.env["ir.actions.server"].create( @@ -447,3 +502,178 @@ def test_migration(self): mod.migrate(self.env.cr, "18.0.1.0.0") self.assertFalse(rule.perm_create) + + def test_mail_activity_plan_ui_logic(self): + """Check team/team user consistency in plan template view""" + plan = self.env["mail.activity.plan"].create( + { + "name": __name__, + "res_model": "res.partner", + } + ) + self.activity1.default_team_id = self.team1 + template = self.env["mail.activity.plan.template"].create( + { + "summary": __name__, + "responsible_type": "other", + "responsible_id": self.employee3.id, + "activity_type_id": self.activity1.id, + "plan_id": plan.id, + "sequence": 1, + "delay_count": 1, + } + ) + # Team is not set by default + self.assertFalse(template.activity_team_id) + # If template is set to assign to team, default team of the activity is set + template.responsible_type = "team" + self.assertEqual(template.activity_team_id, self.team1) + + # Can't just remove the team without changing the assignment type + with self.assertRaisesRegex( + ValidationError, + "Please enter an activity team", + ): + with self.env.cr.savepoint(): + template.activity_team_id = False + + # Team is reset if type is not team + template.write( + { + "responsible_type": "other", + "responsible_id": self.env.user.id, + }, + ) + self.assertFalse(template.activity_team_id) + + # The default team user is set to the only member of the team + template.write( + { + "responsible_type": "team", + "activity_team_id": self.team1.id, + } + ) + self.assertEqual(template.activity_team_user_id, self.employee) + # The responsible is reset if assignment is changed to team + self.assertFalse(template.responsible_id) + # Assign a team with a default member + self.team2.user_id = self.employee2 + template.activity_team_id = self.team2 + # Original team user is kept because it is also a member of the new team + self.assertEqual(template.activity_team_user_id, self.employee) + # Team user is reset if team is reset + template.write( + { + "responsible_type": "other", + "responsible_id": self.env.user.id, + "activity_team_id": False, + }, + ) + self.assertFalse(template.activity_team_user_id) + # Assign the team with the default user again + template.write( + { + "responsible_type": "team", + "activity_team_id": self.team2.id, + }, + ) + # Now the team user is the default user of the team + self.assertEqual(template.activity_team_user_id, self.employee2) + + other_team = self.env["mail.activity.team"].create( + { + "name": "Team 3", + "member_ids": [ + Command.link(self.employee.id), + Command.link(self.employee3.id), + ], + }, + ) + template.activity_team_id = other_team + # Employee 2 is not a member of the new team, so team user is reset + self.assertFalse(template.activity_team_user_id) + + def test_mail_activity_plan(self): + """Activities for teams can be scheduled using an activity plan""" + plan = self.env["mail.activity.plan"].create( + { + "name": __name__, + "res_model": "res.partner", + } + ) + self.env["mail.activity.plan.template"].create( + { + "summary": __name__, + "responsible_type": "other", + "responsible_id": self.employee3.id, + "activity_type_id": self.activity1.id, + "plan_id": plan.id, + "sequence": 1, + "delay_count": 1, + } + ) + self.env["mail.activity.plan.template"].create( + { + "summary": __name__, + "responsible_type": "team", + "activity_team_id": self.team1.id, + "activity_type_id": self.activity2.id, + "plan_id": plan.id, + "sequence": 2, + "delay_count": 2, + } + ) + activities = self.partner_client.activity_ids + + wizard = ( + self.env["mail.activity.schedule"] + .with_context( + active_ids=self.partner_client.ids, + active_model=self.partner_client._name, + ) + .create( + { + "plan_id": plan.id, + "plan_date": date.today(), + } + ) + ) + wizard.action_schedule_plan() + new_activities = self.partner_client.activity_ids - activities + self.assertRecordValues( + new_activities, + [ + { + "activity_type_id": self.activity2.id, + "team_id": self.team1.id, + "user_id": False, + }, + { + "activity_type_id": self.activity1.id, + "team_id": False, + "user_id": self.employee3.id, + }, + ], + ) + + # Coverage: _plan_filter_activity_templates_to_schedule will still + # return both activities if called without special context key + self.assertEqual( + wizard._plan_filter_activity_templates_to_schedule(), + plan.template_ids, + ) + # or when upper frame inspection fails + with self.assertLogs( + "odoo.addons.mail_activity_team.wizard.mail_activity_schedule", + level="WARNING", + ) as log_catcher: + self.assertEqual( + wizard.with_context( + fire_team_activities=True, + )._plan_filter_activity_templates_to_schedule(), + plan.template_ids, + ) + self.assertIn( + "Could not find 'activity_descriptions' list in inspected frames", + log_catcher.output[0], + ) diff --git a/mail_activity_team/views/mail_activity_plan_template_views.xml b/mail_activity_team/views/mail_activity_plan_template_views.xml new file mode 100644 index 000000000..cf9a2e093 --- /dev/null +++ b/mail_activity_team/views/mail_activity_plan_template_views.xml @@ -0,0 +1,24 @@ + + + + + mail.activity.plan.template + + + + + + + + diff --git a/mail_activity_team/views/mail_activity_plan_views.xml b/mail_activity_team/views/mail_activity_plan_views.xml new file mode 100644 index 000000000..e6c95b13b --- /dev/null +++ b/mail_activity_team/views/mail_activity_plan_views.xml @@ -0,0 +1,15 @@ + + + + + mail.activity.plan + + + + + + + diff --git a/mail_activity_team/wizard/mail_activity_schedule.py b/mail_activity_team/wizard/mail_activity_schedule.py index c8a18eed1..56b02c59c 100644 --- a/mail_activity_team/wizard/mail_activity_schedule.py +++ b/mail_activity_team/wizard/mail_activity_schedule.py @@ -1,8 +1,13 @@ # Copyright 2024 Camptocamp SA # Copyright 2024 CorporateHub # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import inspect +import logging from odoo import api, fields, models +from odoo.tools.misc import format_date + +_logger = logging.getLogger(__name__) class MailActivitySchedule(models.TransientModel): @@ -60,13 +65,70 @@ def _onchange_activity_team_user_id(self): ) def _action_schedule_activities(self): - return self._get_applied_on_records().activity_schedule( - activity_type_id=self.activity_type_id.id, - automated=False, - summary=self.summary, - note=self.note, - user_id=self.activity_team_user_id.id, - team_user_id=self.activity_team_user_id.id, - team_id=self.activity_team_id.id, - date_deadline=self.date_deadline, + # Insert default team data which is picked up for activities that are + # created without a team already. + self = self.with_context( + schedule_default_team_id=self.activity_team_id.id, + schedule_default_team_user_id=self.activity_team_user_id.id, + schedule_default_user_id=self.activity_team_user_id.id, + ) + return super()._action_schedule_activities() + + def action_schedule_plan(self): + # Triggering scheduled team activities in + # _plan_filter_activity_templates_to_schedule which is called from the + # super method to fetch the activities that need to be scheduled. + # This is because activity parameters are determined inline in the + # super method, and the activity team cannot be inserted there in a + # clean override. + self = self.with_context(fire_team_activities=True) + return super().action_schedule_plan() + + @staticmethod + def _get_activity_schedule_plan_data(): + """Fetch some variables defined in action_schedule_plan""" + frame = inspect.currentframe() + while frame.f_back: + frame = frame.f_back + f_locals = frame.f_locals + if "activity_descriptions" in f_locals and "record" in f_locals: + return f_locals["record"], f_locals["activity_descriptions"] + _logger.warning( + "Could not find 'activity_descriptions' list in inspected frames" ) + return None, None + + def _plan_filter_activity_templates_to_schedule(self): + # Instead of returning all templates, including those with a team, + # go ahead and schedule only those with a team and only return + # the remaining activity templates. + res = super()._plan_filter_activity_templates_to_schedule() + if self.env.context.get("fire_team_activities"): + # Immediately schedule team activities + record, activity_descriptions = self._get_activity_schedule_plan_data() + if record is None: + return res + templates = res.filtered("activity_team_required") + others = res - templates + for template in templates: + date_deadline = template._get_date_deadline(self.plan_date) + record.activity_schedule( + activity_type_id=template.activity_type_id.id, + automated=False, + summary=template.summary, + note=template.note, + user_id=template.activity_team_user_id.id, + team_id=template.activity_team_id.id, + date_deadline=date_deadline, + ) + activity_descriptions.append( + self.env._( + "%(activity)s, assigned to team %(name)s, " + "due on the %(deadline)s", + activity=template.summary or template.activity_type_id.name, + name=template.activity_team_id.name, + deadline=format_date(self.env, date_deadline), + ) + ) + return others + return res