From a118555a40d7ddfb53689ce672fdb897a851e292 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 10 May 2022 10:30:46 +0200 Subject: [PATCH] [IMP] project: link milestone to task Before this commit, the milestones are only linked to a project without any link to the tasks of the same project. However, the tasks could be considered as steps to reach a project milestone. This commit adds a link between the `project.task` and `project.milestone` models. This link is added in the `project.task` model with the Many2One field called `milestone_id`. With this field, the user will be able to link a milestone to a task. task-2829542 --- .../hr_timesheet/data/hr_timesheet_demo.xml | 2 +- addons/project/__manifest__.py | 1 + addons/project/data/project_demo.xml | 51 ++++++++----- addons/project/models/project.py | 25 ++++++- addons/project/models/project_milestone.py | 22 +++++- addons/project/models/project_update.py | 8 ++ addons/project/models/res_config_settings.py | 2 + addons/project/security/project_security.xml | 5 ++ .../static/src/xml/project_templates.xml | 3 +- addons/project/tests/__init__.py | 1 + .../project/tests/test_project_milestone.py | 51 +++++++++++++ .../project/tests/test_project_update_flow.py | 39 ++++++++++ .../project/tests/test_project_update_ui.py | 8 ++ .../project/views/project_milestone_views.xml | 57 +++++++++++++++ addons/project/views/project_update_views.xml | 31 -------- addons/project/views/project_views.xml | 73 +++++++++++++++++-- .../views/res_config_settings_views.xml | 11 +++ .../sale_timesheet/data/sale_service_demo.xml | 9 +++ .../tests/test_sale_timesheet_ui.py | 5 ++ 19 files changed, 343 insertions(+), 61 deletions(-) create mode 100644 addons/project/tests/test_project_milestone.py create mode 100644 addons/project/views/project_milestone_views.xml diff --git a/addons/hr_timesheet/data/hr_timesheet_demo.xml b/addons/hr_timesheet/data/hr_timesheet_demo.xml index b06c6797cea..de071563315 100644 --- a/addons/hr_timesheet/data/hr_timesheet_demo.xml +++ b/addons/hr_timesheet/data/hr_timesheet_demo.xml @@ -3524,7 +3524,7 @@ - + diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 85e4b1b4c31..7bac6b33b7a 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -36,6 +36,7 @@ 'views/project_collaborator_views.xml', 'wizard/project_report_wizard_views.xml', 'views/project_views.xml', + 'views/project_milestone_views.xml', 'views/res_partner_views.xml', 'views/res_config_settings_views.xml', 'views/mail_activity_views.xml', diff --git a/addons/project/data/project_demo.xml b/addons/project/data/project_demo.xml index 6e03a5fa54d..a8fc6c2c027 100644 --- a/addons/project/data/project_demo.xml +++ b/addons/project/data/project_demo.xml @@ -204,6 +204,27 @@ + + + + + First Phase + + + + + + + Second Phase + + + + + + Final Phase + + + 20 @@ -214,6 +235,7 @@ 7 + @@ -224,6 +246,7 @@ Lunch Room: kitchen + project.task @@ -273,6 +296,7 @@ 4 + project.task @@ -322,6 +346,7 @@ Modifications to the kitchen of the lunch room + project.task @@ -371,6 +396,7 @@ 1 + project.task @@ -413,6 +439,7 @@ 11 + project.task @@ -452,6 +479,7 @@ 9 + project.task @@ -485,6 +513,7 @@ blocked + project.task @@ -533,6 +562,7 @@ 3 + @@ -581,6 +611,7 @@ + 20 @@ -1277,26 +1308,6 @@ Send it ASAP, its urgent. - - - - First Phase - - - - - - - Second Phase - - - - - - Final Phase - - - Review of the situation diff --git a/addons/project/models/project.py b/addons/project/models/project.py index efe4a6f313b..37f7439d813 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -323,6 +323,7 @@ class Project(models.Model): allow_subtasks = fields.Boolean('Sub-tasks', default=lambda self: self.env.user.has_group('project.group_subtask_project')) allow_recurring_tasks = fields.Boolean('Recurring Tasks', default=lambda self: self.env.user.has_group('project.group_project_recurring_tasks')) allow_task_dependencies = fields.Boolean('Task Dependencies', default=lambda self: self.env.user.has_group('project.group_project_task_dependencies')) + allow_milestones = fields.Boolean('Milestones', default=lambda self: self.env.user.has_group('project.group_project_milestone')) tag_ids = fields.Many2many('project.tags', relation='project_project_project_tags_rel', string='Tags') # Project Sharing fields @@ -363,7 +364,7 @@ class Project(models.Model): ], default='to_define', compute='_compute_last_update_status', store=True, readonly=False, required=True) last_update_color = fields.Integer(compute='_compute_last_update_color') milestone_ids = fields.One2many('project.milestone', 'project_id', copy=True) - milestone_count = fields.Integer(compute='_compute_milestone_count') + milestone_count = fields.Integer(compute='_compute_milestone_count', groups='project.group_project_milestone') is_milestone_exceeded = fields.Boolean(compute="_compute_is_milestone_exceeded", search='_search_is_milestone_exceeded') _sql_constraints = [ @@ -782,10 +783,11 @@ class Project(models.Model): return {} panel_data = { 'user': self._get_user_values(), - 'milestones': self._get_milestones(), 'buttons': sorted(self._get_stat_buttons(), key=lambda k: k['sequence']), 'currency_id': self.currency_id.id, } + if self.allow_milestones: + panel_data['milestones'] = self._get_milestones() if self._show_profitability(): profitability_items = self._get_profitability_items() if self._get_profitability_sequence_per_invoice_type() and profitability_items and 'revenues' in profitability_items and 'costs' in profitability_items: # sort the data values @@ -1120,6 +1122,19 @@ class Task(models.Model): # customer portal: include comment and incoming emails in communication history website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ['email', 'comment'])]) is_private = fields.Boolean(compute='_compute_is_private') + allow_milestones = fields.Boolean(related='project_id.allow_milestones') + milestone_id = fields.Many2one( + 'project.milestone', + 'Milestone', + domain="[('project_id', '=', project_id)]", + compute='_compute_milestone_id', + readonly=False, + store=True, + tracking=True, + help="Track major progress points that must be reached to achieve success (e.g. Product Launch). " + "After all the tasks connected to this milestone are completed, you will be invited to mark it as reached if you wish. " + "You can deliver your services automatically when a milestone is reached by linking it to a sales order item." + ) # Task Dependencies fields allow_task_dependencies = fields.Boolean(related='project_id.allow_task_dependencies') @@ -1994,6 +2009,12 @@ class Task(models.Model): if task.parent_id: task.project_id = task.display_project_id or task.parent_id.project_id + @api.depends('project_id') + def _compute_milestone_id(self): + for task in self: + if task.project_id != task.milestone_id.project_id: + task.milestone_id = False + # --------------------------------------------------- # Mail gateway # --------------------------------------------------- diff --git a/addons/project/models/project_milestone.py b/addons/project/models/project_milestone.py index a055236210e..b714ce7cf50 100644 --- a/addons/project/models/project_milestone.py +++ b/addons/project/models/project_milestone.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import _, api, fields, models +from odoo import api, fields, models class ProjectMilestone(models.Model): _name = 'project.milestone' @@ -17,10 +17,12 @@ class ProjectMilestone(models.Model): deadline = fields.Date(tracking=True, copy=False) is_reached = fields.Boolean(string="Reached", default=False, copy=False) reached_date = fields.Date(compute='_compute_reached_date', store=True) + task_ids = fields.One2many('project.task', 'milestone_id', 'Tasks') # computed non-stored fields is_deadline_exceeded = fields.Boolean(compute="_compute_is_deadline_exceeded") is_deadline_future = fields.Boolean(compute="_compute_is_deadline_future") + task_count = fields.Integer('# of Tasks', compute='_compute_task_count', groups='project.group_project_milestone') @api.depends('is_reached') def _compute_reached_date(self): @@ -38,11 +40,29 @@ class ProjectMilestone(models.Model): for ms in self: ms.is_deadline_future = ms.deadline and ms.deadline > fields.Date.context_today(self) + @api.depends('task_ids.milestone_id') + def _compute_task_count(self): + task_read_group = self.env['project.task']._read_group([('milestone_id', 'in', self.ids), ('allow_milestones', '=', True)], ['milestone_id'], ['milestone_id']) + task_count_per_milestone = {res['milestone_id'][0]: res['milestone_id_count'] for res in task_read_group} + for milestone in self: + milestone.task_count = task_count_per_milestone.get(milestone.id, 0) + def toggle_is_reached(self, is_reached): self.ensure_one() self.update({'is_reached': is_reached}) return self._get_data() + def action_view_tasks(self): + self.ensure_one() + action = self.env['ir.actions.act_window']._for_xml_id('project.action_view_task_from_milestone') + action['context'] = {'default_project_id': self.project_id.id, 'default_milestone_id': self.id} + if self.task_count == 1: + action['view_mode'] = 'form' + action['res_id'] = self.task_ids.id + if 'views' in action: + action['views'] = [(view_id, view_type) for view_id, view_type in action['views'] if view_type == 'form'] + return action + @api.model def _get_fields_to_export(self): return ['id', 'name', 'deadline', 'is_reached', 'reached_date', 'is_deadline_exceeded', 'is_deadline_future'] diff --git a/addons/project/models/project_update.py b/addons/project/models/project_update.py index 789feb050c9..bd16213fb60 100644 --- a/addons/project/models/project_update.py +++ b/addons/project/models/project_update.py @@ -110,6 +110,14 @@ class ProjectUpdate(models.Model): @api.model def _get_milestone_values(self, project): Milestone = self.env['project.milestone'] + if not project.allow_milestones: + return { + 'show_section': False, + 'list': [], + 'updated': [], + 'last_update_date': None, + 'created': [] + } list_milestones = Milestone.search( [('project_id', '=', project.id), '|', ('deadline', '<', fields.Date.context_today(self) + relativedelta(years=1)), ('deadline', '=', False)])._get_data_list() diff --git a/addons/project/models/res_config_settings.py b/addons/project/models/res_config_settings.py index e9375f1c9ed..0dd42cc8a62 100644 --- a/addons/project/models/res_config_settings.py +++ b/addons/project/models/res_config_settings.py @@ -14,6 +14,7 @@ class ResConfigSettings(models.TransientModel): group_project_stages = fields.Boolean("Project Stages", implied_group="project.group_project_stages") group_project_recurring_tasks = fields.Boolean("Recurring Tasks", implied_group="project.group_project_recurring_tasks") group_project_task_dependencies = fields.Boolean("Task Dependencies", implied_group="project.group_project_task_dependencies") + group_project_milestone = fields.Boolean('Milestones', implied_group='project.group_project_milestone') rating_status = fields.Selection( [('stage', 'Rating when changing stage'), ('periodic', 'Periodic rating') @@ -41,6 +42,7 @@ class ResConfigSettings(models.TransientModel): basic_project_features = { "group_subtask_project": "allow_subtasks", "group_project_task_dependencies": "allow_task_dependencies", + 'group_project_milestone': 'allow_milestones', } config_feature_vals = {config_flag: self[config_flag] for config_flag in global_features.keys() | basic_project_features.keys()} diff --git a/addons/project/security/project_security.xml b/addons/project/security/project_security.xml index 02ce3a99fe4..bd78cef7c75 100644 --- a/addons/project/security/project_security.xml +++ b/addons/project/security/project_security.xml @@ -44,6 +44,11 @@ + + Use Milestones + + + diff --git a/addons/project/static/src/xml/project_templates.xml b/addons/project/static/src/xml/project_templates.xml index 8e4df090cce..132ca14fd1a 100644 --- a/addons/project/static/src/xml/project_templates.xml +++ b/addons/project/static/src/xml/project_templates.xml @@ -197,8 +197,9 @@ + -
+
Milestones diff --git a/addons/project/tests/__init__.py b/addons/project/tests/__init__.py index 5a27c35173e..fb617a77540 100644 --- a/addons/project/tests/__init__.py +++ b/addons/project/tests/__init__.py @@ -5,6 +5,7 @@ from . import test_burndown_chart from . import test_project_base from . import test_project_config from . import test_project_flow +from . import test_project_milestone from . import test_project_profitability from . import test_project_recurrence from . import test_project_sharing diff --git a/addons/project/tests/test_project_milestone.py b/addons/project/tests/test_project_milestone.py new file mode 100644 index 00000000000..13070b461e5 --- /dev/null +++ b/addons/project/tests/test_project_milestone.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import Form, tagged + +from .test_project_base import TestProjectCommon + + +@tagged('-at_install', 'post_install') +class TestProjectMilestone(TestProjectCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.milestone = cls.env['project.milestone'].with_context({'mail_create_nolog': True}).create({ + 'name': 'Test Milestone', + 'project_id': cls.project_pigs.id, + }) + + def test_milestones_settings_change(self): + # To be sure the feature is disabled globally to begin the test. + self.env['res.config.settings'] \ + .create({'group_project_milestone': False}) \ + .execute() + self.assertFalse(self.env.user.has_group('project.group_project_milestone'), 'The "Milestones" feature should not be globally enabled by default.') + self.assertFalse(self.project_pigs.allow_milestones, 'The "Milestones" feature should not be enabled by default.') + self.env['res.config.settings'] \ + .create({'group_project_milestone': True}) \ + .execute() + self.assertTrue(self.env.user.has_group('project.group_project_milestone'), 'The "Milestones" feature should globally be enabled.') + self.assertTrue(self.project_pigs.allow_milestones, 'The "Milestones" feature should be enabled by default on the project when the feature is enabled.') + project = self.env['project.project'].create({'name': 'Test allow_milestones on New Project'}) + self.assertTrue(project.allow_milestones, 'The "Milestones" feature should be enabled by default when the feature is enabled globally.') + + with Form(self.env['project.project']) as project_form: + project_form.name = 'My Mouses Project' + self.assertTrue(project_form.allow_milestones, 'New projects allow_milestones should be True by default.') + + def test_change_project_in_task(self): + """ Test when a task is linked to a milestone and when we change its project the milestone is removed + + Test Case: + ========= + 1) Set a milestone on the task + 2) Change the project of that task + 3) Check no milestone is linked to the task + """ + self.task_1.milestone_id = self.milestone + self.assertEqual(self.task_1.milestone_id, self.milestone) + + self.task_1.project_id = self.project_goats + self.assertFalse(self.task_1.milestone_id, 'No milestone should be linked to the task since its project has changed') diff --git a/addons/project/tests/test_project_update_flow.py b/addons/project/tests/test_project_update_flow.py index 4435f265197..87ce7dfddb4 100644 --- a/addons/project/tests/test_project_update_flow.py +++ b/addons/project/tests/test_project_update_flow.py @@ -10,6 +10,12 @@ from odoo.addons.project.tests.test_project_base import TestProjectCommon @tagged('-at_install', 'post_install') class TestProjectUpdate(TestProjectCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env['res.config.settings'] \ + .create({'group_project_milestone': True}) \ + .execute() def test_project_update_form(self): with Form(self.env['project.milestone'].with_context({'default_project_id': self.project_pigs.id})) as milestone_form: @@ -47,9 +53,29 @@ class TestProjectUpdate(TestProjectCommon): template_values = self.env['project.update']._get_template_values(self.project_pigs) + self.assertTrue(template_values['milestones']['show_section'], 'The milestone section should not be visible since the feature is disabled') self.assertEqual(len(template_values['milestones']['list']), 2, "Milestone list length should be equal to 2") self.assertEqual(len(template_values['milestones']['created']), 3, "Milestone created length tasks should be equal to 3") + self.project_pigs.write({'allow_milestones': False}) + + template_values = self.env['project.update']._get_template_values(self.project_pigs) + + self.assertFalse(template_values['milestones']['show_section'], 'The milestone section should not be visible since the feature is disabled') + self.assertEqual(len(template_values['milestones']['list']), 0, "Milestone list length should be equal to 0 because the Milestones feature is disabled.") + self.assertEqual(len(template_values['milestones']['created']), 0, "Milestone created length tasks should be equal to 0 because the Milestones feature is disabled.") + + self.project_pigs.write({'allow_milestones': True}) + self.env['res.config.settings'] \ + .create({'group_project_milestone': False}) \ + .execute() + + template_values = self.env['project.update']._get_template_values(self.project_pigs) + + self.assertFalse(template_values['milestones']['show_section'], 'The milestone section should not be visible since the feature is disabled') + self.assertEqual(len(template_values['milestones']['list']), 0, "Milestone list length should be equal to 0 because the Milestones feature is disabled.") + self.assertEqual(len(template_values['milestones']['created']), 0, "Milestone created length tasks should be equal to 0 because the Milestones feature is disabled.") + def test_project_update_panel(self): with Form(self.env['project.milestone'].with_context({'default_project_id': self.project_pigs.id})) as milestone_form: milestone_form.name = "Test 1" @@ -73,3 +99,16 @@ class TestProjectUpdate(TestProjectCommon): self.assertEqual(panel_data['milestones']['data'][0]['name'], "Test 2", "Sorting isn't correct") self.assertEqual(panel_data['milestones']['data'][1]['name'], "Test 1", "Sorting isn't correct") self.assertEqual(panel_data['milestones']['data'][2]['name'], "Test 3", "Sorting isn't correct") + + # Disable the "Milestones" feature in the project and check the "Milestones" section is not loaded for this project. + self.project_pigs.write({'allow_milestones': False}) + panel_data = self.project_pigs.get_panel_data() + self.assertNotIn('milestones', panel_data, 'Since the "Milestones" feature is disabled in this project, the "Milestones" section is not loaded.') + + # Disable globally the Milestones feature and check the Milestones section is not loaded. + self.project_pigs.write({'allow_milestones': True}) + self.env['res.config.settings'] \ + .create({'group_project_milestone': False}) \ + .execute() + panel_data = self.project_pigs.get_panel_data() + self.assertNotIn('milestones', panel_data, 'Since the "Milestones" feature is globally disabled, the "Milestones" section is not loaded.') diff --git a/addons/project/tests/test_project_update_ui.py b/addons/project/tests/test_project_update_ui.py index df60a3eb64a..b673dc431d0 100644 --- a/addons/project/tests/test_project_update_ui.py +++ b/addons/project/tests/test_project_update_ui.py @@ -4,6 +4,14 @@ from odoo.tests import HttpCase, tagged @tagged('post_install', '-at_install') class TestProjectUpdateUi(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Enable the "Milestones" feature to be able to create milestones on this tour. + cls.env['res.config.settings'] \ + .create({'group_project_milestone': True}) \ + .execute() def test_01_project_tour(self): self.start_tour("/web", 'project_update_tour', login="admin") diff --git a/addons/project/views/project_milestone_views.xml b/addons/project/views/project_milestone_views.xml new file mode 100644 index 00000000000..2dc82dba5ea --- /dev/null +++ b/addons/project/views/project_milestone_views.xml @@ -0,0 +1,57 @@ + + + + + project.milestone.view.form + project.milestone + +
+ +
+ +
+ + + + + + + + +
+
+
+
+ + + project.milestone.view.tree + project.milestone + + + + + + + + -
+
+
+ +
+
+
+
@@ -609,7 +622,7 @@ - + @@ -801,6 +814,9 @@ +
+
+
+ +
+
+
+

Time Management

diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml index 6d4e7e42eae..2644398e9cc 100644 --- a/addons/sale_timesheet/data/sale_service_demo.xml +++ b/addons/sale_timesheet/data/sale_service_demo.xml @@ -258,6 +258,11 @@ + + Preparation and Delivery Phase + + + Cabinets @@ -291,6 +296,7 @@ Kitchen Assembly + @@ -814,6 +820,7 @@ + @@ -824,6 +831,7 @@ + @@ -835,6 +843,7 @@ + diff --git a/addons/sale_timesheet/tests/test_sale_timesheet_ui.py b/addons/sale_timesheet/tests/test_sale_timesheet_ui.py index 716008cac30..c1a0a11fae7 100644 --- a/addons/sale_timesheet/tests/test_sale_timesheet_ui.py +++ b/addons/sale_timesheet/tests/test_sale_timesheet_ui.py @@ -29,5 +29,10 @@ class TestUi(HttpCase): 'service_tracking': 'no', }) + # Enable the "Milestones" feature to be able to create milestones on this tour. + cls.env['res.config.settings'] \ + .create({'group_project_milestone': True}) \ + .execute() + def test_ui(self): self.start_tour('/web', 'sale_timesheet_tour', login='admin', timeout=100)