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 0aa3d5970b9..a74bd330c4f 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',
@@ -49,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/data/project_demo.xml b/addons/project/data/project_demo.xml
index 6e03a5fa54d..f8b0a6ddb0a 100644
--- a/addons/project/data/project_demo.xml
+++ b/addons/project/data/project_demo.xml
@@ -7,6 +7,15 @@
+
+
+
+
+
+
+
+
+
Bug
@@ -204,6 +213,27 @@
+
+
+
+
+ First Phase
+
+
+
+
+
+
+ Second Phase
+
+
+
+
+
+ Final Phase
+
+
+
20
@@ -214,6 +244,7 @@
7
+
@@ -224,6 +255,7 @@
Lunch Room: kitchen
+ project.task
@@ -273,6 +305,7 @@
4
+ project.task
@@ -322,6 +355,7 @@
Modifications to the kitchen of the lunch room
+ project.task
@@ -371,6 +405,7 @@
1
+ project.task
@@ -413,6 +448,7 @@
11
+ project.task
@@ -452,6 +488,7 @@
9
+ project.task
@@ -485,6 +522,7 @@
blocked
+ project.task
@@ -533,6 +571,7 @@
3
+
@@ -581,6 +620,7 @@
+ 20
@@ -1277,26 +1317,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..1410b39ae0a 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
@@ -45,6 +45,9 @@ PROJECT_TASK_READABLE_FIELDS = {
'legend_done',
'user_ids',
'display_parent_task_button',
+ 'allow_milestones',
+ 'milestone_id',
+ 'has_late_and_unreached_milestone',
}
PROJECT_TASK_WRITABLE_FIELDS = {
@@ -323,6 +326,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 +367,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 +786,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 +1125,23 @@ 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."
+ )
+ has_late_and_unreached_milestone = fields.Boolean(
+ compute='_compute_has_late_and_unreached_milestone',
+ search='_search_has_late_and_unreached_milestone',
+ )
# Task Dependencies fields
allow_task_dependencies = fields.Boolean(related='project_id.allow_task_dependencies')
@@ -1918,7 +1940,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)
@@ -1994,6 +2016,38 @@ 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
+
+ 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'].sudo()._search([ # sudo is needed for the portal user in Project Sharing.
+ ('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
# ---------------------------------------------------
@@ -2261,6 +2315,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'),
@@ -2447,7 +2517,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/models/project_milestone.py b/addons/project/models/project_milestone.py
index a055236210e..a9ca91545b5 100644
--- a/addons/project/models/project_milestone.py
+++ b/addons/project/models/project_milestone.py
@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import _, api, fields, models
+from collections import defaultdict
+
+from odoo import api, fields, models
class ProjectMilestone(models.Model):
_name = 'project.milestone'
@@ -17,10 +19,13 @@ 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')
+ 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):
@@ -38,14 +43,58 @@ 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 _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})
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']
+ 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/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..93ebc05699a 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', group='base.group_portal,base.group_user')
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/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/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/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/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/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/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..5b0255da3ed
--- /dev/null
+++ b/addons/project/views/project_milestone_views.xml
@@ -0,0 +1,58 @@
+
+
+
+
+ project.milestone.view.form
+ project.milestone
+
+
+
+
+
+
+ project.milestone.view.tree
+ project.milestone
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/project/views/project_sharing_views.xml b/addons/project/views/project_sharing_views.xml
index a148174051b..f5d06ca7d48 100644
--- a/addons/project/views/project_sharing_views.xml
+++ b/addons/project/views/project_sharing_views.xml
@@ -44,6 +44,8 @@
+
+
@@ -56,6 +58,10 @@
+
+
+
+
@@ -119,10 +125,12 @@
+
+
@@ -179,6 +187,13 @@
+
+
+
+
@@ -240,6 +261,7 @@
+
@@ -253,6 +275,7 @@
domain="[('activity_ids.date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/>
+
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 @@
-(due ) - reached on
+(due
+ - ready to be marked as reached) - reached on))
+
+
+(ready to be marked as reached)
@@ -27,7 +31,7 @@