diff --git a/mail_activity_plan_domain/README.rst b/mail_activity_plan_domain/README.rst new file mode 100644 index 000000000..2d4438d2f --- /dev/null +++ b/mail_activity_plan_domain/README.rst @@ -0,0 +1,130 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================= +Mail Activity Plan Domain +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2710542277c45fe88cd38a70506deaf32b1614d5f77537c9ed250123f6877682 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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 + :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 + :target: https://github.com/OCA/mail/tree/17.0/mail_activity_plan_domain + :alt: OCA/mail +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/mail-17-0/mail-17-0-mail_activity_plan_domain + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/mail&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends Odoo's activity plan feature with domain-based +filtering at two levels: + +**Plan domain** + +Each activity plan gains a *Domain* field. Only records matching this +domain will have the plan available in the scheduling wizard +(``mail.activity.schedule``). This lets you restrict plans to, for +example, company-type partners or records in a specific stage. + +**Template domain** + +Each line of an activity plan (template) gains its own *Domain* field. +When executing a plan, activities whose template domain does not match +the target record are silently skipped. This allows a single plan to +cover heterogeneous records while still generating only the relevant +activities per record. + +**Notes** + +- The error preview shown in the scheduling wizard (missing responsible, + etc.) deliberately ignores template domains so that all potential + configuration issues remain visible. +- When scheduling a plan on multiple records, execution is serialized + record by record so that each record is evaluated independently + against both plan and template domains. +- Domain syntax follows the standard Odoo domain format, e.g. + ``[('is_company', '=', True)]``. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use this module: + +1. Go to **Discuss > Configuration > Activity Plans**. +2. Open or create an activity plan. +3. In the **Domain** field, set the domain that records must match for + this plan to appear in the scheduling wizard (e.g. + ``[('is_company', '=', True)]`` to restrict the plan to company-type + partners). Leave empty or use ``[]`` to apply to all records. +4. In the plan lines, each activity template also has its own **Domain** + field. Set it to skip that activity for records that do not match + (e.g. ``[('is_company', '=', False)]`` to schedule an activity only + for individual contacts). Leave empty or use ``[]`` to always + schedule the activity. + +When scheduling a plan from a record, only plans whose **Domain** +matches that record will be listed. During execution, activities whose +template domain does not match the record are silently skipped. + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Tecnativa + +Contributors +------------ + +- `Tecnativa `__: + + - Cristina Hidalgo + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/mail `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mail_activity_plan_domain/__init__.py b/mail_activity_plan_domain/__init__.py new file mode 100644 index 000000000..aee8895e7 --- /dev/null +++ b/mail_activity_plan_domain/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/mail_activity_plan_domain/__manifest__.py b/mail_activity_plan_domain/__manifest__.py new file mode 100644 index 000000000..81b843bad --- /dev/null +++ b/mail_activity_plan_domain/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "Mail Activity Plan Domain", + "summary": "Apply domain filters to activity plans and their templates", + "version": "17.0.1.0.0", + "development_status": "Beta", + "category": "Discuss", + "website": "https://github.com/OCA/mail", + "author": "Tecnativa, Odoo Community Association (OCA)", + "license": "AGPL-3", + "installable": True, + "depends": ["mail"], + "data": [ + "views/mail_activity_plan_views.xml", + "views/mail_activity_plan_template_views.xml", + ], +} diff --git a/mail_activity_plan_domain/i18n/es.po b/mail_activity_plan_domain/i18n/es.po new file mode 100644 index 000000000..900c46e43 --- /dev/null +++ b/mail_activity_plan_domain/i18n/es.po @@ -0,0 +1,63 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mail_activity_plan_domain +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-15 13:13+0000\n" +"PO-Revision-Date: 2026-04-15 13:13+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: mail_activity_plan_domain +#: model:ir.model,name:mail_activity_plan_domain.model_mail_activity_plan +msgid "Activity Plan" +msgstr "Plan de actividad" + +#. module: mail_activity_plan_domain +#: model:ir.model,name:mail_activity_plan_domain.model_mail_activity_plan_template +msgid "Activity plan template" +msgstr "Plantilla del plan de actividad" + +#. module: mail_activity_plan_domain +#: model:ir.model,name:mail_activity_plan_domain.model_mail_activity_schedule +msgid "Activity schedule plan Wizard" +msgstr "Asistente para planificar actividades" + +#. module: mail_activity_plan_domain +#: model:ir.model.fields,field_description:mail_activity_plan_domain.field_mail_activity_plan__domain +#: model:ir.model.fields,field_description:mail_activity_plan_domain.field_mail_activity_plan_template__domain +msgid "Domain" +msgstr "Dominio" + +#. module: mail_activity_plan_domain +#: model:ir.model.fields,help:mail_activity_plan_domain.field_mail_activity_plan_template__domain +msgid "" +"Domain to filter the records for which this activity will be scheduled. " +"Leave empty or use '[]' to always schedule this activity." +msgstr "" +"Dominio para filtrar los registros para los que se programará esta " +"actividad. Déjelo vacío o use '[]' para programar siempre esta actividad." + +#. module: mail_activity_plan_domain +#: model:ir.model.fields,help:mail_activity_plan_domain.field_mail_activity_plan__domain +msgid "" +"Domain to filter the records on which this plan is applicable. Leave empty " +"or use '[]' to apply to all records of the target model." +msgstr "" +"Dominio para filtrar los registros a los que es aplicable este plan. " +"Déjelo vacío o use '[]' para aplicarlo a todos los registros del modelo." + +#. module: mail_activity_plan_domain +#. odoo-python +#: code:addons/mail_activity_plan_domain/models/mail_activity_schedule.py:0 +#, python-format +msgid "Launch Plans" +msgstr "Lanzar planes" diff --git a/mail_activity_plan_domain/i18n/mail_activity_plan_domain.pot b/mail_activity_plan_domain/i18n/mail_activity_plan_domain.pot new file mode 100644 index 000000000..b6c69a626 --- /dev/null +++ b/mail_activity_plan_domain/i18n/mail_activity_plan_domain.pot @@ -0,0 +1,58 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mail_activity_plan_domain +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-15 13:12+0000\n" +"PO-Revision-Date: 2026-04-15 13:12+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: mail_activity_plan_domain +#: model:ir.model,name:mail_activity_plan_domain.model_mail_activity_plan +msgid "Activity Plan" +msgstr "" + +#. module: mail_activity_plan_domain +#: model:ir.model,name:mail_activity_plan_domain.model_mail_activity_plan_template +msgid "Activity plan template" +msgstr "" + +#. module: mail_activity_plan_domain +#: model:ir.model,name:mail_activity_plan_domain.model_mail_activity_schedule +msgid "Activity schedule plan Wizard" +msgstr "" + +#. module: mail_activity_plan_domain +#: model:ir.model.fields,field_description:mail_activity_plan_domain.field_mail_activity_plan__domain +#: model:ir.model.fields,field_description:mail_activity_plan_domain.field_mail_activity_plan_template__domain +msgid "Domain" +msgstr "" + +#. module: mail_activity_plan_domain +#: model:ir.model.fields,help:mail_activity_plan_domain.field_mail_activity_plan_template__domain +msgid "" +"Domain to filter the records for which this activity will be scheduled. " +"Leave empty or use '[]' to always schedule this activity." +msgstr "" + +#. module: mail_activity_plan_domain +#: model:ir.model.fields,help:mail_activity_plan_domain.field_mail_activity_plan__domain +msgid "" +"Domain to filter the records on which this plan is applicable. Leave empty " +"or use '[]' to apply to all records of the target model." +msgstr "" + +#. module: mail_activity_plan_domain +#. odoo-python +#: code:addons/mail_activity_plan_domain/models/mail_activity_schedule.py:0 +#, python-format +msgid "Launch Plans" +msgstr "" diff --git a/mail_activity_plan_domain/models/__init__.py b/mail_activity_plan_domain/models/__init__.py new file mode 100644 index 000000000..f09097778 --- /dev/null +++ b/mail_activity_plan_domain/models/__init__.py @@ -0,0 +1,2 @@ +from . import mail_activity_plan +from . import mail_activity_plan_template diff --git a/mail_activity_plan_domain/models/mail_activity_plan.py b/mail_activity_plan_domain/models/mail_activity_plan.py new file mode 100644 index 000000000..71bf3df74 --- /dev/null +++ b/mail_activity_plan_domain/models/mail_activity_plan.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class MailActivityPlan(models.Model): + _inherit = "mail.activity.plan" + + domain = fields.Char( + default="[]", + help="Domain to filter the records on which this plan is applicable. " + "Leave empty or use '[]' to apply to all records of the target model.", + ) diff --git a/mail_activity_plan_domain/models/mail_activity_plan_template.py b/mail_activity_plan_domain/models/mail_activity_plan_template.py new file mode 100644 index 000000000..ae0b3363a --- /dev/null +++ b/mail_activity_plan_domain/models/mail_activity_plan_template.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class MailActivityPlanTemplate(models.Model): + _inherit = "mail.activity.plan.template" + + domain = fields.Char( + default="[]", + help="Domain to filter the records for which this activity will be " + "scheduled. Leave empty or use '[]' to always schedule this activity.", + ) diff --git a/mail_activity_plan_domain/pyproject.toml b/mail_activity_plan_domain/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/mail_activity_plan_domain/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/mail_activity_plan_domain/readme/CONTRIBUTORS.md b/mail_activity_plan_domain/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..689a1cdc4 --- /dev/null +++ b/mail_activity_plan_domain/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Tecnativa](https://www.tecnativa.com): + - Cristina Hidalgo diff --git a/mail_activity_plan_domain/readme/DESCRIPTION.md b/mail_activity_plan_domain/readme/DESCRIPTION.md new file mode 100644 index 000000000..88a6a170b --- /dev/null +++ b/mail_activity_plan_domain/readme/DESCRIPTION.md @@ -0,0 +1,27 @@ +This module extends Odoo's activity plan feature with domain-based filtering at +two levels: + +**Plan domain** + +Each activity plan gains a *Domain* field. Only records matching this domain +will have the plan available in the scheduling wizard +(`mail.activity.schedule`). This lets you restrict plans to, for example, +company-type partners or records in a specific stage. + +**Template domain** + +Each line of an activity plan (template) gains its own *Domain* field. When +executing a plan, activities whose template domain does not match the target +record are silently skipped. This allows a single plan to cover heterogeneous +records while still generating only the relevant activities per record. + +**Notes** + +- The error preview shown in the scheduling wizard (missing responsible, + etc.) deliberately ignores template domains so that all potential + configuration issues remain visible. +- When scheduling a plan on multiple records, execution is serialized + record by record so that each record is evaluated independently against + both plan and template domains. +- Domain syntax follows the standard Odoo domain format, + e.g. `[('is_company', '=', True)]`. diff --git a/mail_activity_plan_domain/readme/USAGE.md b/mail_activity_plan_domain/readme/USAGE.md new file mode 100644 index 000000000..ff7c63f22 --- /dev/null +++ b/mail_activity_plan_domain/readme/USAGE.md @@ -0,0 +1,17 @@ +To use this module: + +1. Go to **Discuss > Configuration > Activity Plans**. +2. Open or create an activity plan. +3. In the **Domain** field, set the domain that records must match for this + plan to appear in the scheduling wizard + (e.g. `[('is_company', '=', True)]` to restrict the plan to company-type + partners). Leave empty or use `[]` to apply to all records. +4. In the plan lines, each activity template also has its own **Domain** field. + Set it to skip that activity for records that do not match + (e.g. `[('is_company', '=', False)]` to schedule an activity only for + individual contacts). Leave empty or use `[]` to always schedule the + activity. + +When scheduling a plan from a record, only plans whose **Domain** matches that +record will be listed. During execution, activities whose template domain does +not match the record are silently skipped. diff --git a/mail_activity_plan_domain/static/description/index.html b/mail_activity_plan_domain/static/description/index.html new file mode 100644 index 000000000..7a70d96cd --- /dev/null +++ b/mail_activity_plan_domain/static/description/index.html @@ -0,0 +1,476 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Mail Activity Plan Domain

+ +

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

+

This module extends Odoo’s activity plan feature with domain-based +filtering at two levels:

+

Plan domain

+

Each activity plan gains a Domain field. Only records matching this +domain will have the plan available in the scheduling wizard +(mail.activity.schedule). This lets you restrict plans to, for +example, company-type partners or records in a specific stage.

+

Template domain

+

Each line of an activity plan (template) gains its own Domain field. +When executing a plan, activities whose template domain does not match +the target record are silently skipped. This allows a single plan to +cover heterogeneous records while still generating only the relevant +activities per record.

+

Notes

+
    +
  • The error preview shown in the scheduling wizard (missing responsible, +etc.) deliberately ignores template domains so that all potential +configuration issues remain visible.
  • +
  • When scheduling a plan on multiple records, execution is serialized +record by record so that each record is evaluated independently +against both plan and template domains.
  • +
  • Domain syntax follows the standard Odoo domain format, e.g. +[('is_company', '=', True)].
  • +
+

Table of contents

+ +
+

Usage

+

To use this module:

+
    +
  1. Go to Discuss > Configuration > Activity Plans.
  2. +
  3. Open or create an activity plan.
  4. +
  5. In the Domain field, set the domain that records must match for +this plan to appear in the scheduling wizard (e.g. +[('is_company', '=', True)] to restrict the plan to company-type +partners). Leave empty or use [] to apply to all records.
  6. +
  7. In the plan lines, each activity template also has its own Domain +field. Set it to skip that activity for records that do not match +(e.g. [('is_company', '=', False)] to schedule an activity only +for individual contacts). Leave empty or use [] to always +schedule the activity.
  8. +
+

When scheduling a plan from a record, only plans whose Domain +matches that record will be listed. During execution, activities whose +template domain does not match the record are silently skipped.

+
+
+

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 +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Tecnativa
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/mail project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/mail_activity_plan_domain/tests/__init__.py b/mail_activity_plan_domain/tests/__init__.py new file mode 100644 index 000000000..7731d6df4 --- /dev/null +++ b/mail_activity_plan_domain/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mail_activity_plan_domain diff --git a/mail_activity_plan_domain/tests/test_mail_activity_plan_domain.py b/mail_activity_plan_domain/tests/test_mail_activity_plan_domain.py new file mode 100644 index 000000000..e2af395b1 --- /dev/null +++ b/mail_activity_plan_domain/tests/test_mail_activity_plan_domain.py @@ -0,0 +1,254 @@ +from odoo import Command +from odoo.tests import tagged, users + +from odoo.addons.mail.tests.test_mail_activity import ActivityScheduleCase + + +@tagged("mail_activity", "mail_activity_plan_domain") +class TestMailActivityPlanDomain(ActivityScheduleCase): + """Tests for mail_activity_plan_domain module. + + Covers: + - Plan domain: plans only appear for matching records. + - Template domain: activities are only scheduled for matching records. + - Error preview: ignores template domain (shows all potential errors). + - Multi-record serialized execution. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create two partners: one company, one individual + cls.partner_company = cls.env["res.partner"].create( + {"name": "ACME Corp", "is_company": True} + ) + cls.partner_individual = cls.env["res.partner"].create( + {"name": "John Doe", "is_company": False} + ) + # Plan without domain (applies to all partners) + cls.plan_all = cls.env["mail.activity.plan"].create( + { + "name": "Plan All Partners", + "res_model": "res.partner", + "domain": "[]", + "template_ids": [ + Command.create( + { + "activity_type_id": cls.activity_type_todo.id, + "responsible_type": "other", + "responsible_id": cls.user_admin.id, + "sequence": 10, + "summary": "General task", + } + ), + ], + } + ) + # Plan restricted to companies only + cls.plan_companies = cls.env["mail.activity.plan"].create( + { + "name": "Plan Companies Only", + "res_model": "res.partner", + "domain": "[('is_company', '=', True)]", + "template_ids": [ + Command.create( + { + "activity_type_id": cls.activity_type_todo.id, + "responsible_type": "other", + "responsible_id": cls.user_admin.id, + "sequence": 10, + "summary": "Company task", + } + ), + ], + } + ) + # Plan with two templates: one for all, one restricted to companies + cls.plan_mixed = cls.env["mail.activity.plan"].create( + { + "name": "Plan Mixed Templates", + "res_model": "res.partner", + "domain": "[]", + "template_ids": [ + Command.create( + { + "activity_type_id": cls.activity_type_todo.id, + "responsible_type": "other", + "responsible_id": cls.user_admin.id, + "sequence": 10, + "summary": "Common task", + "domain": "[]", + } + ), + Command.create( + { + "activity_type_id": cls.activity_type_todo.id, + "responsible_type": "other", + "responsible_id": cls.user_admin.id, + "sequence": 20, + "summary": "Company-only task", + "domain": "[('is_company', '=', True)]", + } + ), + ], + } + ) + + def _make_wizard(self, records, plan=None): + """Helper: instantiate the activity schedule wizard for the given + records and optionally pre-select a plan. + + Uses the same Form-based approach as the core tests to ensure computed + fields (plan_available_ids, plan_id) are correctly resolved via + onchange semantics before the record is saved. + """ + form = self._instantiate_activity_schedule_wizard(records) + if plan: + form.plan_id = plan + return form.save() + + @users("admin") + def test_plan_domain_field_exists(self): + """Plan and template have the domain field with the expected default.""" + self.assertEqual(self.plan_all.domain, "[]") + self.assertEqual(self.plan_companies.domain, "[('is_company', '=', True)]") + for template in self.plan_mixed.template_ids: + self.assertIsNotNone(template.domain) + + @users("admin") + def test_plan_available_ids_without_domain(self): + """Plan with no domain is available for any record.""" + wizard = self._make_wizard(self.partner_individual) + self.assertIn(self.plan_all, wizard.plan_available_ids) + + @users("admin") + def test_plan_domain_excludes_non_matching_records(self): + """Plan with domain is NOT available for records that don't match.""" + wizard = self._make_wizard(self.partner_individual) + self.assertNotIn(self.plan_companies, wizard.plan_available_ids) + + @users("admin") + def test_plan_domain_includes_matching_records(self): + """Plan with domain IS available for records that match.""" + wizard = self._make_wizard(self.partner_company) + self.assertIn(self.plan_companies, wizard.plan_available_ids) + + @users("admin") + def test_plan_domain_multi_record_any_match(self): + """Plan with domain is available if AT LEAST ONE selected record matches.""" + both = self.partner_company + self.partner_individual + wizard = self._make_wizard(both) + self.assertIn(self.plan_companies, wizard.plan_available_ids) + + @users("admin") + def test_plan_available_ids_recompute_on_res_ids_change(self): + """Changing res_ids triggers recomputation of available plans.""" + wizard = self._make_wizard(self.partner_individual) + self.assertNotIn(self.plan_companies, wizard.plan_available_ids) + wizard.res_ids = repr(self.partner_company.ids) + self.assertIn(self.plan_companies, wizard.plan_available_ids) + + @users("admin") + def test_template_domain_schedules_all_activities_for_company(self): + """Both templates are scheduled when the record matches the company domain.""" + wizard = self._make_wizard(self.partner_company, plan=self.plan_mixed) + with self._mock_activities(): + wizard.action_schedule_plan() + + activities = self._new_activities.filtered( + lambda a: a.res_model == "res.partner" + and a.res_id == self.partner_company.id + ) + summaries = activities.mapped("summary") + self.assertIn("Common task", summaries) + self.assertIn("Company-only task", summaries) + self.assertEqual(len(activities), 2) + + @users("admin") + def test_template_domain_skips_non_matching_activity_for_individual(self): + """Company-only template is NOT scheduled for an individual partner.""" + wizard = self._make_wizard(self.partner_individual, plan=self.plan_mixed) + with self._mock_activities(): + wizard.action_schedule_plan() + + activities = self._new_activities.filtered( + lambda a: a.res_model == "res.partner" + and a.res_id == self.partner_individual.id + ) + summaries = activities.mapped("summary") + self.assertIn("Common task", summaries) + self.assertNotIn("Company-only task", summaries) + self.assertEqual(len(activities), 1) + + @users("admin") + def test_template_domain_multi_record_per_record_filtering(self): + """In multi-record mode, template domain is evaluated per record.""" + both = self.partner_company + self.partner_individual + wizard = self._make_wizard(both, plan=self.plan_mixed) + with self._mock_activities(): + wizard.action_schedule_plan() + + company_activities = self._new_activities.filtered( + lambda a: a.res_model == "res.partner" + and a.res_id == self.partner_company.id + ) + individual_activities = self._new_activities.filtered( + lambda a: a.res_model == "res.partner" + and a.res_id == self.partner_individual.id + ) + self.assertEqual(len(company_activities), 2) + self.assertEqual(len(individual_activities), 1) + self.assertNotIn("Company-only task", individual_activities.mapped("summary")) + + @users("admin") + def test_error_preview_ignores_template_domain(self): + """The wizard error preview checks ALL templates regardless of domain. + + This ensures that missing responsible warnings are shown even for + templates that would be filtered out for the current record. + """ + plan_error = self.env["mail.activity.plan"].create( + { + "name": "Plan Error Preview", + "res_model": "res.partner", + "domain": "[]", + "template_ids": [ + Command.create( + { + "activity_type_id": self.activity_type_todo.id, + "responsible_type": "on_demand", + "sequence": 10, + "summary": "Task needs responsible", + "domain": "[('is_company', '=', True)]", + } + ), + ], + } + ) + # Individual record — template domain would exclude this template, + # but the error preview must still warn about missing responsible. + # We must clear plan_on_demand_user_id (default = current user) to + # trigger the "no responsible" error, same as the core tests do. + # Note: the wizard cannot be saved when has_error=True (_check_consistency + # constraint), so we read has_error/error from the Form directly. + form = self._instantiate_activity_schedule_wizard(self.partner_individual) + form.plan_id = plan_error + form.plan_on_demand_user_id = self.env["res.users"] + self.assertTrue(form.has_error) + self.assertIn("No responsible specified", form.error) + + @users("admin") + def test_plan_domain_skips_non_matching_records_on_execution(self): + """Single-record execution path works correctly for matching record.""" + wizard = self._make_wizard(self.partner_individual, plan=self.plan_all) + with self._mock_activities(): + wizard.with_context( + mail_activity_plan_domain_record_id=self.partner_individual.id + ).action_schedule_plan() + + activities = self._new_activities.filtered( + lambda a: a.res_model == "res.partner" + and a.res_id == self.partner_individual.id + ) + self.assertEqual(len(activities), 1) + self.assertEqual(activities[0].summary, "General task") diff --git a/mail_activity_plan_domain/views/mail_activity_plan_template_views.xml b/mail_activity_plan_domain/views/mail_activity_plan_template_views.xml new file mode 100644 index 000000000..4c3a4cf9a --- /dev/null +++ b/mail_activity_plan_domain/views/mail_activity_plan_template_views.xml @@ -0,0 +1,31 @@ + + + + + mail.activity.plan.view.form.template.domain + mail.activity.plan + + + + + + + + + + + mail.activity.plan.template.view.form.domain + mail.activity.plan.template + + + + + + + + diff --git a/mail_activity_plan_domain/views/mail_activity_plan_views.xml b/mail_activity_plan_domain/views/mail_activity_plan_views.xml new file mode 100644 index 000000000..b39f0ca9d --- /dev/null +++ b/mail_activity_plan_domain/views/mail_activity_plan_views.xml @@ -0,0 +1,21 @@ + + + + + mail.activity.plan.view.form.domain + mail.activity.plan + + + + + + + + + + + diff --git a/mail_activity_plan_domain/wizards/__init__.py b/mail_activity_plan_domain/wizards/__init__.py new file mode 100644 index 000000000..6c7dc5222 --- /dev/null +++ b/mail_activity_plan_domain/wizards/__init__.py @@ -0,0 +1 @@ +from . import mail_activity_schedule diff --git a/mail_activity_plan_domain/wizards/mail_activity_schedule.py b/mail_activity_plan_domain/wizards/mail_activity_schedule.py new file mode 100644 index 000000000..b7aded7c3 --- /dev/null +++ b/mail_activity_plan_domain/wizards/mail_activity_schedule.py @@ -0,0 +1,108 @@ +from odoo import _, api, models +from odoo.tools.safe_eval import safe_eval + + +class MailActivitySchedule(models.TransientModel): + _inherit = "mail.activity.schedule" + + @api.depends("res_ids") + def _compute_plan_available_ids(self): + # Add res_ids as dependency so that available plans recompute when + # the selected records change (needed for plan domain evaluation) + return super()._compute_plan_available_ids() + + def _compute_plan_id(self): + # Preserve an already-selected plan when it is still available. + # The core implementation resets plan_id to False unless plan_mode is + # active. This override keeps the current value when it is still among + # the available plans, avoiding unwanted resets triggered by the ORM + # recompute cascade (e.g. when res_ids changes or during create()). + self = self.filtered( + lambda s: not s.plan_id or s.plan_id not in s.plan_available_ids + ) + return super()._compute_plan_id() + + def _get_plan_available_base_domain(self): + # Extend the base domain to also filter plans whose domain matches + # at least one of the currently selected records (AND condition). + domain = super()._get_plan_available_base_domain() + records = self._get_applied_on_records() + if not records: + return domain + all_plans = self.env["mail.activity.plan"].search(domain) + eval_context = {"uid": self.env.uid, "user": self.env.user} + valid_ids = [ + plan.id + for plan in all_plans + if self._is_plan_domain_matching(plan, records, eval_context) + ] + return [("id", "in", valid_ids)] + + def _is_plan_domain_matching(self, plan, records, eval_context=None): + """Return True if the plan has no domain or if at least one of the + given records matches the plan's domain.""" + if not plan.domain or plan.domain == "[]": + return True + if eval_context is None: + eval_context = {"uid": self.env.uid, "user": self.env.user} + return bool(records.filtered_domain(safe_eval(plan.domain, eval_context))) + + def action_schedule_plan(self): + # Serialize execution record by record so that the template domain + # can be evaluated individually per record. + # When the context key ``mail_activity_plan_domain_record_id`` is set, + # this method acts as a single-record dispatcher and delegates to the + # standard implementation via super(). Otherwise it loops over each + # record, calling itself with the proper context. + if self.env.context.get("mail_activity_plan_domain_record_id"): + # Single-record mode: let the standard implementation run. + # _get_applied_on_records and _plan_filter_activity_templates_to_schedule + # are already overridden to handle the context record. + return super().action_schedule_plan() + applied_on = self._get_applied_on_records() + for record in applied_on: + self.with_context( + mail_activity_plan_domain_record_id=record.id + ).action_schedule_plan() + if len(applied_on) == 1: + return {"type": "ir.actions.client", "tag": "soft_reload"} + return { + "type": "ir.actions.act_window", + "res_model": self.res_model, + "name": _("Launch Plans"), + "view_mode": "tree,form", + "target": "current", + "domain": [("id", "in", applied_on.ids)], + } + + def _get_applied_on_records(self): + # When called from the serialized loop (context has a single record + # ID), return only that record instead of re-browsing all res_ids. + record_id = self.env.context.get("mail_activity_plan_domain_record_id") + if record_id: + return self.env[self.res_model].browse(record_id) + return super()._get_applied_on_records() + + def _plan_filter_activity_templates_to_schedule(self): + # Filter plan templates whose domain matches the current record. + # Note: intentionally NOT used by _check_plan_templates_error so that + # the error preview still shows all potential issues regardless of + # template domains. + templates = super()._plan_filter_activity_templates_to_schedule() + record_id = self.env.context.get("mail_activity_plan_domain_record_id") + if not record_id: + return templates + record = self.env[self.res_model].browse(record_id) + eval_context = {"uid": self.env.uid, "user": self.env.user} + return templates.filtered( + lambda tpl: self._is_template_domain_matching(tpl, record, eval_context) + ) + + def _is_template_domain_matching(self, template, record, eval_context=None): + """Return True if the template has no domain or if the given record + matches the template's domain.""" + if not template.domain or template.domain == "[]": + return True + if eval_context is None: + eval_context = {"uid": self.env.uid, "user": self.env.user} + return bool(record.filtered_domain(safe_eval(template.domain, eval_context)))