diff --git a/project_task_stage_change_restriction/README.rst b/project_task_stage_change_restriction/README.rst new file mode 100644 index 0000000000..08c8c57f8a --- /dev/null +++ b/project_task_stage_change_restriction/README.rst @@ -0,0 +1,119 @@ +===================================== +Project Task Stage Change Restriction +===================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:70428b68fa49a42f600173b5497b2a3cab2b78eca3ac7ce36d711878aa841f88 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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%2Fproject-lightgray.png?logo=github + :target: https://github.com/OCA/project/tree/18.0/project_task_stage_change_restriction + :alt: OCA/project +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/project-18-0/project-18-0-project_task_stage_change_restriction + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows specifying which users or groups can move a task to a +specific stage. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +There may be cases where you want to prevent certain users from moving a +task into specific stages. For example, developers can move tasks to the +"Review" stage, but only a project manager can move a task to the "Done" +or "Cancel" stages. + +Configuration +============= + +The **Task Stages** menu is only visible when Odoo is in developer mode. +Please turn on developer mode before proceeding. + +Go to "Project > Configuration > Task Stages" and select or create a new +task stage. Configure the following fields in the "Stage Change +Restriction" group: + +- **Assigned Only** + If enabled, only users assigned to the task can move it into this + stage. +- **Project Manager** + If enabled, only the manager of the project this task belongs to can + move it. +- **Group Members** + Select groups whose members can move tasks into this stage. + +Please be advised, that selected conditions are evaluated using the "OR" +principle. So, a user should satisfy any of the selected conditions. + +NB: restrictions are not applied if a stage is being changed by a +superuser. + +Usage +===== + +| Try to move a task to a restricted stage. If your user doesn't satisfy + the stage-allowance conditions, the following access error will be + raised: +| "Sorry, you are not allowed to move the task '' into the + stage ''." + +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 +------- + +* Cetmix + +Contributors +------------ + +Cetmix Ivan Sokolov Andrei Loukachov + +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_change_restriction/__init__.py b/project_task_stage_change_restriction/__init__.py new file mode 100644 index 0000000000..6f11353338 --- /dev/null +++ b/project_task_stage_change_restriction/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/project_task_stage_change_restriction/__manifest__.py b/project_task_stage_change_restriction/__manifest__.py new file mode 100644 index 0000000000..0910553834 --- /dev/null +++ b/project_task_stage_change_restriction/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Project Task Stage Change Restriction", + "summary": "Restrict project task stage", + "version": "18.0.1.0.0", + "category": "Project", + "author": "Odoo Community Association (OCA), Cetmix", + "license": "AGPL-3", + "website": "https://github.com/OCA/project", + "depends": ["project"], + "data": [ + "views/project_task_stage_views.xml", + ], + "demo": [ + "data/demo_project_task_stage.xml", + ], + "installable": True, + "application": False, +} diff --git a/project_task_stage_change_restriction/data/demo_project_task_stage.xml b/project_task_stage_change_restriction/data/demo_project_task_stage.xml new file mode 100644 index 0000000000..5ccbe0910a --- /dev/null +++ b/project_task_stage_change_restriction/data/demo_project_task_stage.xml @@ -0,0 +1,53 @@ + + + + + Free + 5 + + + Assigned Only + 10 + 1 + + + Project Manager Only + 15 + 1 + + + Project Users Only + 20 + + + + + Restricted Demo Project + + + + + + Dev Task + + + + + + PM Task + + + + + diff --git a/project_task_stage_change_restriction/i18n/.empty b/project_task_stage_change_restriction/i18n/.empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/project_task_stage_change_restriction/i18n/it.po b/project_task_stage_change_restriction/i18n/it.po new file mode 100644 index 0000000000..35f219d345 --- /dev/null +++ b/project_task_stage_change_restriction/i18n/it.po @@ -0,0 +1,129 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * project_task_stage_change_restriction +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-07-28 09:25+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.4\n" + +#. module: project_task_stage_change_restriction +#: model:ir.model.fields,field_description:project_task_stage_change_restriction.field_project_task_type__allow_assigned_only +#: model:project.task.type,name:project_task_stage_change_restriction.demo_stage_assigned_only +msgid "Assigned Only" +msgstr "Solo assegnati" + +#. module: project_task_stage_change_restriction +#: model:project.task.type,legend_blocked:project_task_stage_change_restriction.demo_stage_assigned_only +#: model:project.task.type,legend_blocked:project_task_stage_change_restriction.demo_stage_free +#: model:project.task.type,legend_blocked:project_task_stage_change_restriction.demo_stage_group_only +#: model:project.task.type,legend_blocked:project_task_stage_change_restriction.demo_stage_pm_only +msgid "Blocked" +msgstr "Bloccato" + +#. module: project_task_stage_change_restriction +#: model:project.task.type,name:project_task_stage_change_restriction.demo_stage_free +msgid "Free" +msgstr "Libero" + +#. module: project_task_stage_change_restriction +#: model:ir.model.fields,field_description:project_task_stage_change_restriction.field_project_task_type__allow_group_ids +msgid "Group Members" +msgstr "Membri del gruppo" + +#. module: project_task_stage_change_restriction +#: model_terms:ir.ui.view,arch_db:project_task_stage_change_restriction.view_project_task_type_form_restriction +msgid "If enabled, only the project manager can move tasks into this stage." +msgstr "" +"Se abilitata, solo il responsabile del progetto può spostare i lavori tra le " +"fasi." + +#. module: project_task_stage_change_restriction +#: model_terms:ir.ui.view,arch_db:project_task_stage_change_restriction.view_project_task_type_form_restriction +msgid "" +"If enabled, only users assigned to the task can move it into this stage." +msgstr "" +"Se abilitata, solo gli utenti assegnati al lavoro possono spostarlo in " +"questa fase." + +#. module: project_task_stage_change_restriction +#: model:project.task.type,legend_normal:project_task_stage_change_restriction.demo_stage_assigned_only +#: model:project.task.type,legend_normal:project_task_stage_change_restriction.demo_stage_free +#: model:project.task.type,legend_normal:project_task_stage_change_restriction.demo_stage_group_only +#: model:project.task.type,legend_normal:project_task_stage_change_restriction.demo_stage_pm_only +msgid "In Progress" +msgstr "In lavoro" + +#. module: project_task_stage_change_restriction +#: model_terms:ir.ui.view,arch_db:project_task_stage_change_restriction.view_project_task_type_form_restriction +msgid "Members of selected groups can move tasks into this stage." +msgstr "" +"I membri dei gruppi selezionati possono spostare i lavori in questa fase." + +#. module: project_task_stage_change_restriction +#: model:ir.model.fields,field_description:project_task_stage_change_restriction.field_project_task_type__allow_project_manager +msgid "Project Manager" +msgstr "Responsabile progetto" + +#. module: project_task_stage_change_restriction +#: model:project.task.type,name:project_task_stage_change_restriction.demo_stage_pm_only +msgid "Project Manager Only" +msgstr "Solo responsabile progetto" + +#. module: project_task_stage_change_restriction +#: model:project.task.type,name:project_task_stage_change_restriction.demo_stage_group_only +msgid "Project Users Only" +msgstr "Solo utenti progetto" + +#. module: project_task_stage_change_restriction +#: model:project.task.type,legend_done:project_task_stage_change_restriction.demo_stage_assigned_only +#: model:project.task.type,legend_done:project_task_stage_change_restriction.demo_stage_free +#: model:project.task.type,legend_done:project_task_stage_change_restriction.demo_stage_group_only +#: model:project.task.type,legend_done:project_task_stage_change_restriction.demo_stage_pm_only +msgid "Ready" +msgstr "Pronto" + +#. module: project_task_stage_change_restriction +#: model:project.project,name:project_task_stage_change_restriction.demo_project_restricted +msgid "Restricted Demo Project" +msgstr "Progetto demo limitato" + +#. module: project_task_stage_change_restriction +#. odoo-python +#: code:addons/project_task_stage_change_restriction/models/project_task.py:0 +#, python-format +msgid "" +"Sorry, you are not allowed to move the task '%(task)s' into the stage " +"'%(stage)s'." +msgstr "" +"Non si è abilitati allo spostamento del lavoro '%(task)s' alla fase " +"'%(stage)s'." + +#. module: project_task_stage_change_restriction +#: model_terms:ir.ui.view,arch_db:project_task_stage_change_restriction.view_project_task_type_form_restriction +msgid "Stage Change Restriction" +msgstr "Limitazione modifica fase" + +#. module: project_task_stage_change_restriction +#: model:ir.model,name:project_task_stage_change_restriction.model_project_task +msgid "Task" +msgstr "Lavoro" + +#. module: project_task_stage_change_restriction +#: model:ir.model,name:project_task_stage_change_restriction.model_project_task_type +msgid "Task Stage" +msgstr "Fase lavoro" + +#. module: project_task_stage_change_restriction +#: model:project.project,label_tasks:project_task_stage_change_restriction.demo_project_restricted +msgid "Tasks" +msgstr "Lavori" diff --git a/project_task_stage_change_restriction/i18n/project_task_stage_change_restriction.pot b/project_task_stage_change_restriction/i18n/project_task_stage_change_restriction.pot new file mode 100644 index 0000000000..331197acb6 --- /dev/null +++ b/project_task_stage_change_restriction/i18n/project_task_stage_change_restriction.pot @@ -0,0 +1,119 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * project_task_stage_change_restriction +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \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: project_task_stage_change_restriction +#: model:ir.model.fields,field_description:project_task_stage_change_restriction.field_project_task_type__allow_assigned_only +#: model:project.task.type,name:project_task_stage_change_restriction.demo_stage_assigned_only +msgid "Assigned Only" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:project.task.type,legend_blocked:project_task_stage_change_restriction.demo_stage_assigned_only +#: model:project.task.type,legend_blocked:project_task_stage_change_restriction.demo_stage_free +#: model:project.task.type,legend_blocked:project_task_stage_change_restriction.demo_stage_group_only +#: model:project.task.type,legend_blocked:project_task_stage_change_restriction.demo_stage_pm_only +msgid "Blocked" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:project.task.type,name:project_task_stage_change_restriction.demo_stage_free +msgid "Free" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:ir.model.fields,field_description:project_task_stage_change_restriction.field_project_task_type__allow_group_ids +msgid "Group Members" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model_terms:ir.ui.view,arch_db:project_task_stage_change_restriction.view_project_task_type_form_restriction +msgid "If enabled, only the project manager can move tasks into this stage." +msgstr "" + +#. module: project_task_stage_change_restriction +#: model_terms:ir.ui.view,arch_db:project_task_stage_change_restriction.view_project_task_type_form_restriction +msgid "" +"If enabled, only users assigned to the task can move it into this stage." +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:project.task.type,legend_normal:project_task_stage_change_restriction.demo_stage_assigned_only +#: model:project.task.type,legend_normal:project_task_stage_change_restriction.demo_stage_free +#: model:project.task.type,legend_normal:project_task_stage_change_restriction.demo_stage_group_only +#: model:project.task.type,legend_normal:project_task_stage_change_restriction.demo_stage_pm_only +msgid "In Progress" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model_terms:ir.ui.view,arch_db:project_task_stage_change_restriction.view_project_task_type_form_restriction +msgid "Members of selected groups can move tasks into this stage." +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:ir.model.fields,field_description:project_task_stage_change_restriction.field_project_task_type__allow_project_manager +msgid "Project Manager" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:project.task.type,name:project_task_stage_change_restriction.demo_stage_pm_only +msgid "Project Manager Only" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:project.task.type,name:project_task_stage_change_restriction.demo_stage_group_only +msgid "Project Users Only" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:project.task.type,legend_done:project_task_stage_change_restriction.demo_stage_assigned_only +#: model:project.task.type,legend_done:project_task_stage_change_restriction.demo_stage_free +#: model:project.task.type,legend_done:project_task_stage_change_restriction.demo_stage_group_only +#: model:project.task.type,legend_done:project_task_stage_change_restriction.demo_stage_pm_only +msgid "Ready" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:project.project,name:project_task_stage_change_restriction.demo_project_restricted +msgid "Restricted Demo Project" +msgstr "" + +#. module: project_task_stage_change_restriction +#. odoo-python +#: code:addons/project_task_stage_change_restriction/models/project_task.py:0 +#, python-format +msgid "" +"Sorry, you are not allowed to move the task '%(task)s' into the stage " +"'%(stage)s'." +msgstr "" + +#. module: project_task_stage_change_restriction +#: model_terms:ir.ui.view,arch_db:project_task_stage_change_restriction.view_project_task_type_form_restriction +msgid "Stage Change Restriction" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:ir.model,name:project_task_stage_change_restriction.model_project_task +msgid "Task" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:ir.model,name:project_task_stage_change_restriction.model_project_task_type +msgid "Task Stage" +msgstr "" + +#. module: project_task_stage_change_restriction +#: model:project.project,label_tasks:project_task_stage_change_restriction.demo_project_restricted +msgid "Tasks" +msgstr "" diff --git a/project_task_stage_change_restriction/models/__init__.py b/project_task_stage_change_restriction/models/__init__.py new file mode 100644 index 0000000000..f4ceadc0b6 --- /dev/null +++ b/project_task_stage_change_restriction/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import project_task_type +from . import project_task diff --git a/project_task_stage_change_restriction/models/project_task.py b/project_task_stage_change_restriction/models/project_task.py new file mode 100644 index 0000000000..2e29a8bd9e --- /dev/null +++ b/project_task_stage_change_restriction/models/project_task.py @@ -0,0 +1,76 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, models +from odoo.exceptions import UserError + + +class ProjectTask(models.Model): + _inherit = "project.task" + + def _is_move_allowed(self, task, new_stage, user): + """Return True if **user** may move **task** into **new_stage** (OR-logic). + + OR-logic sequence: + 1. No restrictions on stage, or superuser → always True + 2. allow_assigned_only and user in task.user_ids + 3. allow_project_manager and user is project.manager_id + 4. allow_group_ids and user in allowed groups + """ + # unrestricted stage / super-user + if not new_stage or not new_stage._has_restrictions() or user._is_superuser(): + return True + + # Assigned Only + if new_stage.allow_assigned_only and user in task.user_ids: + return True + + # Project Manager: use the core alias `manager_id` + pm = getattr(task.project_id, "manager_id", task.project_id.user_id) + if new_stage.allow_project_manager and pm and user == pm: + return True + + # Group Members + if new_stage._user_in_allowed_group(user): + return True + + return False + + def _check_stage_restriction(self, vals): + """Raise UserError if current env-user is NOT allowed.""" + stage_id = vals.get("stage_id") + if not stage_id: + return True + + new_stage = self.env["project.task.type"].browse(stage_id) + + for task in self: + if not self._is_move_allowed(task, new_stage, self.env.user): + raise UserError( + _( + "Sorry, you are not allowed to move the task " + "'%(task)s' into the stage '%(stage)s'." + ) + % {"task": task.display_name, "stage": new_stage.display_name} + ) + return True + + def write(self, vals): + """Override write() to enforce stage‐change restrictions.""" + # validate the user is allowed to move into a new stage + self._check_stage_restriction(vals) + return super().write(vals) + + @api.model_create_multi + def create(self, vals_list): + """Override create() to enforce stage restrictions on new tasks. + + :param vals_list: list of dicts of values for each record + :return: the newly created recordset + """ + recs = super().create(vals_list) + # validate once stage is definitely set + for rec in recs: + if rec.stage_id: + rec._check_stage_restriction({"stage_id": rec.stage_id.id}) + return recs diff --git a/project_task_stage_change_restriction/models/project_task_type.py b/project_task_stage_change_restriction/models/project_task_type.py new file mode 100644 index 0000000000..5520b18ac2 --- /dev/null +++ b/project_task_stage_change_restriction/models/project_task_type.py @@ -0,0 +1,37 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ProjectTaskType(models.Model): + _inherit = "project.task.type" + + allow_assigned_only = fields.Boolean(string="Assigned Only") + allow_project_manager = fields.Boolean(string="Project Manager") + allow_group_ids = fields.Many2many( + "res.groups", + "project_task_stage_allowed_group_rel", + "stage_id", + "group_id", + string="Group Members", + ) + + def _has_restrictions(self): + """Return *True* if **any** restriction flag / group is set.""" + self.ensure_one() + return bool( + self.allow_assigned_only + or self.allow_project_manager + or self.allow_group_ids + ) + + def _user_in_allowed_group(self, user): + """ + Return *True* when *user* belongs to ≥ 1 selected groups. + Empty group list → rule **not** applied. + """ + self.ensure_one() + if not self.allow_group_ids: + return False + return bool(self.allow_group_ids & user.groups_id) diff --git a/project_task_stage_change_restriction/pyproject.toml b/project_task_stage_change_restriction/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/project_task_stage_change_restriction/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/project_task_stage_change_restriction/readme/CONFIGURE.md b/project_task_stage_change_restriction/readme/CONFIGURE.md new file mode 100644 index 0000000000..95301f9007 --- /dev/null +++ b/project_task_stage_change_restriction/readme/CONFIGURE.md @@ -0,0 +1,16 @@ +The **Task Stages** menu is only visible when Odoo is in developer mode. +Please turn on developer mode before proceeding. + +Go to "Project > Configuration > Task Stages" and select or create a new task stage. +Configure the following fields in the "Stage Change Restriction" group: + +- **Assigned Only** + If enabled, only users assigned to the task can move it into this stage. +- **Project Manager** + If enabled, only the manager of the project this task belongs to can move it. +- **Group Members** + Select groups whose members can move tasks into this stage. + +Please be advised, that selected conditions are evaluated using the "OR" principle. So, a user should satisfy any of the selected conditions. + +NB: restrictions are not applied if a stage is being changed by a superuser. diff --git a/project_task_stage_change_restriction/readme/CONTEXT.md b/project_task_stage_change_restriction/readme/CONTEXT.md new file mode 100644 index 0000000000..1bdfa51183 --- /dev/null +++ b/project_task_stage_change_restriction/readme/CONTEXT.md @@ -0,0 +1 @@ +There may be cases where you want to prevent certain users from moving a task into specific stages. For example, developers can move tasks to the "Review" stage, but only a project manager can move a task to the "Done" or "Cancel" stages. diff --git a/project_task_stage_change_restriction/readme/CONTRIBUTORS.md b/project_task_stage_change_restriction/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..4c6060863c --- /dev/null +++ b/project_task_stage_change_restriction/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +Cetmix + Ivan Sokolov + Andrei Loukachov + \ No newline at end of file diff --git a/project_task_stage_change_restriction/readme/DESCRIPTION.md b/project_task_stage_change_restriction/readme/DESCRIPTION.md new file mode 100644 index 0000000000..95d4159b36 --- /dev/null +++ b/project_task_stage_change_restriction/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module allows specifying which users or groups can move a task to a specific stage. diff --git a/project_task_stage_change_restriction/readme/USAGE.md b/project_task_stage_change_restriction/readme/USAGE.md new file mode 100644 index 0000000000..f3346f1600 --- /dev/null +++ b/project_task_stage_change_restriction/readme/USAGE.md @@ -0,0 +1,2 @@ +Try to move a task to a restricted stage. If your user doesn't satisfy the stage-allowance conditions, the following access error will be raised: +"Sorry, you are not allowed to move the task '' into the stage ''." diff --git a/project_task_stage_change_restriction/readme/newsfragments/.gitkeep b/project_task_stage_change_restriction/readme/newsfragments/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/project_task_stage_change_restriction/static/description/icon.png b/project_task_stage_change_restriction/static/description/icon.png new file mode 100644 index 0000000000..1dcc49c24f Binary files /dev/null and b/project_task_stage_change_restriction/static/description/icon.png differ diff --git a/project_task_stage_change_restriction/static/description/index.html b/project_task_stage_change_restriction/static/description/index.html new file mode 100644 index 0000000000..f92d18ab8d --- /dev/null +++ b/project_task_stage_change_restriction/static/description/index.html @@ -0,0 +1,124 @@ +
+
+
+

Module name

+

This module was written to extend the functionality of ... to support ... and allow you to ...

+
+
+
+ +
+
+
+

Installation

+
+
+

To install this module, you need to: +

    +
  • ...
  • +
+

+
+
+
+ + + +
+
+
+
+ +
+
+
+

Configuration

+
+
+

To configure this module, you need to: +

    +
  • ...
  • +
+

+
+
+
+ + + +
+
+
+
+ +
+
+
+

Usage

+
+
+

To use this module, you need to: +

    +
  • ...
  • +
+

+

For further information, please visit: +

+

+
+
+
+ + + +
+
+
+
+ +
+
+
+

Known issues / Roadmap

+
+
+

+

    +
  • ...
  • +
+

+
+
+
+ + + +
+
+
+
+ +
+
+
+

Credits

+
+
+

Contributors

+ +
+
+

Maintainer

+

+ This module is maintained by the OCA.
+ 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.
+ To contribute to this module, please visit http://odoo-community.org.
+ +

+
+
+
diff --git a/project_task_stage_change_restriction/tests/__init__.py b/project_task_stage_change_restriction/tests/__init__.py new file mode 100644 index 0000000000..b49560d44d --- /dev/null +++ b/project_task_stage_change_restriction/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_stage_change_restriction diff --git a/project_task_stage_change_restriction/tests/test_stage_change_restriction.py b/project_task_stage_change_restriction/tests/test_stage_change_restriction.py new file mode 100644 index 0000000000..079a9fef67 --- /dev/null +++ b/project_task_stage_change_restriction/tests/test_stage_change_restriction.py @@ -0,0 +1,289 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import SUPERUSER_ID +from odoo.exceptions import UserError +from odoo.tests import common as tests_common + +from odoo.addons.base.tests.common import BaseCommon + + +@tests_common.tagged("-at_install", "post_install") +class TestStageChangeRestriction(BaseCommon): + """Validate stage-change & creation access rules for project tasks.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + env = cls.env + + gp_user = env.ref("project.group_project_user") + gp_manager = env.ref("project.group_project_manager") + try: + cls.grp_sales_admin = env.ref("sale.group_sale_manager") + except ValueError: + cls.grp_sales_admin = env["res.groups"].create( + {"name": "Sales / Manager (Test)"} + ) + + def _mk_user(login, groups): + return ( + env["res.users"] + .with_user(SUPERUSER_ID) + .create( + { + "name": login.replace("_", " ").title(), + "login": login, + "groups_id": [(4, g.id) for g in groups], + } + ) + ) + + cls.user_dev = _mk_user("dev_user", [gp_user]) + cls.user_pm = _mk_user("pm_user", [gp_user, gp_manager]) + cls.user_sales = _mk_user("sales_user", [gp_user, cls.grp_sales_admin]) + + cls.project = ( + env["project.project"] + .with_user(SUPERUSER_ID) + .create( + { + "name": "Demo Project", + "user_id": cls.user_pm.id, + } + ) + ) + + Stage = env["project.task.type"].with_user(SUPERUSER_ID).create + cls.stage_free = Stage({"name": "Free", "project_ids": [(4, cls.project.id)]}) + cls.stage_assigned = Stage( + { + "name": "Assigned Only", + "allow_assigned_only": True, + "project_ids": [(4, cls.project.id)], + } + ) + cls.stage_pm = Stage( + { + "name": "Project Manager Only", + "allow_project_manager": True, + "project_ids": [(4, cls.project.id)], + } + ) + cls.stage_group = Stage( + { + "name": "Sales Only", + "allow_group_ids": [(6, 0, [cls.grp_sales_admin.id])], + "project_ids": [(4, cls.project.id)], + } + ) + cls.stage_assigned_or_pm = Stage( + { + "name": "Assigned OR PM", + "allow_assigned_only": True, + "allow_project_manager": True, + "project_ids": [(4, cls.project.id)], + } + ) + cls.stage_assigned_or_group = Stage( + { + "name": "Assigned OR Sales", + "allow_assigned_only": True, + "allow_group_ids": [(6, 0, [cls.grp_sales_admin.id])], + "project_ids": [(4, cls.project.id)], + } + ) + cls.stage_pm_or_group = Stage( + { + "name": "PM OR Sales", + "allow_project_manager": True, + "allow_group_ids": [(6, 0, [cls.grp_sales_admin.id])], + "project_ids": [(4, cls.project.id)], + } + ) + + cls.task_tpl = ( + env["project.task"] + .with_user(SUPERUSER_ID) + .create( + { + "name": "Template Task", + "project_id": cls.project.id, + "stage_id": cls.stage_free.id, + } + ) + ) + + def _clone_task(self, acting_user, *, assignees=None, stage=None): + """Copy template and return it **as** ``acting_user``. + + :param acting_user: user performing follow‑up actions + :param assignees: list/tuple of users assigned to the task + :param stage: optional initial stage + """ + vals = { + "user_ids": [(6, 0, [u.id for u in (assignees or [])])], + "project_id": self.project.id, + } + if stage: + vals["stage_id"] = stage.id + return self.task_tpl.copy(vals).with_user(acting_user) + + def _ok_move(self, task, user, stage): + """ + Assert that `user` is allowed to move `task` to `stage`. + + :raises AssertionError: if the stage was not applied + """ + task.with_user(user).write({"stage_id": stage.id}) + self.assertEqual(task.stage_id, stage) + + def _fail_move(self, task, user, stage): + """ + Assert that `user` is NOT allowed to move `task` to `stage`. + + :raises UserError: if the write does not fail as expected + """ + with self.assertRaises(UserError): + task.with_user(user).write({"stage_id": stage.id}) + + def _ok_create(self, creator, stage, *, assignees=None): + """ + Assert that `creator` may create a task in `stage` (with optional assignees). + + :returns: the newly created task record + :raises AssertionError: if the task is not in the expected stage + """ + rec = ( + self.env["project.task"] + .with_user(creator) + .create( + { + "name": "Task", + "project_id": self.project.id, + "stage_id": stage.id, + "user_ids": [(6, 0, [u.id for u in (assignees or [])])], + } + ) + ) + self.assertEqual(rec.stage_id, stage) + + def _fail_create(self, creator, stage, *, assignees=None): + """ + Assert that `creator` may NOT create a task in `stage`. + + :raises UserError: if the create does not fail as expected + """ + with self.assertRaises(UserError): + self.env["project.task"].with_user(creator).create( + { + "name": "Bad", + "project_id": self.project.id, + "stage_id": stage.id, + "user_ids": [(6, 0, [u.id for u in (assignees or [])])], + } + ) + + def test_move_free(self): + task = self._clone_task(self.user_dev) + for u in (self.user_dev, self.user_pm, self.user_sales): + self._ok_move(task, u, self.stage_free) + + def test_move_assigned_only(self): + task = self._clone_task(self.user_dev, assignees=[self.user_dev]) + self._ok_move(task, self.user_dev, self.stage_assigned) + self._fail_move(task, self.user_pm, self.stage_assigned) + self._fail_move(task, self.user_sales, self.stage_assigned) + + def test_move_pm_only(self): + task = self._clone_task(self.user_dev) + self._ok_move(task, self.user_pm, self.stage_pm) + self._fail_move(task, self.user_dev, self.stage_pm) + self._fail_move(task, self.user_sales, self.stage_pm) + + def test_move_group_only(self): + task = self._clone_task(self.user_dev) + self._ok_move(task, self.user_sales, self.stage_group) + self._fail_move(task, self.user_dev, self.stage_group) + self._fail_move(task, self.user_pm, self.stage_group) + + def test_move_assigned_or_pm(self): + task = self._clone_task(self.user_dev, assignees=[self.user_dev]) + self._ok_move(task, self.user_dev, self.stage_assigned_or_pm) + self._ok_move(task, self.user_pm, self.stage_assigned_or_pm) + self._fail_move(task, self.user_sales, self.stage_assigned_or_pm) + + def test_move_assigned_or_group(self): + task = self._clone_task(self.user_dev, assignees=[self.user_dev]) + self._ok_move(task, self.user_dev, self.stage_assigned_or_group) + self._ok_move(task, self.user_sales, self.stage_assigned_or_group) + self._fail_move(task, self.user_pm, self.stage_assigned_or_group) + + def test_move_pm_or_group(self): + task = self._clone_task(self.user_dev) + self._ok_move(task, self.user_pm, self.stage_pm_or_group) + self._ok_move(task, self.user_sales, self.stage_pm_or_group) + self._fail_move(task, self.user_dev, self.stage_pm_or_group) + + def test_superuser_bypass_move(self): + task = self._clone_task(self.user_dev) + task.with_user(SUPERUSER_ID).write({"stage_id": self.stage_pm.id}) + self.assertEqual(task.stage_id, self.stage_pm) + + def test_create_free(self): + for u in (self.user_dev, self.user_pm, self.user_sales): + self._ok_create(u, self.stage_free) + + def test_create_assigned_only(self): + self._ok_create(self.user_dev, self.stage_assigned, assignees=[self.user_dev]) + self._fail_create(self.user_pm, self.stage_assigned, assignees=[self.user_dev]) + self._fail_create( + self.user_sales, self.stage_assigned, assignees=[self.user_dev] + ) + + def test_create_pm_only(self): + self._ok_create(self.user_pm, self.stage_pm) + self._fail_create(self.user_dev, self.stage_pm) + self._fail_create(self.user_sales, self.stage_pm) + + def test_create_group_only(self): + self._ok_create(self.user_sales, self.stage_group) + self._fail_create(self.user_dev, self.stage_group) + self._fail_create(self.user_pm, self.stage_group) + + def test_create_assigned_or_pm(self): + self._ok_create( + self.user_dev, self.stage_assigned_or_pm, assignees=[self.user_dev] + ) + self._ok_create(self.user_pm, self.stage_assigned_or_pm) + self._fail_create( + self.user_sales, self.stage_assigned_or_pm, assignees=[self.user_dev] + ) + + def test_create_assigned_or_group(self): + self._ok_create( + self.user_dev, self.stage_assigned_or_group, assignees=[self.user_dev] + ) + self._ok_create(self.user_sales, self.stage_assigned_or_group) + self._fail_create( + self.user_pm, self.stage_assigned_or_group, assignees=[self.user_dev] + ) + + def test_create_pm_or_group(self): + self._ok_create(self.user_pm, self.stage_pm_or_group) + self._ok_create(self.user_sales, self.stage_pm_or_group) + self._fail_create(self.user_dev, self.stage_pm_or_group) + + def test_superuser_bypass_create(self): + rec = ( + self.env["project.task"] + .with_user(SUPERUSER_ID) + .create( + { + "name": "SU task", + "project_id": self.project.id, + "stage_id": self.stage_assigned.id, + } + ) + ) + self.assertEqual(rec.stage_id, self.stage_assigned) diff --git a/project_task_stage_change_restriction/views/project_task_stage_views.xml b/project_task_stage_change_restriction/views/project_task_stage_views.xml new file mode 100644 index 0000000000..6ffb3de633 --- /dev/null +++ b/project_task_stage_change_restriction/views/project_task_stage_views.xml @@ -0,0 +1,27 @@ + + + + project.task.type.form.restriction + project.task.type + + + + + + + + + + + +