From 0073908eebdf9db57d45e89f5802f59714b7bb5b Mon Sep 17 00:00:00 2001 From: IsabelAForgeFlow Date: Mon, 15 Jun 2026 11:33:49 +0200 Subject: [PATCH] [ADD] project_task_stage_state_sync --- project_task_stage_state_sync/README.rst | 107 +++++ project_task_stage_state_sync/__init__.py | 3 + project_task_stage_state_sync/__manifest__.py | 14 + .../models/__init__.py | 4 + .../models/project_task.py | 28 ++ .../models/project_task_type.py | 41 ++ project_task_stage_state_sync/pyproject.toml | 3 + .../readme/CONFIGURE.md | 14 + .../readme/CONTRIBUTORS.md | 1 + .../readme/DESCRIPTION.md | 12 + .../static/description/index.html | 451 ++++++++++++++++++ .../tests/__init__.py | 3 + .../test_project_task_stage_state_sync.py | 107 +++++ .../views/project_task_type_views.xml | 13 + 14 files changed, 801 insertions(+) create mode 100644 project_task_stage_state_sync/README.rst create mode 100644 project_task_stage_state_sync/__init__.py create mode 100644 project_task_stage_state_sync/__manifest__.py create mode 100644 project_task_stage_state_sync/models/__init__.py create mode 100644 project_task_stage_state_sync/models/project_task.py create mode 100644 project_task_stage_state_sync/models/project_task_type.py create mode 100644 project_task_stage_state_sync/pyproject.toml create mode 100644 project_task_stage_state_sync/readme/CONFIGURE.md create mode 100644 project_task_stage_state_sync/readme/CONTRIBUTORS.md create mode 100644 project_task_stage_state_sync/readme/DESCRIPTION.md create mode 100644 project_task_stage_state_sync/static/description/index.html create mode 100644 project_task_stage_state_sync/tests/__init__.py create mode 100644 project_task_stage_state_sync/tests/test_project_task_stage_state_sync.py create mode 100644 project_task_stage_state_sync/views/project_task_type_views.xml diff --git a/project_task_stage_state_sync/README.rst b/project_task_stage_state_sync/README.rst new file mode 100644 index 0000000000..73c6d0dec6 --- /dev/null +++ b/project_task_stage_state_sync/README.rst @@ -0,0 +1,107 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============================================= +Project Task Stage/State - Bidirectional Sync +============================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:92f5e5d8c27c7ac220d68e1fe3232afa4315e842af76fd342ce8f33cdac18b35 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fproject-lightgray.png?logo=github + :target: https://github.com/OCA/project/tree/19.0/project_task_stage_state_sync + :alt: OCA/project +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/project-19-0/project-19-0-project_task_stage_state_sync + :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/project&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends ``project_task_stage_state`` to complete the +bidirectional link between task states and stages. + +``project_task_stage_state`` already handles the **Stage → State** +direction: moving a task to a stage updates its state to the one +configured on that stage. + +This module adds the **State → Stage** direction: when a task's state +changes (manually or programmatically), the task is automatically moved +to the stage configured for that state. + +The sync is configured per stage via a checkbox, so it can be enabled +selectively without affecting every stage in every project. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +On each stage (Project → Configuration → Stages): + +1. Set the **State** field to the state that moving to this stage should + assign. +2. Enable **Sync State → Stage** to also activate the reverse: tasks + whose state is set to that value will automatically move to this + stage. + +**Uniqueness rule:** Only one stage per project may have *Sync State → +Stage* enabled for a given state value. Attempting to enable it on a +second stage that shares the same state and project will raise a +validation error naming the conflicting stage. Two different projects +are allowed to each have their own stage with sync enabled for the same +state — for example, both "Project A / Done" and "Project B / Done" can +independently have *Sync State → Stage* ticked for the *Done* state. The +uniqueness rule only applies within a single project. + +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 +======= + +Contributors +------------ + +- Isabel Andreu + +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/project `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/project_task_stage_state_sync/__init__.py b/project_task_stage_state_sync/__init__.py new file mode 100644 index 0000000000..e87e85c4c4 --- /dev/null +++ b/project_task_stage_state_sync/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from . import models diff --git a/project_task_stage_state_sync/__manifest__.py b/project_task_stage_state_sync/__manifest__.py new file mode 100644 index 0000000000..ee7d95482f --- /dev/null +++ b/project_task_stage_state_sync/__manifest__.py @@ -0,0 +1,14 @@ +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +{ + "name": "Project Task Stage/State - Bidirectional Sync", + "version": "19.0.1.0.0", + "category": "Project Management", + "summary": "Optionally move tasks to the matching stage when their state changes", + "author": "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/project", + "license": "AGPL-3", + "installable": True, + "depends": ["project_task_stage_state"], + "data": ["views/project_task_type_views.xml"], +} diff --git a/project_task_stage_state_sync/models/__init__.py b/project_task_stage_state_sync/models/__init__.py new file mode 100644 index 0000000000..b209446f0b --- /dev/null +++ b/project_task_stage_state_sync/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from . import project_task +from . import project_task_type diff --git a/project_task_stage_state_sync/models/project_task.py b/project_task_stage_state_sync/models/project_task.py new file mode 100644 index 0000000000..f2525ca794 --- /dev/null +++ b/project_task_stage_state_sync/models/project_task.py @@ -0,0 +1,28 @@ +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from odoo import models + + +class ProjectTask(models.Model): + _inherit = "project.task" + + def _inverse_state(self): + res = super()._inverse_state() + self._sync_stage_from_state() + return res + + def _sync_stage_from_state(self): + for task in self: + if task.stage_id.task_state == task.state: + continue + stage = task._find_stage_for_state(task.state) + if stage: + task.stage_id = stage + + def _find_stage_for_state(self, state): + self.ensure_one() + if not state: + return self.env["project.task.type"] + return self.project_id.type_ids.filtered( + lambda s: s.task_state == state and s.sync_state_to_stage + )[:1] diff --git a/project_task_stage_state_sync/models/project_task_type.py b/project_task_stage_state_sync/models/project_task_type.py new file mode 100644 index 0000000000..2bc60b9341 --- /dev/null +++ b/project_task_stage_state_sync/models/project_task_type.py @@ -0,0 +1,41 @@ +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class ProjectTaskType(models.Model): + _inherit = "project.task.type" + + sync_state_to_stage = fields.Boolean( + string="Sync State → Stage", + help="If ticked, setting a task's state to the value configured above " + "will automatically move that task to this stage.", + ) + + @api.constrains("sync_state_to_stage", "task_state", "project_ids") + def _check_unique_sync_per_state_per_project(self): + for stage in self.filtered("sync_state_to_stage"): + if not stage.task_state: + continue + for project in stage.project_ids: + conflicting = project.type_ids.filtered( + lambda s, stage=stage: s.id != stage.id + and s.task_state == stage.task_state + and s.sync_state_to_stage + ) + if conflicting: + state_label = dict(stage._get_task_states()).get( + stage.task_state, stage.task_state + ) + raise ValidationError( + self.env._( + "Stage '%(conflicting_stage)s' in project '%(project)s' " + "is already configured to move tasks here when their state " + "is '%(state)s'. Only one stage per project can have " + "Sync State → Stage enabled for the same state value.", + conflicting_stage=conflicting[0].name, + project=project.name, + state=state_label, + ) + ) diff --git a/project_task_stage_state_sync/pyproject.toml b/project_task_stage_state_sync/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/project_task_stage_state_sync/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/project_task_stage_state_sync/readme/CONFIGURE.md b/project_task_stage_state_sync/readme/CONFIGURE.md new file mode 100644 index 0000000000..ecbf070b47 --- /dev/null +++ b/project_task_stage_state_sync/readme/CONFIGURE.md @@ -0,0 +1,14 @@ +On each stage (Project → Configuration → Stages): + +1. Set the **State** field to the state that moving to this stage should assign. +2. Enable **Sync State → Stage** to also activate the reverse: tasks whose state + is set to that value will automatically move to this stage. + +**Uniqueness rule:** Only one stage per project may have *Sync State → Stage* +enabled for a given state value. Attempting to enable it on a second stage that +shares the same state and project will raise a validation error naming the +conflicting stage. Two different projects are allowed to each +have their own stage with sync enabled for the same state — for example, +both "Project A / Done" and "Project B / Done" can independently have +*Sync State → Stage* ticked for the *Done* state. The uniqueness rule only +applies within a single project. diff --git a/project_task_stage_state_sync/readme/CONTRIBUTORS.md b/project_task_stage_state_sync/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..ab0b57cd2e --- /dev/null +++ b/project_task_stage_state_sync/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Isabel Andreu \ diff --git a/project_task_stage_state_sync/readme/DESCRIPTION.md b/project_task_stage_state_sync/readme/DESCRIPTION.md new file mode 100644 index 0000000000..56cedc97d2 --- /dev/null +++ b/project_task_stage_state_sync/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +This module extends `project_task_stage_state` to complete the bidirectional +link between task states and stages. + +`project_task_stage_state` already handles the **Stage → State** direction: +moving a task to a stage updates its state to the one configured on that stage. + +This module adds the **State → Stage** direction: when a task's state changes +(manually or programmatically), the task is +automatically moved to the stage configured for that state. + +The sync is configured per stage via a checkbox, so it can be enabled selectively +without affecting every stage in every project. diff --git a/project_task_stage_state_sync/static/description/index.html b/project_task_stage_state_sync/static/description/index.html new file mode 100644 index 0000000000..ee90bbf69a --- /dev/null +++ b/project_task_stage_state_sync/static/description/index.html @@ -0,0 +1,451 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Project Task Stage/State - Bidirectional Sync

+ +

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

+

This module extends project_task_stage_state to complete the +bidirectional link between task states and stages.

+

project_task_stage_state already handles the Stage → State +direction: moving a task to a stage updates its state to the one +configured on that stage.

+

This module adds the State → Stage direction: when a task’s state +changes (manually or programmatically), the task is automatically moved +to the stage configured for that state.

+

The sync is configured per stage via a checkbox, so it can be enabled +selectively without affecting every stage in every project.

+

Table of contents

+ +
+

Configuration

+

On each stage (Project → Configuration → Stages):

+
    +
  1. Set the State field to the state that moving to this stage should +assign.
  2. +
  3. Enable Sync State → Stage to also activate the reverse: tasks +whose state is set to that value will automatically move to this +stage.
  4. +
+

Uniqueness rule: Only one stage per project may have Sync State → +Stage enabled for a given state value. Attempting to enable it on a +second stage that shares the same state and project will raise a +validation error naming the conflicting stage. Two different projects +are allowed to each have their own stage with sync enabled for the same +state — for example, both “Project A / Done” and “Project B / Done” can +independently have Sync State → Stage ticked for the Done state. The +uniqueness rule only applies within a single project.

+
+
+

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

+
+

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/project project on GitHub.

+

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

+
+
+
+
+ + diff --git a/project_task_stage_state_sync/tests/__init__.py b/project_task_stage_state_sync/tests/__init__.py new file mode 100644 index 0000000000..17775fc4fb --- /dev/null +++ b/project_task_stage_state_sync/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_project_task_stage_state_sync diff --git a/project_task_stage_state_sync/tests/test_project_task_stage_state_sync.py b/project_task_stage_state_sync/tests/test_project_task_stage_state_sync.py new file mode 100644 index 0000000000..ff835f1e00 --- /dev/null +++ b/project_task_stage_state_sync/tests/test_project_task_stage_state_sync.py @@ -0,0 +1,107 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError + +from odoo.addons.base.tests.common import BaseCommon + + +class TestProjectTaskStageStateSync(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.project = cls.env["project.project"].create({"name": "Test Project"}) + cls.stage_in_progress = cls.env["project.task.type"].create( + { + "name": "In Progress", + "sequence": 1, + "task_state": "01_in_progress", + "project_ids": [(4, cls.project.id)], + } + ) + cls.stage_done = cls.env["project.task.type"].create( + { + "name": "Done", + "sequence": 2, + "task_state": "1_done", + "sync_state_to_stage": True, + "project_ids": [(4, cls.project.id)], + } + ) + cls.stage_canceled = cls.env["project.task.type"].create( + { + "name": "Canceled", + "sequence": 3, + "task_state": "1_canceled", + "sync_state_to_stage": False, + "project_ids": [(4, cls.project.id)], + } + ) + cls.task = cls.env["project.task"].create( + { + "name": "Test Task", + "project_id": cls.project.id, + "stage_id": cls.stage_in_progress.id, + } + ) + + def test_state_change_moves_task_to_synced_stage(self): + self.task.write({"state": "1_done"}) + self.assertEqual(self.task.state, "1_done") + self.assertEqual(self.task.stage_id, self.stage_done) + + def test_no_sync_when_checkbox_disabled(self): + self.task.write({"state": "1_canceled"}) + self.assertNotEqual(self.task.stage_id, self.stage_canceled) + self.assertEqual(self.task.stage_id, self.stage_in_progress) + + def test_no_matching_stage_is_noop(self): + self.task.write({"state": "04_waiting_normal"}) + self.assertEqual(self.task.stage_id, self.stage_in_progress) + + def test_constraint_raises_on_duplicate_sync(self): + project = self.env["project.project"].create({"name": "Constraint Test"}) + self.env["project.task.type"].create( + { + "name": "Done A", + "task_state": "1_done", + "sync_state_to_stage": True, + "project_ids": [(4, project.id)], + } + ) + with self.assertRaises(ValidationError): + self.env["project.task.type"].create( + { + "name": "Done B", + "task_state": "1_done", + "sync_state_to_stage": True, + "project_ids": [(4, project.id)], + } + ) + + def test_constraint_allows_same_state_without_sync(self): + project = self.env["project.project"].create({"name": "Constraint Test"}) + self.env["project.task.type"].create( + { + "name": "Done A", + "task_state": "1_done", + "sync_state_to_stage": True, + "project_ids": [(4, project.id)], + } + ) + self.env["project.task.type"].create( + { + "name": "Done B", + "task_state": "1_done", + "sync_state_to_stage": False, + "project_ids": [(4, project.id)], + } + ) + + def test_constraint_allows_sync_without_task_state(self): + # sync_state_to_stage=True with no task_state set is never a conflict. + self.env["project.task.type"].create( + { + "name": "Unmapped Stage", + "sync_state_to_stage": True, + } + ) diff --git a/project_task_stage_state_sync/views/project_task_type_views.xml b/project_task_stage_state_sync/views/project_task_type_views.xml new file mode 100644 index 0000000000..781769dba9 --- /dev/null +++ b/project_task_stage_state_sync/views/project_task_type_views.xml @@ -0,0 +1,13 @@ + + + + task_type_edit_sync_state_to_stage + project.task.type + + + + + + + +