diff --git a/project_milestone_spent_hours/README.rst b/project_milestone_spent_hours/README.rst new file mode 100644 index 0000000000..208a2a8f30 --- /dev/null +++ b/project_milestone_spent_hours/README.rst @@ -0,0 +1,34 @@ +Project Milestone Spent Hours +================================= + +.. contents:: Table of Contents + +Context +------- +The module `project_milestone `_ allows to define milestones for a project. + +Multiple tasks in the project can be linked to a given milestone. + +Field total hours is displayed in form and list view of a project milestone and in tab of milestones of a project + +Description +----------- +Field Total Hours is the sum of timesheets of active tasks associated to the milestone + +Overview +-------- + +I create timesheets and set duration for 2 tasks associated to the same milestone + +.. image:: project_milestone_spent_hours/static/description/task1.png + +.. image:: project_milestone_spent_hours/static/description/task2.png + +I open the milestone, the field Total hours is set with sum of timesheets spent hours of associated tasks + +.. image:: project_milestone_spent_hours/static/description/milestone.png + + +Contributors +------------ +* Numigi (tm) and all its contributors (https://bit.ly/numigiens) diff --git a/project_milestone_spent_hours/__init__.py b/project_milestone_spent_hours/__init__.py new file mode 100644 index 0000000000..d6a63ca028 --- /dev/null +++ b/project_milestone_spent_hours/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2024-today Numigi and all its contributors (https://bit.ly/numigiens) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/project_milestone_spent_hours/__manifest__.py b/project_milestone_spent_hours/__manifest__.py new file mode 100644 index 0000000000..3beb36b2a4 --- /dev/null +++ b/project_milestone_spent_hours/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2024-today Numigi and all its contributors (https://bit.ly/numigiens) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Project Milestone Spent Hours", + "version": "14.0.1.0.0", + "author": "Numigi, Odoo Community Association (OCA)", + "maintainer": "Numigi", + "website": "https://github.com/OCA/project", + "license": "AGPL-3", + "category": "Project", + "summary": """Add field Total Hours in milestones which display the sum + of all hours of tasks associated to the milestone""", + "depends": ["hr_timesheet", "project_milestone"], + "data": [ + "views/project_milestone.xml", + "views/project.xml", + ], + "installable": True, +} diff --git a/project_milestone_spent_hours/i18n/fr.po b/project_milestone_spent_hours/i18n/fr.po new file mode 100644 index 0000000000..7ffe8a7135 --- /dev/null +++ b/project_milestone_spent_hours/i18n/fr.po @@ -0,0 +1,37 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * project_milestone_spent_hours +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-03-28 15:25+0000\n" +"PO-Revision-Date: 2022-03-28 15:25+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: project_milestone_spent_hours +#: model:ir.model,name:project_milestone_spent_hours.model_account_analytic_line +msgid "Analytic Line" +msgstr "Ligne analytique" + +#. module: project_milestone_spent_hours +#: model:ir.model.fields,field_description:project_milestone_spent_hours.field_account_analytic_line__milestone_id +msgid "Milestone" +msgstr "Jalon" + +#. module: project_milestone_spent_hours +#: model:ir.model,name:project_milestone_spent_hours.model_project_milestone +msgid "Project Milestone" +msgstr "Jalon du projet" + +#. module: project_milestone_spent_hours +#: model:ir.model.fields,field_description:project_milestone_spent_hours.field_project_milestone__total_hours +msgid "Total Hours" +msgstr "Heures passées" + diff --git a/project_milestone_spent_hours/models/__init__.py b/project_milestone_spent_hours/models/__init__.py new file mode 100644 index 0000000000..42c1590b68 --- /dev/null +++ b/project_milestone_spent_hours/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2024-today Numigi and all its contributors (https://bit.ly/numigiens) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import project_milestone, account_analytic_line diff --git a/project_milestone_spent_hours/models/account_analytic_line.py b/project_milestone_spent_hours/models/account_analytic_line.py new file mode 100644 index 0000000000..ad3c3dcada --- /dev/null +++ b/project_milestone_spent_hours/models/account_analytic_line.py @@ -0,0 +1,17 @@ +# Copyright 2024-today Numigi and all its contributors (https://bit.ly/numigiens) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AccountAnalyticLine(models.Model): + _inherit = "account.analytic.line" + + milestone_id = fields.Many2one( + "project.milestone", + related="task_id.milestone_id", + string="Milestone", + index=True, + compute_sudo=True, + store=True, + ) diff --git a/project_milestone_spent_hours/models/project_milestone.py b/project_milestone_spent_hours/models/project_milestone.py new file mode 100644 index 0000000000..37b05e57c5 --- /dev/null +++ b/project_milestone_spent_hours/models/project_milestone.py @@ -0,0 +1,46 @@ +# Copyright 2024-today Numigi and all its contributors (https://bit.ly/numigiens) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ProjectMilestone(models.Model): + _inherit = "project.milestone" + + active = fields.Boolean("Active", default=True) + total_hours = fields.Float( + compute="_compute_total_hours", + string="Total Hours", + compute_sudo=True, + store=True, + ) + + def write(self, vals): + res = super(ProjectMilestone, self).write(vals) + if "project_id" in vals: + self._remove_task_milestones(vals["project_id"]) + return res + + def _remove_task_milestones(self, project_id): + self.with_context(active_test=False).mapped("project_task_ids").filtered( + lambda milestone: not project_id or milestone.project_id.id != project_id + ).write({"milestone_id": False}) + + @api.depends( + "project_task_ids", + "project_task_ids.active", + "project_task_ids.milestone_id", + "project_task_ids.timesheet_ids", + "project_task_ids.timesheet_ids.unit_amount", + "active", + ) + def _compute_total_hours(self): + for record in self: + total_hours = 0.0 + if record.active: + total_hours = sum( + record.project_task_ids.filtered(lambda milestone: milestone.active) + .mapped("timesheet_ids") + .mapped("unit_amount") + ) + record.total_hours = total_hours diff --git a/project_milestone_spent_hours/static/description/icon.png b/project_milestone_spent_hours/static/description/icon.png new file mode 100644 index 0000000000..92a86b10ed Binary files /dev/null and b/project_milestone_spent_hours/static/description/icon.png differ diff --git a/project_milestone_spent_hours/static/description/milestone.png b/project_milestone_spent_hours/static/description/milestone.png new file mode 100644 index 0000000000..d729dc4db4 Binary files /dev/null and b/project_milestone_spent_hours/static/description/milestone.png differ diff --git a/project_milestone_spent_hours/static/description/task1.png b/project_milestone_spent_hours/static/description/task1.png new file mode 100644 index 0000000000..3f678988d0 Binary files /dev/null and b/project_milestone_spent_hours/static/description/task1.png differ diff --git a/project_milestone_spent_hours/static/description/task2.png b/project_milestone_spent_hours/static/description/task2.png new file mode 100644 index 0000000000..bc3ca30cd3 Binary files /dev/null and b/project_milestone_spent_hours/static/description/task2.png differ diff --git a/project_milestone_spent_hours/tests/__init__.py b/project_milestone_spent_hours/tests/__init__.py new file mode 100644 index 0000000000..dd1b8a7b4c --- /dev/null +++ b/project_milestone_spent_hours/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2024-today Numigi and all its contributors (https://bit.ly/numigiens) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_spent_hours diff --git a/project_milestone_spent_hours/tests/test_spent_hours.py b/project_milestone_spent_hours/tests/test_spent_hours.py new file mode 100644 index 0000000000..80b12748c5 --- /dev/null +++ b/project_milestone_spent_hours/tests/test_spent_hours.py @@ -0,0 +1,74 @@ +# Copyright 2024-today Numigi and all its contributors (https://bit.ly/numigiens) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import SavepointCase + + +class TestMilestoneTotalHours(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.project = cls.env["project.project"].create({"name": "My Project"}) + + cls.milestone_1 = cls.env["project.milestone"].create( + {"name": "My Milestone 1", "project_id": cls.project.id} + ) + + cls.milestone_2 = cls.env["project.milestone"].create( + {"name": "My Milestone 2", "project_id": cls.project.id} + ) + + cls.task = cls.env["project.task"].create( + { + "name": "My Task", + "project_id": cls.project.id, + "milestone_id": cls.milestone_1.id, + } + ) + + cls.analytic_line_1 = cls.env["account.analytic.line"].create( + { + "name": "My Timesheet 1", + "task_id": cls.task.id, + "unit_amount": 10, + "project_id": cls.project.id, + } + ) + + cls.analytic_line_2 = cls.env["account.analytic.line"].create( + { + "name": "My Timesheet 2", + "task_id": cls.task.id, + "unit_amount": 20, + "project_id": cls.project.id, + } + ) + + def test_propagate_milestone_on_analytic_line(self): + assert self.task.milestone_id & self.analytic_line_1.milestone_id + + def test_update_milestone_total_hours_when_creating_analytic_line(self): + assert self.milestone_1.total_hours == 30 + + def test_update_milestone_total_hours_when_updating_analytic_line(self): + self.analytic_line_1.unit_amount = 20 + assert self.milestone_1.total_hours == 40 + + def test_update_milestone_total_hours_when_removing_analytic_line(self): + self.analytic_line_1.unlink() + assert self.milestone_1.total_hours == 20 + + def test_update_milestone_total_hours_when_modifying_milestone_on_task(self): + self.task.milestone_id = self.milestone_2 + assert self.milestone_1.total_hours == 0 + assert self.milestone_2.total_hours == 30 + + def test_update_milestone_total_hours_when_task_inactive(self): + self.task.active = 0 + assert self.milestone_1.total_hours == 0 + assert self.milestone_2.total_hours == 0 + + def test_update_milestone_total_hours_when_remove_project(self): + self.milestone_1.project_id = False + assert self.milestone_1.total_hours == 0 diff --git a/project_milestone_spent_hours/views/project.xml b/project_milestone_spent_hours/views/project.xml new file mode 100644 index 0000000000..b080e1530c --- /dev/null +++ b/project_milestone_spent_hours/views/project.xml @@ -0,0 +1,21 @@ + + + + + Project Milestone Spent Hours Form + project.project + + form + + + + + + + diff --git a/project_milestone_spent_hours/views/project_milestone.xml b/project_milestone_spent_hours/views/project_milestone.xml new file mode 100644 index 0000000000..0d06ebe774 --- /dev/null +++ b/project_milestone_spent_hours/views/project_milestone.xml @@ -0,0 +1,26 @@ + + + + + Project Milestone Spent Hours List + project.milestone + + + + + + + + + + + Project Milestone Spent Hours Form + project.milestone + + + + + + + + diff --git a/setup/project_milestone_spent_hours/odoo/addons/project_milestone_spent_hours b/setup/project_milestone_spent_hours/odoo/addons/project_milestone_spent_hours new file mode 120000 index 0000000000..8ed7dbd70c --- /dev/null +++ b/setup/project_milestone_spent_hours/odoo/addons/project_milestone_spent_hours @@ -0,0 +1 @@ +../../../../project_milestone_spent_hours \ No newline at end of file diff --git a/setup/project_milestone_spent_hours/setup.py b/setup/project_milestone_spent_hours/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/project_milestone_spent_hours/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)