diff --git a/project_template/README.rst b/project_template/README.rst index 8ffd74471b..e48890d95f 100644 --- a/project_template/README.rst +++ b/project_template/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 - ================= Project Templates ================= @@ -17,7 +13,7 @@ Project Templates .. |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%2Fproject-lightgray.png?logo=github @@ -32,7 +28,8 @@ Project Templates |badge1| |badge2| |badge3| |badge4| |badge5| -This module adds templates for projects. +This module adds templates for projects and reusable project task +structures. **Table of contents** @@ -52,6 +49,12 @@ To use this module, you need to: 4. Use the "Create Project from Template" link in the drop down menu on each template while in the Kanban view or the button on the project template form. +5. Select a project template on a new project to copy its settings and + reusable tasks. +6. Mark tasks that should be copied or reused with the "Is Template" + field. +7. Use the "Task Template" field on a task to create subtasks from a + template task. Bug Tracker =========== @@ -70,6 +73,7 @@ Authors ------- * Patrick Wilson +* ACSONE SA/NV Contributors ------------ @@ -79,6 +83,7 @@ Contributors - Mantas Šniukas - Atte Isopuro - Stefan Rijnhart +- Souheil Bejaoui souheil.bejaoui@acsone.eu Maintainers ----------- @@ -96,10 +101,13 @@ promote its widespread use. .. |maintainer-patrickrwilson| image:: https://github.com/patrickrwilson.png?size=40px :target: https://github.com/patrickrwilson :alt: patrickrwilson +.. |maintainer-sbejaoui| image:: https://github.com/sbejaoui.png?size=40px + :target: https://github.com/sbejaoui + :alt: sbejaoui -Current `maintainer `__: +Current `maintainers `__: -|maintainer-patrickrwilson| +|maintainer-patrickrwilson| |maintainer-sbejaoui| This module is part of the `OCA/project `_ project on GitHub. diff --git a/project_template/__manifest__.py b/project_template/__manifest__.py index c91df19b2f..4c892787a4 100644 --- a/project_template/__manifest__.py +++ b/project_template/__manifest__.py @@ -1,17 +1,21 @@ # Copyright 2019 Patrick Wilson +# Copyright 2026 ACSONE SA/NV # License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). { "name": "Project Templates", "summary": """Project Templates""", - "author": "Patrick Wilson, Odoo Community Association (OCA)", + "author": "Patrick Wilson, ACSONE SA/NV, Odoo Community Association (OCA)", "website": "https://github.com/OCA/project", "category": "Project Management", "version": "18.0.1.0.1", "license": "AGPL-3", - "depends": ["project"], - "data": ["views/project.xml"], + "depends": [ + # Odoo Community + "project", + ], + "data": ["views/project.xml", "views/project_task.xml"], "application": False, "development_status": "Beta", - "maintainers": ["patrickrwilson"], + "maintainers": ["patrickrwilson", "sbejaoui"], } diff --git a/project_template/models/project.py b/project_template/models/project.py index 56d0ffe8ec..47b4b4e0cb 100644 --- a/project_template/models/project.py +++ b/project_template/models/project.py @@ -1,26 +1,45 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import api, fields, models +from odoo import Command, api, fields, models -# See _map_tasks_default_valeus +# see _map_tasks_default_values TASK_DEFAULT_COPY_CONTEXT_KEY = f"{__name__}.task_default_copy_context_key" +TEMPLATE_TASKS_ONLY_CONTEXT_KEY = f"{__name__}.template_tasks_only_context_key" class Project(models.Model): _inherit = "project.project" is_template = fields.Boolean(copy=False) + project_template_id = fields.Many2one( + "project.project", + copy=False, + domain="[('is_template', '=', True)]", + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + self._add_project_template_defaults(vals) + projects = super().create(vals_list) + for project, vals in zip(projects, vals_list, strict=True): + project._apply_project_template_from_vals(vals) + return projects - # CREATE A PROJECT FROM A TEMPLATE AND OPEN THE NEWLY CREATED PROJECT def create_project_from_template(self): + """create a project from this template and open the generated project""" if " (TEMPLATE)" in self.name: new_name = self.name.replace(" (TEMPLATE)", " (COPY)") else: new_name = self.name + " (COPY)" - new_project = self.with_context(**{TASK_DEFAULT_COPY_CONTEXT_KEY: True}).copy( - default={"name": new_name, "active": True, "alias_name": False} + new_project = self.with_context(**self._get_template_copy_context()).copy( + default={ + "name": new_name, + "active": True, + "alias_name": False, + "is_template": False, + } ) - # OPEN THE NEWLY CREATED PROJECT FORM return { "view_mode": "form", "res_model": "project.project", @@ -29,11 +48,9 @@ def create_project_from_template(self): "type": "ir.actions.act_window", } - # ADD "(TEMPLATE)" TO THE NAME WHEN PROJECT IS MARKED AS A TEMPLATE @api.onchange("is_template") def on_change_is_template(self): - # Add "(TEMPLATE)" to the Name if is_template == true - # if self.name is needed for creating projects via configuration menu + """align project metadata with the template flag in the form""" if self.name: if self.is_template: if "(TEMPLATE)" not in self.name: @@ -48,3 +65,107 @@ def on_change_is_template(self): else: if " (TEMPLATE)" in self.name: self.name = self.name.replace(" (TEMPLATE)", "") + + def write(self, vals): + """keep task template flags aligned when a project changes type""" + projects_to_enable = self._get_projects_to_toggle_task_template(vals, True) + projects_to_disable = self._get_projects_to_toggle_task_template(vals, False) + res = super().write(vals) + projects_to_enable._set_tasks_template(True) + projects_to_disable._set_tasks_template(False) + return res + + def map_tasks(self, new_project_id): + """delegate regular project copies to core and filter template copies""" + if not self.env.context.get(TEMPLATE_TASKS_ONLY_CONTEXT_KEY): + return super().map_tasks(new_project_id) + return self._copy_template_tasks(new_project_id) + + def _get_template_copy_context(self): + """return context flags used to copy only reusable template tasks""" + return { + TASK_DEFAULT_COPY_CONTEXT_KEY: True, + TEMPLATE_TASKS_ONLY_CONTEXT_KEY: True, + } + + @api.onchange("project_template_id") + def _onchange_project_template_id(self): + if self.project_template_id: + self.update(self.project_template_id._prepare_project_template_vals()) + + def _add_project_template_defaults(self, vals): + template = self.browse(vals.get("project_template_id")) + if not template: + return + for field_name, value in template._prepare_project_template_vals().items(): + vals.setdefault(field_name, value) + + def _apply_project_template_from_vals(self, vals): + template = self.project_template_id + if template and vals.get("project_template_id") and "tasks" not in vals: + template.with_context(**template._get_template_copy_context()).map_tasks( + self.id + ) + + def _prepare_project_template_vals(self): + """prepare project values copied when selecting this project as template""" + self.ensure_one() + return { + "label_tasks": self.label_tasks, + "privacy_visibility": self.privacy_visibility, + "allow_milestones": self.allow_milestones, + "allow_task_dependencies": self.allow_task_dependencies, + "type_ids": [Command.set(self.type_ids.ids)], + } + + def _get_projects_to_toggle_task_template(self, vals, is_template): + if vals.get("is_template") != is_template: + return self.browse() + return self.filtered(lambda project: project.is_template != is_template) + + def _set_tasks_template(self, is_template): + if self: + self.with_context(active_test=False).task_ids.write( + {"is_template": is_template} + ) + + def _get_template_tasks_to_copy(self): + """return root tasks selected as reusable parts of the template project""" + self.ensure_one() + return ( + self.env["project.task"] + .with_context(active_test=False) + .search( + [ + ("project_id", "=", self.id), + ("parent_id", "=", False), + ("is_template", "=", True), + ] + ) + ) + + def _get_template_task_copy_defaults(self, project): + return { + **self._map_tasks_default_values(project), + "is_template": False, + "task_template_id": False, + } + + def _copy_template_tasks(self, new_project_id): + """copy only tasks explicitly marked as reusable template tasks""" + project = self.browse(new_project_id) + tasks = self._get_template_tasks_to_copy() + if self.allow_task_dependencies and "task_mapping" not in self.env.context: + self = self.with_context(task_mapping={}) + new_tasks = tasks.with_context(copy_project=True).copy( + self._get_template_task_copy_defaults(project) + ) + subtasks = new_tasks._get_all_subtasks() + subtasks_not_displayed = subtasks.filtered( + lambda task: not task.display_in_project + ) + subtasks.filtered(lambda task: task.project_id == self).write( + {"project_id": project.id} + ) + subtasks_not_displayed.write({"display_in_project": False}) + return True diff --git a/project_template/models/project_task.py b/project_template/models/project_task.py index 000e0276e9..b0511aff00 100644 --- a/project_template/models/project_task.py +++ b/project_template/models/project_task.py @@ -1,22 +1,168 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import models +from odoo import Command, api, fields, models -from .project import TASK_DEFAULT_COPY_CONTEXT_KEY +from .project import TASK_DEFAULT_COPY_CONTEXT_KEY, TEMPLATE_TASKS_ONLY_CONTEXT_KEY class ProjectTask(models.Model): _inherit = "project.task" + project_is_template = fields.Boolean( + related="project_id.is_template", string="Project Is Template" + ) + is_template = fields.Boolean(copy=False) + task_template_id = fields.Many2one( + "project.task", + copy=False, + domain="[('is_template', '=', True), " + "('project_id.is_template', '=', True), ('id', '!=', id)]", + ) + + @api.model_create_multi + def create(self, vals_list): + """set template defaults before create and apply template fields after""" + for vals in vals_list: + self._add_is_template_default(vals) + tasks = super().create(vals_list) + for task, vals in zip(tasks, vals_list, strict=True): + task._apply_template_fields_from_vals(vals) + return tasks + + def write(self, vals): + """apply template field behavior after the regular write""" + res = super().write(vals) + self._apply_template_fields_from_vals(vals) + return res + def copy_data(self, default=None): - # Propagate task end dates when creating a project from a template + """preserve template-specific fields while reusing the core copy flow""" + default = dict(default or {}) vals_list = super().copy_data(default=default) - if self.env.context.get(TASK_DEFAULT_COPY_CONTEXT_KEY): - for task, vals in zip(self, vals_list, strict=True): - vals["date_end"] = task.date_end + for task, vals in zip(self, vals_list, strict=True): + task._update_template_copy_data(vals, default) return vals_list def update_date_end(self, stage_id): - # Refuse to overwrite date_end when we are copying a template + # keep the template task end date during template project copies if self.env.context.get(TASK_DEFAULT_COPY_CONTEXT_KEY): return {} return super().update_date_end(stage_id) + + @api.onchange("project_id") + def _onchange_project_id_template(self): + self.is_template = bool(self.project_id.is_template) + + @api.onchange("task_template_id") + def _onchange_task_template_id(self): + if self.task_template_id and self._get_task_template_target_project(): + self.child_ids = [ + Command.create(self._prepare_task_template_child_vals(template_child)) + for template_child in self.task_template_id._get_template_children() + ] + + def _add_is_template_default(self, vals): + """default task template flag from its project or parent task""" + if "is_template" in vals: + return + project = self.env["project.project"].browse( + vals.get("project_id") or self.env.context.get("default_project_id") + ) + parent = self.browse( + vals.get("parent_id") or self.env.context.get("default_parent_id") + ) + vals["is_template"] = bool(project.is_template or parent.is_template) + + def _apply_template_fields_from_vals(self, vals): + """apply behavior attached to template-related values""" + if self._should_apply_task_template(vals): + self._apply_task_template() + if vals.get("is_template") is False: + self._set_subtasks_template(False) + + def _should_apply_task_template(self, vals): + return bool(vals.get("task_template_id") and "child_ids" not in vals) + + def _update_template_copy_data(self, vals, default): + """adapt copied values when copying a project template or task template""" + if self.env.context.get(TASK_DEFAULT_COPY_CONTEXT_KEY): + vals["date_end"] = self.date_end + if self.env.context.get(TEMPLATE_TASKS_ONLY_CONTEXT_KEY): + vals.update(is_template=False, task_template_id=False) + vals["child_ids"] = [ + Command.create(child._prepare_template_child_copy_vals(default)) + for child in self._get_template_children() + ] + + def _get_template_children(self): + return self.child_ids.filtered(lambda child: child.active and child.is_template) + + def _prepare_template_child_copy_vals(self, default=None): + """prepare copied values for a child task inside a template tree""" + self.ensure_one() + default = { + **dict(default or {}), + "depend_on_ids": False, + "dependent_ids": False, + "parent_id": False, + "is_template": False, + "task_template_id": False, + } + return self._copy_as_template_task(default) + + def _copy_as_template_task(self, default=None): + """copy a task with template filtering enabled for its children""" + self.ensure_one() + return self.with_context( + copy_project=True, + **{TEMPLATE_TASKS_ONLY_CONTEXT_KEY: True}, + ).copy_data(dict(default or {}))[0] + + def _set_subtasks_template(self, is_template): + subtasks = self._get_all_subtasks() + if subtasks: + subtasks.write({"is_template": is_template}) + + def _apply_task_template(self): + for task in self.filtered("task_template_id"): + if not task._get_task_template_target_project(): + continue + task.write( + { + "child_ids": [ + Command.create(task._prepare_task_template_child_vals(child)) + for child in task.task_template_id._get_template_children() + ] + } + ) + + def _get_task_template_target_project(self): + self.ensure_one() + return ( + self.project_id + or self.parent_id.project_id + or self.env["project.project"].browse( + self.env.context.get("default_project_id") + ) + ) + + def _set_template_copy_project(self, vals, project_id): + if not project_id: + return + vals["project_id"] = project_id + for command in vals.get("child_ids", []): + if command[0] == Command.CREATE: + self._set_template_copy_project(command[2], project_id) + + def _prepare_task_template_child_vals(self, template_child): + """prepare child task values created from the selected task template""" + self.ensure_one() + project = self._get_task_template_target_project() + default = { + "project_id": project.id, + "display_in_project": False, + "is_template": False, + "task_template_id": False, + } + vals = template_child._copy_as_template_task(default) + self._set_template_copy_project(vals, project.id) + return vals diff --git a/project_template/readme/CONTRIBUTORS.md b/project_template/readme/CONTRIBUTORS.md index 82ac6b0f8e..1100d7d7f4 100644 --- a/project_template/readme/CONTRIBUTORS.md +++ b/project_template/readme/CONTRIBUTORS.md @@ -3,3 +3,4 @@ - Mantas Šniukas \<\> - Atte Isopuro \<\> - Stefan Rijnhart \<\> +- Souheil Bejaoui diff --git a/project_template/readme/DESCRIPTION.md b/project_template/readme/DESCRIPTION.md index 6d1a92a25a..39c8be025a 100644 --- a/project_template/readme/DESCRIPTION.md +++ b/project_template/readme/DESCRIPTION.md @@ -1 +1 @@ -This module adds templates for projects. +This module adds templates for projects and reusable project task structures. diff --git a/project_template/readme/USAGE.md b/project_template/readme/USAGE.md index b0fd138be5..429b4a1df0 100644 --- a/project_template/readme/USAGE.md +++ b/project_template/readme/USAGE.md @@ -1,10 +1,11 @@ To use this module, you need to: -1. Have Manager rights for Project group to edit projects and project - templates. -2. Convert project to a project template by setting the "Is Template?" - field on any project. +1. Have Manager rights for Project group to edit projects and project templates. +2. Convert project to a project template by setting the "Is Template?" field on any + project. 3. View Templates via the Template filter. -4. Use the "Create Project from Template" link in the drop down menu on - each template while in the Kanban view or the button on the project - template form. +4. Use the "Create Project from Template" link in the drop down menu on each template + while in the Kanban view or the button on the project template form. +5. Select a project template on a new project to copy its settings and reusable tasks. +6. Mark tasks that should be copied or reused with the "Is Template" field. +7. Use the "Task Template" field on a task to create subtasks from a template task. diff --git a/project_template/static/description/index.html b/project_template/static/description/index.html index e0022840e1..879b39c396 100644 --- a/project_template/static/description/index.html +++ b/project_template/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Project Templates -
+
+

Project Templates

- - -Odoo Community Association - -
-

Project Templates

-

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

-

This module adds templates for projects.

+

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

+

This module adds templates for projects and reusable project task +structures.

Table of contents

    @@ -390,7 +386,7 @@

    Project Templates

-

Usage

+

Usage

To use this module, you need to:

  1. Have Manager rights for Project group to edit projects and project @@ -401,10 +397,16 @@

    Usage

  2. Use the “Create Project from Template” link in the drop down menu on each template while in the Kanban view or the button on the project template form.
  3. +
  4. Select a project template on a new project to copy its settings and +reusable tasks.
  5. +
  6. Mark tasks that should be copied or reused with the “Is Template” +field.
  7. +
  8. Use the “Task Template” field on a task to create subtasks from a +template task.
-

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 @@ -412,25 +414,27 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Patrick Wilson
  • +
  • ACSONE SA/NV
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -438,13 +442,12 @@

Maintainers

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.

-

Current maintainer:

-

patrickrwilson

+

Current maintainers:

+

patrickrwilson sbejaoui

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_template/tests/__init__.py b/project_template/tests/__init__.py index 16f937a422..6f47ac6592 100644 --- a/project_template/tests/__init__.py +++ b/project_template/tests/__init__.py @@ -1,3 +1,4 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from . import test_project_template +from . import test_project_task_template diff --git a/project_template/tests/test_project_task_template.py b/project_template/tests/test_project_task_template.py new file mode 100644 index 0000000000..e0cb9ba9ec --- /dev/null +++ b/project_template/tests/test_project_task_template.py @@ -0,0 +1,278 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.tests import Form + +from odoo.addons.base.tests.common import BaseCommon + + +class TestProjectTaskTemplate(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.template_project = cls.env["project.project"].create( + {"name": "Template Project", "is_template": True} + ) + cls.project = cls.env["project.project"].create({"name": "Target Project"}) + + def _create_template_task_tree(self): + """create a reusable template tree with one branch to skip""" + template_task = self.env["project.task"].create( + {"name": "Template parent", "project_id": self.template_project.id} + ) + template_child = self.env["project.task"].create( + { + "name": "Template child", + "project_id": self.template_project.id, + "parent_id": template_task.id, + "is_template": True, + } + ) + template_grandchild = self.env["project.task"].create( + { + "name": "Template grandchild", + "project_id": self.template_project.id, + "parent_id": template_child.id, + "is_template": True, + } + ) + skipped_child = self.env["project.task"].create( + { + "name": "Skipped child", + "project_id": self.template_project.id, + "parent_id": template_task.id, + "is_template": False, + } + ) + # skipped child descendants must not be copied through their parent + self.env["project.task"].create( + { + "name": "Skipped grandchild", + "project_id": self.template_project.id, + "parent_id": skipped_child.id, + "is_template": True, + } + ) + return template_task, template_child, template_grandchild, skipped_child + + def test_onchange_project_id_updates_is_template_on_form(self): + """project onchange updates the task template flag in forms""" + with Form(self.env["project.task"]) as task_form: + task_form.name = "Task" + task_form.project_id = self.template_project + self.assertTrue(task_form.is_template) + task_form.project_id = self.project + self.assertFalse(task_form.is_template) + + def test_onchange_task_template_id_populates_subtasks_on_form(self): + """task template onchange adds first-level template children in forms""" + template_task, template_child, _template_grandchild, skipped_child = ( + self._create_template_task_tree() + ) + + with Form(self.env["project.task"]) as task_form: + task_form.name = "Target task" + task_form.project_id = self.project + task_form.task_template_id = template_task + self.assertEqual(len(task_form.child_ids), 1) + with task_form.child_ids.edit(0) as child_form: + self.assertEqual(child_form.name, template_child.name) + self.assertNotEqual(child_form.name, skipped_child.name) + + def test_is_template_defaults_from_template_project(self): + """new tasks in template projects become template tasks by default""" + task = self.env["project.task"].create( + {"name": "Template task", "project_id": self.template_project.id} + ) + self.assertTrue(task.is_template) + + def test_is_template_defaults_from_template_parent(self): + """new subtasks inherit the template flag from their parent task""" + parent = self.env["project.task"].create( + { + "name": "Template parent", + "project_id": self.project.id, + "is_template": True, + } + ) + child = self.env["project.task"].create( + { + "name": "Template child", + "project_id": self.project.id, + "parent_id": parent.id, + } + ) + self.assertTrue(child.is_template) + + def test_explicit_is_template_value_is_preserved(self): + """explicit task template values are not overwritten by defaults""" + task = self.env["project.task"].create( + { + "name": "Not reusable", + "project_id": self.template_project.id, + "is_template": False, + } + ) + self.assertFalse(task.is_template) + + def test_project_template_flag_updates_existing_tasks(self): + """changing a project template flag updates its existing tasks""" + project = self.env["project.project"].create({"name": "Future Template"}) + task = self.env["project.task"].create( + {"name": "Task", "project_id": project.id} + ) + self.assertFalse(task.is_template) + + project.is_template = True + self.assertTrue(task.is_template) + + project.is_template = False + self.assertFalse(task.is_template) + + def test_disabling_template_task_disables_subtasks(self): + """disabling a template task also disables all its subtasks""" + parent = self.env["project.task"].create( + {"name": "Template parent", "project_id": self.template_project.id} + ) + child = self.env["project.task"].create( + { + "name": "Template child", + "project_id": self.template_project.id, + "parent_id": parent.id, + } + ) + grandchild = self.env["project.task"].create( + { + "name": "Template grandchild", + "project_id": self.template_project.id, + "parent_id": child.id, + } + ) + + parent.is_template = False + self.assertFalse(child.is_template) + self.assertFalse(grandchild.is_template) + + def test_create_with_task_template_creates_template_subtasks(self): + """creating with a task template copies only reusable children""" + template_task, template_child, template_grandchild, skipped_child = ( + self._create_template_task_tree() + ) + target_task = self.env["project.task"].create( + { + "name": "Target task", + "project_id": self.project.id, + "task_template_id": template_task.id, + } + ) + + self.assertEqual(target_task.child_ids.mapped("name"), [template_child.name]) + copied_child = target_task.child_ids + # recursive reusable children are copied by the server-side create path + self.assertEqual( + copied_child.child_ids.mapped("name"), [template_grandchild.name] + ) + self.assertEqual(copied_child.child_ids.project_id, self.project) + self.assertNotIn(skipped_child.name, target_task.child_ids.mapped("name")) + self.assertEqual(copied_child.project_id, self.project) + self.assertFalse(copied_child.display_in_project) + self.assertFalse(copied_child.is_template) + self.assertFalse(copied_child.task_template_id) + self.assertFalse(copied_child.child_ids.is_template) + + def test_write_task_template_creates_template_subtasks(self): + """writing a task template copies reusable children""" + template_task, template_child, _template_grandchild, _skipped_child = ( + self._create_template_task_tree() + ) + target_task = self.env["project.task"].create( + {"name": "Target task", "project_id": self.project.id} + ) + + target_task.task_template_id = template_task + + self.assertEqual(target_task.child_ids.mapped("name"), [template_child.name]) + + def test_existing_children_are_kept_when_writing_task_template(self): + """applying a task template appends to existing children""" + template_task, template_child, _template_grandchild, _skipped_child = ( + self._create_template_task_tree() + ) + target_task = self.env["project.task"].create( + {"name": "Target task", "project_id": self.project.id} + ) + existing_child = self.env["project.task"].create( + { + "name": "Existing child", + "project_id": self.project.id, + "parent_id": target_task.id, + } + ) + + target_task.task_template_id = template_task + + self.assertCountEqual( + target_task.child_ids.mapped("name"), + [existing_child.name, template_child.name], + ) + + def test_explicit_child_ids_do_not_apply_task_template(self): + """explicit children prevent automatic template application""" + template_task, template_child, _template_grandchild, _skipped_child = ( + self._create_template_task_tree() + ) + created_task = self.env["project.task"].create( + { + "name": "Target task", + "project_id": self.project.id, + "task_template_id": template_task.id, + "child_ids": [ + Command.create( + { + "name": "Explicit child on create", + "project_id": self.project.id, + } + ) + ], + } + ) + + self.assertEqual( + created_task.child_ids.mapped("name"), ["Explicit child on create"] + ) + self.assertNotIn(template_child.name, created_task.child_ids.mapped("name")) + + written_task = self.env["project.task"].create( + {"name": "Target task", "project_id": self.project.id} + ) + written_task.write( + { + "task_template_id": template_task.id, + "child_ids": [ + Command.create( + { + "name": "Explicit child on write", + "project_id": self.project.id, + } + ) + ], + } + ) + + self.assertEqual( + written_task.child_ids.mapped("name"), ["Explicit child on write"] + ) + self.assertNotIn(template_child.name, written_task.child_ids.mapped("name")) + + def test_private_task_with_task_template_does_not_create_subtasks(self): + """private tasks do not receive generated subtasks from templates""" + template_task, _template_child, _template_grandchild, _skipped_child = ( + self._create_template_task_tree() + ) + + target_task = self.env["project.task"].create( + {"name": "Private target task", "task_template_id": template_task.id} + ) + + self.assertFalse(target_task.project_id) + self.assertFalse(target_task.child_ids) diff --git a/project_template/tests/test_project_template.py b/project_template/tests/test_project_template.py index 5b083dda51..4211697db0 100644 --- a/project_template/tests/test_project_template.py +++ b/project_template/tests/test_project_template.py @@ -2,6 +2,8 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from datetime import datetime, timedelta +from odoo.tests import Form + from odoo.addons.base.tests.common import BaseCommon @@ -40,6 +42,64 @@ def test_on_change_is_template(self): self.assertEqual(project_01.name, "TestProject") # TEST 02: Create project from template + + def test_onchange_project_template_copies_project_settings(self): + """selecting a project template copies project settings in the form""" + project = self.test_project + project.is_template = True + project.label_tasks = "Deliverables" + project.allow_milestones = True + project.allow_task_dependencies = True + project.privacy_visibility = "employees" + + project_form = Form(self.env["project.project"]) + project_form.name = "Project from selector" + project_form.project_template_id = project + self.assertEqual(project_form.label_tasks, project.label_tasks) + self.assertEqual(project_form.privacy_visibility, project.privacy_visibility) + self.assertEqual(project_form.allow_milestones, project.allow_milestones) + + def test_create_project_with_template_copies_project_data_and_template_tasks(self): + """creating a project with a template copies settings and reusable tasks""" + project = self.test_project + project.is_template = True + project.label_tasks = "Deliverables" + project.privacy_visibility = "employees" + project.allow_task_dependencies = True + self.tasks[0].is_template = True + self.tasks[1].is_template = False + + new_project = self.env["project.project"].create( + {"name": "Project from selector", "project_template_id": project.id} + ) + + self.assertEqual(new_project.label_tasks, project.label_tasks) + self.assertEqual(new_project.privacy_visibility, project.privacy_visibility) + self.assertEqual( + new_project.allow_task_dependencies, project.allow_task_dependencies + ) + self.assertEqual(new_project.task_ids.mapped("name"), [self.tasks[0].name]) + self.assertFalse(new_project.task_ids.is_template) + + def test_create_project_with_template_preserves_explicit_values(self): + """explicit create values are not overwritten by template defaults""" + project = self.test_project + project.is_template = True + project.label_tasks = "Deliverables" + project.privacy_visibility = "employees" + + new_project = self.env["project.project"].create( + { + "name": "Project from selector", + "project_template_id": project.id, + "label_tasks": "Tasks", + "privacy_visibility": "followers", + } + ) + + self.assertEqual(new_project.label_tasks, "Tasks") + self.assertEqual(new_project.privacy_visibility, "followers") + def test_create_project_from_template(self): # Set Project Template project_01 = self.test_project @@ -52,6 +112,7 @@ def test_create_project_from_template(self): [("name", "=", "TestProject (COPY)")] ) self.assertEqual(len(new_project), 1) + self.assertFalse(new_project.is_template) # TEST 03: Create project from template using non-standard name def test_create_project_from_template_non_standard_name(self): @@ -80,6 +141,7 @@ def test_create_project_from_template_duplicate_task_names(self): for i, task in enumerate(self.tasks): date = now - timedelta(weeks=i) task.name = "Same for all tasks" + task.is_template = True dates.add(date) task.date_end = date @@ -104,3 +166,82 @@ def test_create_project_from_template_duplicate_task_names(self): self.assertEqual(len(tasks), 2) self.assertFalse(tasks[0].date_end) self.assertFalse(tasks[1].date_end) + + def test_create_project_from_template_only_copies_template_tasks(self): + project = self.test_project + project.is_template = True + self.tasks[0].is_template = True + self.tasks[1].is_template = False + child_to_copy = self.env["project.task"].create( + { + "name": "Copied child", + "project_id": project.id, + "parent_id": self.tasks[0].id, + "is_template": True, + } + ) + child_to_skip = self.env["project.task"].create( + { + "name": "Skipped child", + "project_id": project.id, + "parent_id": self.tasks[0].id, + "is_template": False, + } + ) + + project.create_project_from_template() + new_project = self.env["project.project"].search( + [("name", "=", "TestProject (COPY)")] + ) + self.assertEqual(len(new_project), 1) + new_tasks = self.env["project.task"].search( + [("project_id", "=", new_project.id)] + ) + self.assertCountEqual( + new_tasks.mapped("name"), [self.tasks[0].name, child_to_copy.name] + ) + self.assertNotIn(child_to_skip.name, new_tasks.mapped("name")) + self.assertFalse(any(new_tasks.mapped("is_template"))) + + def test_create_project_from_template_copies_nested_template_tasks(self): + """project templates copy nested reusable task trees""" + project = self.env["project.project"].create( + {"name": "Nested Template", "is_template": True} + ) + root = self.env["project.task"].create( + {"name": "Copied root", "project_id": project.id, "is_template": True} + ) + child = self.env["project.task"].create( + { + "name": "Copied child", + "project_id": project.id, + "parent_id": root.id, + "is_template": True, + } + ) + grandchild = self.env["project.task"].create( + { + "name": "Copied grandchild", + "project_id": project.id, + "parent_id": child.id, + "is_template": True, + } + ) + + project.create_project_from_template() + + new_project = self.env["project.project"].search( + [("name", "=", "Nested Template (COPY)")] + ) + new_tasks = self.env["project.task"].search( + [("project_id", "=", new_project.id)] + ) + self.assertCountEqual( + new_tasks.mapped("name"), [root.name, child.name, grandchild.name] + ) + self.assertTrue( + new_tasks.filtered(lambda task: task.name == child.name).parent_id + ) + self.assertTrue( + new_tasks.filtered(lambda task: task.name == grandchild.name).parent_id + ) diff --git a/project_template/views/project.xml b/project_template/views/project.xml index 789bc0c615..6e758881d4 100644 --- a/project_template/views/project.xml +++ b/project_template/views/project.xml @@ -1,3 +1,4 @@ + @@ -17,6 +18,11 @@ + @@ -59,9 +65,27 @@ - + {"display_milestone_deadline": True, "search_default_projects": True} + + + Project Templates + project.project + kanban,list,form + [("is_template", "=", True)] + {"default_is_template": True, "search_default_templates": True} + + + diff --git a/project_template/views/project_task.xml b/project_template/views/project_task.xml new file mode 100644 index 0000000000..599352e7c6 --- /dev/null +++ b/project_template/views/project_task.xml @@ -0,0 +1,32 @@ + + + + + project.task + + + + + + + + + + + + + + +