From a118555a40d7ddfb53689ce672fdb897a851e292 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 10 May 2022 10:30:46 +0200 Subject: [PATCH 1/8] [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) From d128a7683b121cec1f970f8f3e1a4343c9814b1f Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 10 May 2022 10:34:40 +0200 Subject: [PATCH 2/8] [IMP] project: add 'Late Milestone' filter in tasks Before this commit, the milestone is displayed in red in project update when its deadline is in the past. To display the tasks containing a late milestone, a filter need to be added. This commit adds this filter to display the tasks containing a milestone with a deadline in the past. This filter is also added in the `Tasks Analysis` and the `Burndown Chart` reports. task-2829542 --- addons/project/models/project.py | 37 +++++++++++++++++-- addons/project/report/project_report.py | 12 +++++- .../project/report/project_report_views.xml | 6 +++ .../project_task_burndown_chart_report.py | 8 ++++ ...oject_task_burndown_chart_report_views.xml | 3 ++ addons/project/views/project_views.xml | 4 +- 6 files changed, 65 insertions(+), 5 deletions(-) diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 37f7439d813..e3b09671eb0 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -11,7 +11,7 @@ from random import randint from odoo import api, Command, fields, models, tools, SUPERUSER_ID, _, _lt from odoo.addons.rating.models import rating_data from odoo.exceptions import UserError, ValidationError, AccessError -from odoo.osv.expression import OR, AND +from odoo.osv import expression from odoo.tools.misc import get_lang from .project_task_recurrence import DAYS, WEEKS @@ -1135,6 +1135,11 @@ class Task(models.Model): "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." ) + has_late_and_unreached_milestone = fields.Boolean( + compute='_compute_has_late_and_unreached_milestone', + search='_search_has_late_and_unreached_milestone', + groups='project.group_project_milestone', + ) # Task Dependencies fields allow_task_dependencies = fields.Boolean(related='project_id.allow_task_dependencies') @@ -1933,7 +1938,7 @@ class Task(models.Model): recurrence_domain = [] if recurrence_update == 'subsequent': for task in self: - recurrence_domain = OR([recurrence_domain, ['&', ('recurrence_id', '=', task.recurrence_id.id), ('create_date', '>=', task.create_date)]]) + recurrence_domain = expression.OR([recurrence_domain, ['&', ('recurrence_id', '=', task.recurrence_id.id), ('create_date', '>=', task.create_date)]]) else: recurrence_domain = [('recurrence_id', 'in', self.recurrence_id.ids)] tasks |= self.env['project.task'].search(recurrence_domain) @@ -2015,6 +2020,32 @@ class Task(models.Model): if task.project_id != task.milestone_id.project_id: task.milestone_id = False + def _compute_has_late_and_unreached_milestone(self): + if all(not task.allow_milestones for task in self): + self.has_late_and_unreached_milestone = False + return + late_milestones = self.env['project.milestone']._search([ + ('id', 'in', self.milestone_id.ids), + ('is_reached', '=', False), + ('deadline', '<', fields.Date.today()), + ]) + for task in self: + task.has_late_and_unreached_milestone = task.allow_milestones and task.milestone_id.id in late_milestones + + def _search_has_late_and_unreached_milestone(self, operator, value): + if operator not in ('=', '!=') or not isinstance(value, bool): + raise NotImplementedError(f'The search does not support the {operator} operator or {value} value.') + domain = [ + ('allow_milestones', '=', True), + ('milestone_id', '!=', False), + ('milestone_id.is_reached', '=', False), + ('milestone_id.deadline', '!=', False), ('milestone_id.deadline', '<', fields.Date.today()) + ] + if (operator == '!=' and value) or (operator == '=' and not value): + domain.insert(0, expression.NOT_OPERATOR) + domain = expression.distribute_not(domain) + return domain + # --------------------------------------------------- # Mail gateway # --------------------------------------------------- @@ -2468,7 +2499,7 @@ class ProjectTags(models.Model): ] def _get_project_tags_domain(self, domain, project_id): - return AND([ + return expression.AND([ domain, ['|', ('task_ids.project_id', '=', project_id), ('project_ids', 'in', project_id)] ]) diff --git a/addons/project/report/project_report.py b/addons/project/report/project_report.py index 13562dcf036..5f3fdc0c598 100644 --- a/addons/project/report/project_report.py +++ b/addons/project/report/project_report.py @@ -56,6 +56,9 @@ class ReportProjectTaskUser(models.Model): personal_stage_type_ids = fields.Many2many('project.task.type', relation='project_task_user_rel', column1='task_id', column2='stage_id', string="Personal Stage", readonly=True) + milestone_id = fields.Many2one('project.milestone', readonly=True) + milestone_reached = fields.Boolean('Is Milestone Reached', readonly=True) + milestone_deadline = fields.Date('Milestone Deadline', readonly=True) def _compute_rating_last_text(self): for task_analysis in self: @@ -84,6 +87,9 @@ class ReportProjectTaskUser(models.Model): t.stage_id as stage_id, t.is_closed as is_closed, t.kanban_state as state, + t.milestone_id, + pm.is_reached as milestone_reached, + pm.deadline as milestone_deadline, NULLIF(t.rating_last_value, 0) as rating_last_value, AVG(rt.rating) as rating_avg, t.working_days_close as working_days_close, @@ -115,7 +121,10 @@ class ReportProjectTaskUser(models.Model): t.working_days_close, t.working_days_open, t.working_hours_open, - t.working_hours_close + t.working_hours_close, + t.milestone_id, + pm.is_reached, + pm.deadline """ def _from(self): @@ -125,6 +134,7 @@ class ReportProjectTaskUser(models.Model): AND rt.res_model = 'project.task' AND rt.consumed = True AND rt.rating >= {RATING_LIMIT_MIN} + LEFT JOIN project_milestone pm ON pm.id = t.milestone_id """ def _where(self): diff --git a/addons/project/report/project_report_views.xml b/addons/project/report/project_report_views.xml index ae878335502..de65aa950ff 100644 --- a/addons/project/report/project_report_views.xml +++ b/addons/project/report/project_report_views.xml @@ -47,6 +47,7 @@ + @@ -69,6 +70,10 @@ + @@ -89,6 +94,7 @@ + diff --git a/addons/project/report/project_task_burndown_chart_report.py b/addons/project/report/project_task_burndown_chart_report.py index 8d5d99c7418..70b5ed47c20 100644 --- a/addons/project/report/project_task_burndown_chart_report.py +++ b/addons/project/report/project_task_burndown_chart_report.py @@ -17,6 +17,7 @@ class ReportProjectTaskBurndownChart(models.Model): task_id = fields.Many2one('project.task', readonly=True) project_id = fields.Many2one('project.project', readonly=True) display_project_id = fields.Many2one('project.project', readonly=True) + milestone_id = fields.Many2one('project.milestone', readonly=True) stage_id = fields.Many2one('project.task.type', readonly=True) is_closed = fields.Boolean("Closing Stage", readonly=True, help="Folded in Kanban stages are closing stages.") date = fields.Datetime('Date', readonly=True) @@ -58,6 +59,7 @@ WITH all_moves_stage_task AS ( SELECT pt.project_id, pt.id as task_id, pt.display_project_id, + pt.milestone_id, COALESCE(LAG(mm.date) OVER (PARTITION BY mm.res_id ORDER BY mm.id), pt.create_date) as date_begin, mm.date as date_end, mtv.old_value_integer as stage_id, @@ -83,6 +85,7 @@ WITH all_moves_stage_task AS ( SELECT pt.project_id, pt.id as task_id, pt.display_project_id, + pt.milestone_id, COALESCE(md.date, pt.create_date) as date_begin, (CURRENT_DATE + interval '1 month')::date as date_end, pt.stage_id, @@ -108,6 +111,7 @@ SELECT (task_id*10^7 + 10^6 + to_char(d, 'YYMMDD')::integer)::bigint as id, project_id, task_id, display_project_id, + milestone_id, stage_id, is_closed, d as date, @@ -124,6 +128,7 @@ SELECT (task_id*10^7 + 2*10^6 + to_char(d, 'YYMMDD')::integer)::bigint as id, project_id, task_id, display_project_id, + milestone_id, stage_id, is_closed, date_trunc('week', d) as date, @@ -142,6 +147,7 @@ SELECT (task_id*10^7 + 3*10^6 + to_char(d, 'YYMMDD')::integer)::bigint as id, project_id, task_id, display_project_id, + milestone_id, stage_id, is_closed, date_trunc('month', d) as date, @@ -160,6 +166,7 @@ SELECT (task_id*10^7 + 4*10^6 + to_char(d, 'YYMMDD')::integer)::bigint as id, project_id, task_id, display_project_id, + milestone_id, stage_id, is_closed, date_trunc('quarter', d) as date, @@ -178,6 +185,7 @@ SELECT (task_id*10^7 + 5*10^6 + to_char(d, 'YYMMDD')::integer)::bigint as id, project_id, task_id, display_project_id, + milestone_id, stage_id, is_closed, date_trunc('year', d) as date, diff --git a/addons/project/report/project_task_burndown_chart_report_views.xml b/addons/project/report/project_task_burndown_chart_report_views.xml index 48548bb7de8..bcbf99b48dc 100644 --- a/addons/project/report/project_task_burndown_chart_report_views.xml +++ b/addons/project/report/project_task_burndown_chart_report_views.xml @@ -7,6 +7,7 @@ + @@ -17,10 +18,12 @@ + + diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 8f4872c0d2a..eb8dde03ecd 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -36,6 +36,7 @@ + @@ -1321,6 +1322,7 @@ + @@ -1335,7 +1337,7 @@
- +
From c1feb0deafd68d744d05bd0b9603fd6ceb94f59c Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 10 May 2022 10:39:45 +0200 Subject: [PATCH 3/8] [IMP] sale_project: get the SOL from the milestone to task if no SOL found Before this commit, the compute of `sale_line_id` field searches a SOL to set to each task containing in `self` as default value for `sale_line_id`. If no SOL is found in the parent task or the project set into the task then no SOL will set. Since a milestone can be set to a task and a milestone can have a SOL set in its model, the task could have the one of the milestone if no SOL is found. This commit sets the SOL from the milestone to the task if no SOL is found in the project or the parent task related to that task. task-2829542 --- addons/sale_project/models/project.py | 4 ++-- .../tests/test_so_line_milestones.py | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py index 9b7113a381d..3b12797eb9e 100644 --- a/addons/sale_project/models/project.py +++ b/addons/sale_project/models/project.py @@ -496,12 +496,12 @@ class ProjectTask(models.Model): task.partner_id = sale_order_id.partner_id task.sale_order_id = sale_order_id - @api.depends('commercial_partner_id', 'sale_line_id.order_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id') + @api.depends('commercial_partner_id', 'sale_line_id.order_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'milestone_id.sale_line_id') def _compute_sale_line(self): for task in self: if not task.sale_line_id: # if the display_project_id is set then it means the task is classic task or a subtask with another project than its parent. - task.sale_line_id = task.display_project_id.sale_line_id or task.parent_id.sale_line_id or task.project_id.sale_line_id + task.sale_line_id = task.display_project_id.sale_line_id or task.parent_id.sale_line_id or task.project_id.sale_line_id or task.milestone_id.sale_line_id # check sale_line_id and customer are coherent if task.sale_line_id.order_partner_id.commercial_partner_id != task.partner_id.commercial_partner_id: task.sale_line_id = False diff --git a/addons/sale_project/tests/test_so_line_milestones.py b/addons/sale_project/tests/test_so_line_milestones.py index 8b33b666592..c804e0215e5 100644 --- a/addons/sale_project/tests/test_so_line_milestones.py +++ b/addons/sale_project/tests/test_so_line_milestones.py @@ -9,8 +9,8 @@ from odoo.tests.common import tagged class TestSoLineMilestones(TestSaleCommon): @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) uom_hour = cls.env.ref('uom.product_uom_hour') @@ -107,3 +107,17 @@ class TestSoLineMilestones(TestSaleCommon): self.milestone1.unlink() self.assertEqual(self.sol1.qty_delivered, 0.0, "Delivered quantity should update when a milestone is removed") + + def test_compute_sale_line_in_task(self): + task = self.env['project.task'].create({ + 'name': 'Test Task', + 'project_id': self.project.id, + }) + self.assertEqual(task.sale_line_id, self.sol1, 'The task should have the one of the project linked') + self.project.sale_line_id = False + task.sale_line_id = False + self.assertFalse(task.sale_line_id) + task.write({'milestone_id': self.milestone1.id}) + self.assertEqual(task.sale_line_id, self.milestone1.sale_line_id, 'The task should have the SOL from the milestone.') + self.project.sale_line_id = self.sol2 + self.assertEqual(task.sale_line_id, self.sol1, 'The task should keep the SOL linked to the milestone.') From 41b72e0398dc23ad7495d4d1082f05e27421096e Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 10 May 2022 10:43:58 +0200 Subject: [PATCH 4/8] [IMP] project: display in green milestone if all tasks linked are done Before this commit, when all tasks linked to the milestone are done, it means all tasks are in a closed stage. Then, the user has no visual feedback to know if all tasks linked to the milestone are done to be sure he can mark the milestone has reached without missing a task to complete. This commit checks if all tasks of unreached milestones shown in the project update are done or not. If it is the case the milestone is greened to show to the user that milestone can be marked as reached. task-2829542 --- addons/project/models/project_milestone.py | 31 ++++++++++++++++++- .../src/js/right_panel/project_utils.js | 20 ++++++++---- .../views/project_update_templates.xml | 8 +++-- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/addons/project/models/project_milestone.py b/addons/project/models/project_milestone.py index b714ce7cf50..a9ca91545b5 100644 --- a/addons/project/models/project_milestone.py +++ b/addons/project/models/project_milestone.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict + from odoo import api, fields, models class ProjectMilestone(models.Model): @@ -23,6 +25,7 @@ class ProjectMilestone(models.Model): 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') + can_be_marked_as_done = fields.Boolean(compute='_compute_can_be_marked_as_done', groups='project.group_project_milestone') @api.depends('is_reached') def _compute_reached_date(self): @@ -47,6 +50,32 @@ class ProjectMilestone(models.Model): for milestone in self: milestone.task_count = task_count_per_milestone.get(milestone.id, 0) + def _compute_can_be_marked_as_done(self): + if not any(self._ids): + for milestone in self: + milestone.can_be_marked_as_done = not milestone.is_reached and all(milestone.task_ids.is_closed) + return + unreached_milestones = self.filtered(lambda milestone: not milestone.is_reached) + (self - unreached_milestones).can_be_marked_as_done = False + if unreached_milestones: + task_read_group = self.env['project.task']._read_group( + [('milestone_id', 'in', unreached_milestones.ids)], + ['milestone_id', 'is_closed', 'task_count:count(id)'], + ['milestone_id', 'is_closed'], + lazy=False, + ) + task_count_per_milestones = defaultdict(lambda: (0, 0)) + for res in task_read_group: + opened_task_count, closed_task_count = task_count_per_milestones[res['milestone_id'][0]] + if res['is_closed']: + closed_task_count += res['task_count'] + else: + opened_task_count += res['task_count'] + task_count_per_milestones[res['milestone_id'][0]] = opened_task_count, closed_task_count + for milestone in unreached_milestones: + opened_task_count, closed_task_count = task_count_per_milestones[milestone.id] + milestone.can_be_marked_as_done = closed_task_count > 0 and not opened_task_count + def toggle_is_reached(self, is_reached): self.ensure_one() self.update({'is_reached': is_reached}) @@ -65,7 +94,7 @@ class ProjectMilestone(models.Model): @api.model def _get_fields_to_export(self): - return ['id', 'name', 'deadline', 'is_reached', 'reached_date', 'is_deadline_exceeded', 'is_deadline_future'] + return ['id', 'name', 'deadline', 'is_reached', 'reached_date', 'is_deadline_exceeded', 'is_deadline_future', 'can_be_marked_as_done'] def _get_data(self): self.ensure_one() diff --git a/addons/project/static/src/js/right_panel/project_utils.js b/addons/project/static/src/js/right_panel/project_utils.js index aefbd3dda7e..9dc38da5f00 100644 --- a/addons/project/static/src/js/right_panel/project_utils.js +++ b/addons/project/static/src/js/right_panel/project_utils.js @@ -67,8 +67,8 @@ export class OpenMilestone extends MilestoneComponent { this.rpc = useService("rpc"); this.milestone = useState(this.props.milestone); this.state = useState({ - colorClass: this.milestone.is_deadline_exceeded ? "o_milestone_danger" : "", - checkboxIcon: this.milestone.is_reached ? "fa-check-square-o" : "fa-square-o", + colorClass: this._getColorClass(), + checkboxIcon: this._getCheckBoxIcon(), }); onWillUpdateProps(this.onWillUpdateProps); } @@ -77,11 +77,19 @@ export class OpenMilestone extends MilestoneComponent { return fieldUtils.format.date(moment(this.milestone.deadline)); } + _getColorClass() { + return this.milestone.is_deadline_exceeded && !this.milestone.can_be_marked_as_done ? "o_milestone_danger" : this.milestone.can_be_marked_as_done ? "o_color_green" : ""; + } + + _getCheckBoxIcon() { + return this.milestone.is_reached ? "fa-check-square-o" : "fa-square-o"; + } + onWillUpdateProps(nextProps) { if (nextProps.milestone) { this.milestone = nextProps.milestone; - this.state.colorClass = this.milestone.is_deadline_exceeded ? "o_milestone_danger" : ""; - this.state.checkboxIcon = this.milestone.is_reached ? "fa-check-square-o" : "fa-square-o"; + this.state.colorClass = this._getColorClass(); + this.state.checkboxIcon = this._getCheckBoxIcon(); } if (nextProps.context) { this.contextValue = nextProps.context; @@ -120,8 +128,8 @@ export class OpenMilestone extends MilestoneComponent { method: 'toggle_is_reached', args: [[this.milestone.id], !this.milestone.is_reached], }); - this.state.colorClass = this.milestone.is_deadline_exceeded ? "o_milestone_danger" : ""; - this.state.checkboxIcon = this.milestone.is_reached ? "fa-check-square-o" : "fa-square-o"; + this.state.colorClass = this._getColorClass(); + this.state.checkboxIcon = this._getCheckBoxIcon(); this.write_mutex = false; } } diff --git a/addons/project/views/project_update_templates.xml b/addons/project/views/project_update_templates.xml index c8c297aa67b..b4fde9306a2 100644 --- a/addons/project/views/project_update_templates.xml +++ b/addons/project/views/project_update_templates.xml @@ -2,9 +2,13 @@ @@ -27,7 +31,7 @@
  • - +
  • From e058472d215074f6a3b6d003002527e2210d4e1f Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 10 May 2022 10:48:08 +0200 Subject: [PATCH 5/8] [IMP] project: display wizard to reach milestone if all tasks linked are done Before this commit, when the user changes the stage of a task linked to a milestone, he cannot know if after that task, there is another one before reaching the milestone related. This commit displays a wizard to allow the current user to directly mark as reached the milestone when the user changes the stage of a task linked to a milestone to a closed stage and all tasks linked to that milestone are done too. However, if the milestone is already reached, no wizard will be displayed. task-2829542 --- addons/project/__manifest__.py | 1 + addons/project/models/project.py | 16 +++++++ addons/project/security/ir.model.access.csv | 2 + addons/project/static/src/js/project_form.js | 42 ++++++++++++++++- .../project/static/src/js/project_kanban.js | 24 +++++++--- .../project/views/project_milestone_views.xml | 3 +- addons/project/wizard/__init__.py | 1 + .../wizard/project_milestone_reach_wizard.py | 46 +++++++++++++++++++ .../wizard/project_milestone_reach_wizard.xml | 30 ++++++++++++ 9 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 addons/project/wizard/project_milestone_reach_wizard.py create mode 100644 addons/project/wizard/project_milestone_reach_wizard.xml diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 7bac6b33b7a..9ed3595925b 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -50,6 +50,7 @@ 'data/project_data.xml', 'wizard/project_delete_wizard_views.xml', 'wizard/project_task_type_delete_views.xml', + 'wizard/project_milestone_reach_wizard.xml', ], 'demo': [ 'data/mail_template_demo.xml', diff --git a/addons/project/models/project.py b/addons/project/models/project.py index e3b09671eb0..82c5ec9b59b 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -2313,6 +2313,22 @@ class Task(models.Model): return children return children + children._get_all_subtasks(depth - 1) + def get_milestone_to_mark_as_reached_action(self): + """ Return an action if the milestone can be marked as reached otherwise return False """ + milestones = self.milestone_id.filtered('can_be_marked_as_done') + if milestones: + wizard = self.env['project.milestone.reach.wizard'].create({'line_ids': [Command.create({'milestone_id': m.id}) for m in milestones]}) + return { + 'name': _('Mark milestone as reached'), + 'view_mode': 'form', + 'res_model': 'project.milestone.reach.wizard', + 'views': [(self.env.ref('project.project_milestone_reach_wizard_view_form').id, 'form')], + 'type': 'ir.actions.act_window', + 'res_id': wizard.id, + 'target': 'new', + } + return False + def action_open_parent_task(self): return { 'name': _('Parent Task'), diff --git a/addons/project/security/ir.model.access.csv b/addons/project/security/ir.model.access.csv index c4794bbbada..31ad16d969d 100644 --- a/addons/project/security/ir.model.access.csv +++ b/addons/project/security/ir.model.access.csv @@ -44,3 +44,5 @@ access_project_collaborator_portal,project.collaborator.portal,model_project_col access_project_share_manager,project.share.wizard.manager,model_project_share_wizard,project.group_project_manager,1,1,1,0 access_project_personal_stage,project.personal.stage.user,model_project_task_stage_personal,base.group_user,1,1,1,1 access_project_report_wizard_manager,project.report.wizard.project.manager,model_project_report_wizard,project.group_project_manager,1,1,1,0 +access_project_milestone_reach_wizard_project_user,project.milestone.reach.wizard.project.user,model_project_milestone_reach_wizard,project.group_project_user,1,1,1,0 +access_project_milestone_reach_line_wizard_project_user,project.milestone.reach.line.wizard.project.user,model_project_milestone_reach_line_wizard,project.group_project_user,1,1,1,0 diff --git a/addons/project/static/src/js/project_form.js b/addons/project/static/src/js/project_form.js index 7cda818015b..15905a9cd77 100644 --- a/addons/project/static/src/js/project_form.js +++ b/addons/project/static/src/js/project_form.js @@ -109,7 +109,47 @@ const ProjectFormController = FormController.extend({ }; dialog.open(); - } + }, + + async _applyChanges(dataPointID, changes, event) { + const result = await this._super(...arguments); + if (event.data.force_save && 'stage_id' in changes) { + this._getMilestoneReachWizardAction([parseInt(event.target.res_id)]); + } + return result; + }, + async _getMilestoneReachWizardAction(recordIds) { + const action = await this._rpc({ + model: 'project.task', + method: 'get_milestone_to_mark_as_reached_action', + args: [recordIds], + }); + if (action) { + this.trigger_up('do-action', { + action, + }); + } + }, + async _saveRecord(recordID, options) { + const task = this.model.get(recordID || this.handle); + const result = await this._super(...arguments); + let tasksX2ManyFields = result.filter(fieldName => ['child_ids', 'depend_on_ids'].includes(fieldName)); + if (tasksX2ManyFields.length) { + const taskResIds = []; + for (const taskX2ManyFieldName of tasksX2ManyFields) { + for (const subtask of task.data[taskX2ManyFieldName].data) { + const changes = this.model.localData[subtask.id]._changes; + if (changes && changes.hasOwnProperty('stage_id') && !!subtask.data.milestone_id) { + taskResIds.push(subtask.res_id); + } + } + } + if (taskResIds.length) { + this._getMilestoneReachWizardAction(taskResIds); + } + } + return result; + }, }); const FormDescriptionExpanderRenderer = FormRenderer.extend(Object.assign({}, FormHtmlFieldExpanderMixin, { diff --git a/addons/project/static/src/js/project_kanban.js b/addons/project/static/src/js/project_kanban.js index 330d17d44c1..d1d9783cb2b 100644 --- a/addons/project/static/src/js/project_kanban.js +++ b/addons/project/static/src/js/project_kanban.js @@ -208,7 +208,7 @@ const ProjectTaskKanbanModel = KanbanModel.extend({ * @override * @private */ - moveRecord: function (recordID, groupID, parentID) { + async moveRecord(recordID, groupID, parentID) { const self = this; const parent = this.localData[parentID]; const new_group = this.localData[groupID]; @@ -263,12 +263,22 @@ const ProjectTaskKanbanModel = KanbanModel.extend({ new_group.res_ids.push(resID); new_group.count++; - return this.notifyChanges(recordID, changes).then(function () { - return self.save(recordID); - }).then(function () { - record.parentID = new_group.id; - return [old_group.id, new_group.id]; - }); + await this.notifyChanges(recordID, changes); + await self.save(recordID); + if (groupedFieldName === 'stage_id') { + const action = await this._rpc({ + model: 'project.task', + method: 'get_milestone_to_mark_as_reached_action', + args: [[resID]], + }); + if (action) { + this.trigger_up('do-action', { + action, + }); + } + } + record.parentID = new_group.id; + return [old_group.id, new_group.id]; }, /** diff --git a/addons/project/views/project_milestone_views.xml b/addons/project/views/project_milestone_views.xml index 2dc82dba5ea..5b0255da3ed 100644 --- a/addons/project/views/project_milestone_views.xml +++ b/addons/project/views/project_milestone_views.xml @@ -36,12 +36,13 @@ project.milestone.view.tree project.milestone - + +