Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions project_template/README.rst
Original file line number Diff line number Diff line change
@@ -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
=================
Expand All @@ -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
Expand All @@ -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**

Expand All @@ -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
===========
Expand All @@ -70,6 +73,7 @@ Authors
-------

* Patrick Wilson
* ACSONE SA/NV

Contributors
------------
Expand All @@ -79,6 +83,7 @@ Contributors
- Mantas Šniukas <mantas@vialaurea.lt>
- Atte Isopuro <atte.isopuro@avoin.systems>
- Stefan Rijnhart <stefan@opener.amsterdam>
- Souheil Bejaoui souheil.bejaoui@acsone.eu

Maintainers
-----------
Expand All @@ -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 <https://odoo-community.org/page/maintainer-role>`__:
Current `maintainers <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-patrickrwilson|
|maintainer-patrickrwilson| |maintainer-sbejaoui|

This module is part of the `OCA/project <https://github.com/OCA/project/tree/18.0/project_template>`_ project on GitHub.

Expand Down
12 changes: 8 additions & 4 deletions project_template/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
# Copyright 2019 Patrick Wilson <patrickraymondwilson@gmail.com>
# 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"],
}
139 changes: 130 additions & 9 deletions project_template/models/project.py
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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:
Expand All @@ -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
Loading
Loading