[MERGE] project: link milestones in tasks

Purpose
=======

The goal of this PR is to create a link between milestones and tasks. Indeed, milestones usually contain a set of tasks. Once all of these tasks are done, the milestone can be considered as reached. This link would help users organize their projects more efficiently and have a better overview of what needs to be done when.

## Implementation details

- A new many2one field is added in the `project.task` called `milestone_id` to link a milestone to tasks.
- Add `Milestones` as global setting and project setting. It means the user will can globally enable the feature and choose in which project he wants to use the feature.
- In the form view of milestone, a stat button is added to display the tasks linked to current milestone.
- Add `Late Milestone` filter in tasks, when this filter is applied the user will see the tasks linked to a milestone with a deadline in the past. This filter is also added in the Burndown chart and Tasks analysis report.
- When the user set a milestone on a task and no SOL is found in the project and/or parent task related then set the SOL of the milestone as a default SOL for the task.
- Display in green the milestone in project update view if all tasks linked to that milestone are in a closed stage.
- When a user changes a stage of a task to a closed stage, a wizard will be displayed if the task is linked to a milestone and all others tasks are in a closed stage too. This wizard will allow the user to reach the milestone since all tasks linked to that milestone are considered as finished.
- Remove `delivered_milestone` option in the `invoice_policy` selection field in `product.template` model when the Milestones feature is globally disabled. If some products are milestone one than they will be converted into a manual service ones to allow the user to manually set the quantity delivered for those products.
- Display the milestone field in task in Project Sharing feature.

task-2829542

closes odoo/odoo#90972

Signed-off-by: Laurent Stukkens (ltu) <ltu@odoo.com>
This commit is contained in:
Odoo's Mergebot
2022-06-01 18:34:46 +02:00
committed by GitHub
43 changed files with 964 additions and 233 deletions
@@ -3524,7 +3524,7 @@
</record>
<!-- Projects -->
<record id="project.project_milestone_2" model="project.milestone">
<record id="project.project_1_milestone_2" model="project.milestone">
<field name="deadline" eval="(DateTime.now() + relativedelta(years=1, months=3)).strftime('%Y-%m-15')"/>
</record>
+2
View File
@@ -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',
+40 -20
View File
@@ -7,6 +7,15 @@
<field name="groups_id" eval="[Command.link(ref('group_project_user'))]"/>
</record>
<!-- Groups : Add milestones feature by default -->
<record id="base.group_user" model="res.groups">
<field name="implied_ids" eval="[Command.link(ref('group_project_milestone'))]"/>
</record>
<!-- The feature is also enabled to portal user because the feature is displayed in the Project Sharing feature -->
<record id="base.group_portal" model="res.groups">
<field name="implied_ids" eval="[Command.link(ref('group_project_milestone'))]"/>
</record>
<!-- Categories -->
<record id="project_tags_00" model="project.tags">
<field name="name">Bug</field>
@@ -204,6 +213,27 @@
<field name="user_id" ref="base.user_demo"/>
</record>
<!-- Project 1 Milestones -->
<record id="project_1_milestone_1" model="project.milestone">
<field name="is_reached" eval="True"/>
<field name="deadline" eval="time.strftime('%Y-%m-10')"/>
<field name="name">First Phase</field>
<field name="reached_date" eval="time.strftime('%Y-%m-10')"/>
<field name="project_id" ref="project.project_project_1"/>
</record>
<record id="project_1_milestone_2" model="project.milestone">
<field name="is_reached" eval="False"/>
<field name="deadline" eval="(DateTime.now() + relativedelta(years=1)).strftime('%Y-%m-15')"/>
<field name="name">Second Phase</field>
<field name="project_id" ref="project.project_project_1"/>
</record>
<record id="project_1_milestone_3" model="project.milestone">
<field name="is_reached" eval="False"/>
<field name="deadline" eval="(DateTime.now() + relativedelta(years=2)).strftime('%Y-%m-%d')"/>
<field name="name">Final Phase</field>
<field name="project_id" ref="project.project_project_1"/>
</record>
<!-- Project 1 Tasks -->
<record id="project_1_task_1" model="project.task">
<field name="sequence">20</field>
@@ -214,6 +244,7 @@
<field name="stage_id" ref="project_stage_2"/>
<field name="color">7</field>
<field name="create_date" eval="DateTime.now() - relativedelta(months=5)"/>
<field name="milestone_id" ref="project.project_1_milestone_1" />
</record>
<record id="project_1_task_2" model="project.task">
@@ -224,6 +255,7 @@
<field name="name">Lunch Room: kitchen</field>
<field name="stage_id" ref="project_stage_2"/>
<field name="create_date" eval="DateTime.now() - relativedelta(months=5)"/>
<field name="milestone_id" ref="project.project_1_milestone_1" />
</record>
<record id="project_1_task_2_mail_message_1" model="mail.message">
<field name="model">project.task</field>
@@ -273,6 +305,7 @@
<field name="stage_id" ref="project_stage_2"/>
<field name="color">4</field>
<field name="create_date" eval="DateTime.now() - relativedelta(months=5)"/>
<field name="milestone_id" ref="project.project_1_milestone_2" />
</record>
<record id="project_1_task_3_mail_message_1" model="mail.message">
<field name="model">project.task</field>
@@ -322,6 +355,7 @@
<field name="description">Modifications to the kitchen of the lunch room</field>
<field name="tag_ids" eval="[Command.set([ref('project_tags_00')])]"/>
<field name="create_date" eval="DateTime.now() - relativedelta(months=5)"/>
<field name="milestone_id" ref="project.project_1_milestone_2" />
</record>
<record id="project_1_task_4_mail_message_1" model="mail.message">
<field name="model">project.task</field>
@@ -371,6 +405,7 @@
<field name="create_date" eval="DateTime.now() - relativedelta(months=5)"/>
<field name="date_deadline" eval="DateTime.now() + relativedelta(days=2)"/>
<field name="color">1</field>
<field name="milestone_id" ref="project.project_1_milestone_3" />
</record>
<record id="project_1_task_5_mail_message_1" model="mail.message">
<field name="model">project.task</field>
@@ -413,6 +448,7 @@
<field name="tag_ids" eval="[Command.set([ref('project_tags_01')])]"/>
<field name="create_date" eval="DateTime.now() - relativedelta(months=5)"/>
<field name="color">11</field>
<field name="milestone_id" ref="project.project_1_milestone_3" />
</record>
<record id="project_1_task_6_mail_message_1" model="mail.message">
<field name="model">project.task</field>
@@ -452,6 +488,7 @@
<field name="create_date" eval="DateTime.now() - relativedelta(months=5)"/>
<field name="date_deadline" eval="DateTime.now() + relativedelta(days=6)"/>
<field name="color">9</field>
<field name="milestone_id" ref="project.project_1_milestone_3" />
</record>
<record id="project_1_task_7_mail_message_1" model="mail.message">
<field name="model">project.task</field>
@@ -485,6 +522,7 @@
<field name="kanban_state">blocked</field>
<field name="tag_ids" eval="[Command.set([ref('project_tags_01')])]"/>
<field name="create_date" eval="DateTime.now() - relativedelta(months=5)"/>
<field name="milestone_id" ref="project.project_1_milestone_3" />
</record>
<record id="project_1_task_8_mail_message_1" model="mail.message">
<field name="model">project.task</field>
@@ -533,6 +571,7 @@
<field name="stage_id" ref="project_stage_1"/>
<field name="color">3</field>
<field name="create_date" eval="DateTime.now() - relativedelta(months=5)"/>
<field name="milestone_id" ref="project.project_1_milestone_3" />
</record>
<record id="project_1_task_9_activity_1" model="mail.activity">
<field name="res_id" ref="project_1_task_9"/>
@@ -581,6 +620,7 @@
<field name="recurring_task" eval="True"/>
<field name="recurrence_id" ref="project_task_recurrence_1"/>
<field name="create_date" eval="DateTime.now() + relativedelta(weeks=-2)"/>
<field name="milestone_id" ref="project.project_1_milestone_3" />
</record>
<record id="project_1_task_11" model="project.task">
<field name="sequence">20</field>
@@ -1277,26 +1317,6 @@ Send it ASAP, its urgent.</field>
<field name="rating_template_id" ref="rating_project_request_email_template"/>
</record>
<record id="project_milestone_1" model="project.milestone">
<field name="is_reached" eval="True"/>
<field name="deadline" eval="time.strftime('%Y-%m-10')"/>
<field name="name">First Phase</field>
<field name="reached_date" eval="time.strftime('%Y-%m-10')"/>
<field name="project_id" ref="project.project_project_1"/>
</record>
<record id="project_milestone_2" model="project.milestone">
<field name="is_reached" eval="False"/>
<field name="deadline" eval="(DateTime.now() + relativedelta(years=1)).strftime('%Y-%m-15')"/>
<field name="name">Second Phase</field>
<field name="project_id" ref="project.project_project_1"/>
</record>
<record id="project_milestone_3" model="project.milestone">
<field name="is_reached" eval="False"/>
<field name="deadline" eval="(DateTime.now() + relativedelta(years=2)).strftime('%Y-%m-%d')"/>
<field name="name">Final Phase</field>
<field name="project_id" ref="project.project_project_1"/>
</record>
<record id="project_update_1" model="project.update" context="{'default_project_id': ref('project.project_project_1')}">
<field name="name">Review of the situation</field>
<field name="user_id" eval="ref('base.user_demo')"/>
+75 -5
View File
@@ -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)]
])
+51 -2
View File
@@ -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()
+8
View File
@@ -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()
@@ -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()}
+11 -1
View File
@@ -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):
@@ -47,6 +47,7 @@
<field name="tag_ids"/>
<field name="user_ids" context="{'active_test': False}"/>
<field name="project_id"/>
<field name="milestone_id" groups="project.group_project_milestone"/>
<field name="stage_id"/>
<field name="partner_id" operator="child_of"/>
<field name="active"/>
@@ -69,6 +70,10 @@
<filter string="Open" name="open_tasks" domain="[('is_closed', '=', False)]"/>
<filter string="Closed" name="closed_tasks" domain="[('is_closed', '=', True)]"/>
<separator/>
<filter string="Late Milestones" name="late_milestone"
domain="[('project_id.allow_milestones', '=', True), ('is_closed', '=', False), ('milestone_reached', '=', False), ('milestone_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
groups="project.group_project_milestone"
/>
<filter string="Late Tasks" name="late" domain="[('date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"/>
<filter name="rating_satisfied" string="Satisfied" domain="[('rating_avg', '&gt;=', 3.66)]" groups="project.group_project_rating"/>
<filter name="rating_okay" string="Okay" domain="[('rating_avg', '&lt;', 3.66), ('rating_avg', '&gt;=', 2.33)]" groups="project.group_project_rating"/>
@@ -89,6 +94,7 @@
<filter string="Personal Stage" name="personal_stage" context="{'group_by': 'personal_stage_type_ids'}"/>
<filter string="Assignees" name="User" context="{'group_by': 'user_ids'}"/>
<filter string="Project" name="project" context="{'group_by': 'project_id'}"/>
<filter string="Milestone" name="milestone" context="{'group_by': 'milestone_id'}" groups="project.group_project_milestone"/>
<filter string="Customer" name="Customer" context="{'group_by': 'partner_id'}"/>
<filter string="Kanban State" name="kanban_state" context="{'group_by': 'state'}"/>
<filter string="Deadline" name="deadline" context="{'group_by': 'date_deadline'}"/>
@@ -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,
@@ -7,6 +7,7 @@
<field name="arch" type="xml">
<search string="Burndown Chart">
<field name="project_id" />
<field name="milestone_id" groups="project.group_project_milestone"/>
<field name="date_assign"/>
<field name="date_deadline"/>
<field name="partner_id" filter_domain="[('partner_id', 'child_of', self)]"/>
@@ -17,10 +18,12 @@
<filter name="filter_date_assign" date="date_assign"/>
<filter string="Last Month" invisible="1" name="last_month" domain="[('date','&gt;=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
<filter string="Open tasks" name="open_tasks" domain="[('is_closed', '=', False)]"/>
<filter string="Late Milestones" name="late_milestone" domain="[('is_closed', '=', False), ('task_id.has_late_and_unreached_milestone', '=', True)]" groups="project.group_project_milestone"/>
<group expand="0" string="Group By">
<filter string="Date" name="date" context="{'group_by': 'date'}" />
<filter string="Stage" name="stage" context="{'group_by': 'stage_id'}" />
<filter string="Project" name="project" context="{'group_by': 'project_id'}" />
<filter string="Milestone" name="milestone" context="{'group_by': 'milestone_id'}" groups="project.group_project_milestone" />
</group>
</search>
</field>
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
44 access_project_share_manager project.share.wizard.manager model_project_share_wizard project.group_project_manager 1 1 1 0
45 access_project_personal_stage project.personal.stage.user model_project_task_stage_personal base.group_user 1 1 1 1
46 access_project_report_wizard_manager project.report.wizard.project.manager model_project_report_wizard project.group_project_manager 1 1 1 0
47 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
48 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
@@ -44,6 +44,11 @@
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="group_project_milestone" model="res.groups">
<field name="name">Use Milestones</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<data noupdate="1">
<record id="base.default_user" model="res.users">
<field name="groups_id" eval="[(4,ref('project.group_project_manager'))]"/>
+41 -1
View File
@@ -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, {
+17 -7
View File
@@ -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];
},
/**
@@ -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;
}
}
@@ -197,8 +197,9 @@
</t>
<t t-name="project.MilestoneSection" owl="1">
<t t-set="show_milestones" t-value="state.data.hasOwnProperty('milestones')"/>
<t t-set="milestones" t-value="state.data.milestones"/>
<div class="o_rightpanel_section" name="milestone">
<div class="o_rightpanel_section" name="milestone" t-if="show_milestones">
<div class="o_rightpanel_header">
<div class="o_rightpanel_title o_cursor_default">
Milestones
+1
View File
@@ -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
@@ -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')
@@ -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.')
@@ -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")
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_milestone_view_form" model="ir.ui.view">
<field name="name">project.milestone.view.form</field>
<field name="model">project.milestone</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="%(project.action_view_task_from_milestone)d"
type="action"
class="oe_stat_button"
icon="fa-tasks"
attrs="{'invisible': [('task_count', '=', 0)]}"
context="{'default_project_id': project_id}"
groups="project.group_project_milestone"
>
<field name="task_count" string="Tasks" widget="statinfo"/>
</button>
</div>
<group>
<group name="main_details">
<field name="project_id" invisible="1"/>
<field name="name" placeholder="e.g: Product Launch"/>
<field name="deadline"/>
<field name="is_reached"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="project_milestone_view_tree" model="ir.ui.view">
<field name="name">project.milestone.view.tree</field>
<field name="model">project.milestone</field>
<field name="arch" type="xml">
<tree decoration-success="can_be_marked_as_done" decoration-danger="is_deadline_exceeded and not can_be_marked_as_done" decoration-muted="is_reached" editable="bottom">
<field name="name"/>
<field name="deadline" optional="show"/>
<field name="is_reached" optional="show"/>
<field name="is_deadline_exceeded" invisible="1"/>
<field name="task_count" invisible="1" />
<field name="can_be_marked_as_done" invisible="1"/>
<button name="action_view_tasks"
type="object"
title="View Tasks"
string="View Tasks"
class="btn btn-link float-end"
attrs="{'invisible': [('task_count', '=', 0)]}"
groups="project.group_project_milestone"
/>
</tree>
</field>
</record>
</odoo>
@@ -44,6 +44,8 @@
<field name="legend_blocked" invisible="1"/>
<field name="legend_normal" invisible="1"/>
<field name="legend_done" invisible="1"/>
<field name="allow_milestones" />
<field name="has_late_and_unreached_milestone"/>
<progressbar field="kanban_state" colors='{"done": "success", "blocked": "danger", "normal": "muted"}'/>
<templates>
<t t-name="kanban-box">
@@ -56,6 +58,10 @@
<t t-else=""><field name="name" widget="name_with_subtask_count"/></t>
</strong>
<span invisible="context.get('default_project_id', False)"><br/><field name="project_id" required="1"/></span>
<span t-if="record.allow_milestones.raw_value and record.milestone_id.raw_value" t-attf-class="{{record.has_late_and_unreached_milestone.raw_value ? 'text-danger' : ''}}">
<br/>
<field name="milestone_id" />
</span>
<br />
<t t-if="record.partner_id.value">
<span t-if="!record.partner_is_company.raw_value">
@@ -119,10 +125,12 @@
<field name="is_closed" invisible="1" />
<field name="allow_subtasks" invisible="1" />
<field name="sequence" invisible="1" readonly="1"/>
<field name="allow_milestones" invisible="1"/>
<field name="priority" widget="priority" optional="show" nolabel="1"/>
<field name="name" widget="name_with_subtask_count"/>
<field name="child_text" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="milestone_id" attrs="{'column_invisible': [('allow_milestones', '=', False)]}"/>
<field name="partner_id" optional="hide"/>
<field name="portal_user_names" string="Assignees" optional="show"/>
<field name="date_deadline" optional="hide" widget="remaining_days" attrs="{'invisible': [('is_closed', '=', True)]}"/>
@@ -179,6 +187,13 @@
<group>
<field name="project_id" invisible="1"/>
<field name="display_project_id" string="Project" invisible="1"/>
<field name="allow_milestones" invisible="1"/>
<field name="milestone_id"
placeholder="e.g. Product Launch"
context="{'default_project_id': project_id if not parent_id or not display_project_id else display_project_id}"
attrs="{'invisible': [('allow_milestones', '=', False)]}"
options="{'no_open': True, 'no_create': True, 'no_edit': True}"
/>
<field name="user_ids" invisible="1" />
<field name="portal_user_names"
string="Assignees"
@@ -207,6 +222,12 @@
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="display_project_id" string="Project" optional="hide" invisible="1"/>
<field name="allow_milestones" invisible="1"/>
<field name="milestone_id"
optional="hide"
context="{'default_project_id': display_project_id or project_id}"
attrs="{'invisible': [('allow_milestones', '=', False)], 'column_invisible': [('parent.allow_milestones', '=', False)]}"
/>
<field name="company_id" invisible="1"/>
<field name="partner_id" options="{'no_open': True, 'no_create': True, 'no_edit': True}" optional="hide"/>
<field name="user_ids" invisible="1" />
@@ -240,6 +261,7 @@
<field name="partner_id" operator="child_of"/>
<field name="stage_id"/>
<field string="Project" name="display_project_id"/>
<field name="milestone_id" groups="project.group_project_milestone"/>
<filter string="Unassigned" name="unassigned" domain="[('user_ids', '=', False)]"/>
<separator/>
<filter string="High Priority" name="high_priority" domain="[('priority', '=', 1)]"/>
@@ -253,6 +275,7 @@
domain="[('activity_ids.date_deadline', '&gt;', context_today().strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Group By">
<filter string="Stage" name="stage" context="{'group_by': 'stage_id'}"/>
<filter string="Milestone" name="milestone" context="{'group_by': 'milestone_id'}" groups="project.group_project_milestone"/>
<filter string="Customer" name="customer" context="{'group_by': 'partner_id'}"/>
<filter string="Kanban State" name="kanban_state" context="{'group_by': 'kanban_state'}"/>
<filter string="Deadline" name="date_deadline" context="{'group_by': 'date_deadline'}"/>
@@ -2,9 +2,13 @@
<odoo>
<template id="project.milestone_deadline">
<t t-if="milestone['deadline']">
(due <t t-esc="milestone['deadline']" t-options='{"widget": "date"}'/><t t-if="not milestone['is_reached'] or not milestone['reached_date']">)</t><t t-else=""> - reached on<t t-if="milestone['reached_date'] &gt; milestone['deadline']">
(due <t t-esc="milestone['deadline']" t-options='{"widget": "date"}'/><t t-if="not milestone['is_reached'] or not milestone['reached_date']">
<t t-if="milestone['can_be_marked_as_done']"> - <font t-att-style="'color: rgb(0, ' + str(color_level) + ', 0)'">ready to be marked as reached</font></t>)</t><t t-else=""> - reached on<t t-if="milestone['reached_date'] &gt; milestone['deadline']">
<font t-att-style="'color: rgb(' + str(color_level) + ', 0, 0)'"><b><t t-esc="milestone['reached_date']" t-options='{"widget": "date"}'/></b></font>)</t><t t-else="">
<font t-att-style="'color: rgb(0, ' + str(color_level) + ', 0)'"><b><t t-esc="milestone['reached_date']" t-options='{"widget": "date"}'/></b></font>)</t></t>
</t>
<t t-elif="milestone['can_be_marked_as_done']">
(<font t-att-style="'color: rgb(0, ' + str(color_level) + ', 0)'">ready to be marked as reached</font>)
</t>
</template>
@@ -27,7 +31,7 @@
<t t-foreach="milestones['list']" t-as="milestone">
<li t-attf-class="{{milestone['is_reached'] and 'o_checked' or ''}}">
<t t-esc="milestone['name']"/>
<span t-if="milestone['is_deadline_future'] and not milestone['is_reached']"><font style="color: rgb(190, 190, 190);"><t t-set="color_level" t-value="64"/><t t-call="project.milestone_deadline"/></font></span>
<span t-if="milestone['is_deadline_future'] and not milestone['is_reached'] and not milestone['can_be_marked_as_done']"><font style="color: rgb(190, 190, 190);"><t t-set="color_level" t-value="64"/><t t-call="project.milestone_deadline"/></font></span>
<span t-else=""><t t-set="color_level" t-value="128"/><t t-call="project.milestone_deadline"/></span>
</li>
</t>
@@ -119,35 +119,4 @@
</field>
</record>
<record id="project_milestone_view_form" model="ir.ui.view">
<field name="name">project.milestone.view.form</field>
<field name="model">project.milestone</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group name="main_details">
<field name="project_id" invisible="1"/>
<field name="name" placeholder="e.g. Product Launch"/>
<field name="deadline"/>
<field name="is_reached"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="project_milestone_view_tree" model="ir.ui.view">
<field name="name">project.milestone.view.tree</field>
<field name="model">project.milestone</field>
<field name="arch" type="xml">
<tree decoration-danger="is_deadline_exceeded" decoration-muted="is_reached" editable="bottom">
<field name="name"/>
<field name="deadline" optional="show"/>
<field name="is_reached" optional="show"/>
<field name="is_deadline_exceeded" invisible="1"/>
</tree>
</field>
</record>
</odoo>
+70 -5
View File
@@ -20,6 +20,7 @@
<field name="tag_ids"/>
<field name="user_ids" filter_domain="[('user_ids', 'ilike', self), ('user_ids.active', 'in', [True, False])]"/>
<field string="Project" name="display_project_id"/>
<field name="milestone_id" groups="project.group_project_milestone"/>
<field name="stage_id"/>
<field name="partner_id" operator="child_of"/>
<field name="parent_id"/>
@@ -35,6 +36,7 @@
<filter string="Closed" name="closed_tasks" domain="[('is_closed', '!=', False)]"/>
<separator/>
<filter string="Tasks Due Today" name="tasks_due_today" domain="[('date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Late Milestones" name="late_milestone" domain="[('is_closed', '=', False), ('has_late_and_unreached_milestone', '=', True)]" groups="project.group_project_milestone"/>
<filter string="Late Tasks" name="late" domain="[('date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"/>
<separator/>
<filter name="rating_satisfied" string="Satisfied" domain="[('rating_avg', '&gt;=', 3.66)]" groups="project.group_project_rating"/>
@@ -58,6 +60,7 @@
<filter string="Personal Stage" name="personal_stage" context="{'group_by': 'personal_stage_type_ids'}"/>
<filter string="Assignees" name="user" context="{'group_by': 'user_ids'}"/>
<filter string="Project" name="project" context="{'group_by': 'project_id'}"/>
<filter string="Milestone" name="milestone" context="{'group_by': 'milestone_id'}" groups="project.group_project_milestone"/>
<filter string="Customer" name="customer" context="{'group_by': 'partner_id'}"/>
<filter string="Kanban State" name="kanban_state" context="{'group_by': 'kanban_state'}"/>
<filter string="Deadline" name="date_deadline" context="{'group_by': 'date_deadline'}"/>
@@ -415,7 +418,7 @@
name="%(act_project_project_2_project_task_all)d" icon="fa-tasks">
<field string="Tasks In Progress" name="task_count" widget="statinfo" options="{'label_field': 'label_tasks'}"/>
</button>
<button class="oe_stat_button" name="action_get_list_view" type="object" icon="fa-check-square-o">
<button class="oe_stat_button" name="action_get_list_view" type="object" icon="fa-check-square-o" attrs="{'invisible': [('allow_milestones', '=', False)]}">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="milestone_count" nolabel="1"/>
@@ -580,6 +583,17 @@
</div>
</div>
</div>
<div class="col-lg-6 o_setting_box" id="project_milestone_setting" groups="project.group_project_milestone">
<div class="o_setting_left_pane">
<field name="allow_milestones"/>
</div>
<div class="o_setting_right_pane">
<label for="allow_milestones"/>
<div class="text-muted">
Track major progress points that must be reached to achieve success
</div>
</div>
</div>
</div>
</page>
</notebook>
@@ -609,7 +623,7 @@
<filter string="Followed" name="followed_by_me" domain="[('message_is_follower', '=', True)]"/>
<filter string="Unassigned" name="unassigned_projects" domain="[('user_id', '=', False)]"/>
<separator/>
<filter string="Late Milestones" name="late_milestones" domain="[('is_milestone_exceeded', '=', True)]"/>
<filter string="Late Milestones" name="late_milestones" domain="[('is_milestone_exceeded', '=', True)]" groups="project.group_project_milestone"/>
<separator/>
<filter string="Open" name="open_project" domain="[('stage_id.fold', '=', False)]" groups="project.group_project_stages"/>
<filter string="Closed" name="closed_project" domain="[('stage_id.fold', '=', True)]" groups="project.group_project_stages"/>
@@ -801,6 +815,9 @@
<div role="menuitem">
<a name="action_view_tasks" type="object">Tasks</a>
</div>
<div role="menuitem">
<a name="action_get_list_view" type="object">Milestones</a>
</div>
</div>
<div class="col-6 o_kanban_card_manage_section o_kanban_manage_reporting">
<div role="menuitem" class="o_kanban_card_manage_title" groups="project.group_project_user">
@@ -1005,6 +1022,7 @@
<field name="allow_task_dependencies" invisible="1" />
<field name="rating_last_value" invisible="1"/>
<field name="rating_count" invisible="1"/>
<field name="allow_milestones" invisible="1" />
<header>
<button name="action_assign_to_me" string="Assign to Me" type="object" attrs="{'invisible': &quot;['|', ('user_ids', 'in', uid), ('user_ids', '=', [])]&quot;}" data-hotkey="q"/>
<button name="action_assign_to_me" string="Assign to Me" type="object" class="oe_highlight" attrs="{'invisible' : &quot;['|', ('user_ids', 'in', uid), ('user_ids', '!=', [])]&quot;}" data-hotkey="q"/>
@@ -1084,6 +1102,11 @@
<field name="project_id" attrs="{'invisible': [('parent_id', '!=', False)]}" domain="[('active', '=', True), ('company_id', '=', company_id)]"
placeholder="Private" class="o_project_task_project_field oe_edit_only"/>
<field name="display_project_id" string="Project" attrs="{'invisible': [('parent_id', '=', False)]}" domain="[('active', '=', True), ('company_id', '=', company_id)]"/>
<field name="milestone_id"
placeholder="e.g. Product Launch"
context="{'default_project_id': project_id if not parent_id or not display_project_id else display_project_id}"
attrs="{'invisible': ['|', ('project_id', '=', False), ('allow_milestones', '=', False)]}"
/>
<field name="user_ids"
class="o_task_user_field"
options="{'no_open': True, 'no_quick_create': True}"
@@ -1107,16 +1130,22 @@
<field name="description" type="html" options="{'collaborative': true, 'resizable': false}"/>
</page>
<page name="sub_tasks_page" string="Sub-tasks" attrs="{'invisible': [('allow_subtasks', '=', False)]}">
<field name="child_ids" context="{'default_project_id': project_id if not parent_id or not display_project_id else display_project_id, 'default_user_ids': user_ids, 'default_parent_id': id, 'default_partner_id': partner_id}" widget="subtasks_one2many">
<field name="child_ids" context="{'default_project_id': project_id if not parent_id or not display_project_id else display_project_id, 'default_user_ids': user_ids, 'default_parent_id': id, 'default_partner_id': partner_id, 'default_milestone_id': allow_milestones and milestone_id}" widget="subtasks_one2many">
<tree editable="bottom">
<field name="legend_normal" invisible="1"/>
<field name="legend_done" invisible="1"/>
<field name="legend_blocked" invisible="1"/>
<field name="project_id" invisible="1"/>
<field name="is_closed" invisible="1"/>
<field name="allow_milestones" invisible="1"/>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="display_project_id" string="Project" optional="hide"/>
<field name="milestone_id"
optional="hide"
context="{'default_project_id': display_project_id or project_id}"
attrs="{'invisible': [('allow_milestones', '=', False)], 'column_invisible': [('parent.allow_milestones', '=', False)]}"
/>
<field name="partner_id" optional="hide"/>
<field name="user_ids" widget="many2many_avatar_user" optional="show" domain="[('share', '=', False), ('active', '=', True)]"/>
<field name="company_id" groups="base.group_multi_company" optional="hide"/>
@@ -1132,9 +1161,17 @@
<page name="task_dependencies" string="Blocked By" attrs="{'invisible': [('allow_task_dependencies', '=', False)]}" groups="project.group_project_task_dependencies">
<field name="depend_on_ids" nolabel="1" context="{'default_project_id' : project_id}">
<tree editable="bottom">
<field name="allow_milestones" invisible="1"/>
<field name="parent_id" invisible="1" />
<field name="display_project_id" invisible="1" />
<field name="is_closed" invisible="1" />
<field name="name" />
<field name="project_id" optional="hide" />
<field name="milestone_id"
optional="hide"
context="{'default_project_id': project_id if not parent_id or not display_project_id else display_project_id}"
attrs="{'invisible': [('allow_milestones', '=', False)], 'column_invisible': [('parent.allow_milestones', '=', False)]}"
/>
<field name="user_ids" widget="many2many_avatar_user" optional="show" domain="[('share', '=', False), ('active', '=', True)]"/>
<field name="company_id" optional="hide" groups="base.group_multi_company" />
<field name="activity_ids" widget="list_activity" optional="hide"/>
@@ -1285,6 +1322,8 @@
<field name="child_text"/>
<field name="is_private"/>
<field name="rating_active"/>
<field name="has_late_and_unreached_milestone" />
<field name="allow_milestones" />
<progressbar field="kanban_state" colors='{"done": "success", "blocked": "danger", "normal": "muted"}'/>
<templates>
<t t-name="kanban-box">
@@ -1298,6 +1337,10 @@
</strong>
<t t-if="!record.is_private.raw_value">
<span invisible="context.get('default_project_id', False)"><br/><field name="project_id" required="1"/></span>
<span t-if="record.allow_milestones.raw_value and record.milestone_id.raw_value" t-attf-class="{{record.has_late_and_unreached_milestone.raw_value ? 'text-danger' : ''}}">
<br/>
<field name="milestone_id" />
</span>
</t>
<t t-else="">
<br/>
@@ -1373,11 +1416,13 @@
<field name="is_closed" invisible="1" />
<field name="allow_subtasks" invisible="1" />
<field name="sequence" invisible="1" readonly="1"/>
<field name="allow_milestones" invisible="1"/>
<field name="priority" widget="priority" optional="show" nolabel="1"/>
<field name="id" optional="hide"/>
<field name="name" widget="name_with_subtask_count"/>
<field name="child_text" invisible="1"/>
<field name="project_id" widget="project_private_task" optional="show" readonly="1"/>
<field name="milestone_id" attrs="{'invisible': [('allow_milestones', '=', False)]}" context="{'default_project_id': project_id}" groups="project.group_project_milestone"/>
<field name="partner_id" optional="hide"/>
<field name="parent_id" optional="hide" attrs="{'invisible': [('allow_subtasks', '=', False)]}" groups="base.group_no_one"/>
<field name="user_ids" optional="show" widget="many2many_avatar_user" domain="[('share', '=', False), ('active', '=', True)]"/>
@@ -1418,12 +1463,14 @@
<field name="model">project.task</field>
<field eval="2" name="priority"/>
<field name="arch" type="xml">
<calendar date_start="date_deadline" string="Tasks" mode="month"
color="color" event_limit="5" hide_time="true"
<calendar date_start="date_deadline" string="Tasks" mode="month"
color="color" event_limit="5" hide_time="true"
event_open_popup="true" quick_add="false" show_unusual_days="True"
js_class="project_calendar"
scales="month,year">
<field name="allow_milestones" invisible="1" />
<field name="project_id" widget="project_private_task"/>
<field name="milestone_id" attrs="{'invisible': [('allow_milestones', '=', False)]}"/>
<field name="user_ids" widget="many2many_avatar_user"/>
<field name="partner_id" attrs="{'invisible': [('partner_id', '=', False)]}"/>
<field name="priority" widget="priority"/>
@@ -1581,6 +1628,24 @@
<field name="context">{'project_id':active_id}</field>
</record>
<record id="action_view_task_from_milestone" model="ir.actions.act_window">
<field name="name">Tasks</field>
<field name="res_model">project.task</field>
<field name="view_mode">kanban,tree,calendar,pivot,graph,activity,form</field>
<field name="context">{'default_milestone_id': active_id}</field>
<field name="domain">[('milestone_id', '=', active_id)]</field>
<field name="search_view_id" ref="view_task_search_form"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No tasks found. Let's create one!
</p>
<p>
Keep track of the progress of your tasks from creation to completion.<br/>
Collaborate efficiently by chatting in real-time or via email.
</p>
</field>
</record>
<!-- Menu item for project -->
<menuitem id="menu_tasks_config" name="GTD" parent="menu_project_config" sequence="2"/>
@@ -59,6 +59,17 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="project_milestone">
<div class="o_setting_left_pane">
<field name="group_project_milestone"/>
</div>
<div class="o_setting_right_pane">
<label for="group_project_milestone"/>
<div class="text-muted">
Track major progress points that must be reached to achieve success
</div>
</div>
</div>
</div>
<h2>Time Management</h2>
<div class="row mt16 o_settings_container" name="project_time">
+1
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import project_delete_wizard
from . import project_milestone_reach_wizard
from . import project_task_type_delete
from . import project_share_wizard
from . import project_report_wizard
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
class ProjectMilestoneReachWizard(models.TransientModel):
_name = 'project.milestone.reach.wizard'
_description = 'Mark one or more milestones as reached'
milestone_id = fields.Many2one(related='line_ids.milestone_id')
line_ids = fields.One2many('project.milestone.reach.line.wizard', 'wizard_id')
line_count = fields.Integer(compute='_compute_line_count')
@api.depends('line_ids')
def _compute_line_count(self):
for wizard in self:
wizard.line_count = len(wizard.line_ids)
def action_mark_milestone_as_reach(self):
milestones = self.line_ids.filtered('mark_as_reached').milestone_id if self.line_count > 1 else self.milestone_id
if not milestones:
return {'type': 'ir.actions.act_window_close'}
milestones.is_reached = True
if len(milestones) == 1:
message = _('The %s milestone has successfully been marked as reached.', milestones.display_name)
else:
message = _('The selected milestones have successfully been marked as reached.')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'message': message,
'next': {'type': 'ir.actions.act_window_close'},
}
}
class ProjectMilestoneReachLineWizard(models.TransientModel):
_name = 'project.milestone.reach.line.wizard'
_description = 'Mark a milestone as reached'
wizard_id = fields.Many2one('project.milestone.reach.wizard', readonly=True)
milestone_id = fields.Many2one('project.milestone', readonly=True)
mark_as_reached = fields.Boolean(default=True)
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_milestone_reach_wizard_view_form" model="ir.ui.view">
<field name="name">project.milestone.reach.wizard.view.form</field>
<field name="model">project.milestone.reach.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<field name="line_count" invisible="1" />
<span attrs="{'invisible': [('line_count', '!=', 1)]}">Congratulations, all of the tasks linked to the <field name="milestone_id" nolabel="1" /> milestone are now done. Would you like to mark this milestone as reached?</span>
<span attrs="{'invisible': [('line_count', '=', 1)]}">Congratulations, all of the tasks linked to those milestones are now done. Would you like to mark those milestones as reached?</span>
<group attrs="{'invisible': [('line_count', '=', 1)]}">
<field name="line_ids" nolabel="1">
<tree create="0" editable="1">
<field name="milestone_id" />
<field name="mark_as_reached" />
</tree>
</field>
</group>
</sheet>
<footer>
<button string="Mark As Reached" type="object" name="action_mark_milestone_as_reach" class="btn btn-primary" data-hotkey="q"/>
<button string="Discard" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
</odoo>
+1
View File
@@ -5,3 +5,4 @@ from . import project
from . import project_milestone
from . import sale_order
from . import sale_order_line
from . import res_config_settings
+12 -9
View File
@@ -5,17 +5,20 @@ from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
SERVICE_POLICY = [
# (service_policy, string)
('ordered_prepaid', 'Prepaid/Fixed Price'),
('delivered_milestones', 'Based on Milestones'),
('delivered_manual', 'Based on Delivered Quantity (Manual)'),
]
class ProductTemplate(models.Model):
_inherit = 'product.template'
@api.model
def _selection_service_policy(self):
service_policies = [
# (service_policy, string)
('ordered_prepaid', 'Prepaid/Fixed Price'),
('delivered_manual', 'Based on Delivered Quantity (Manual)'),
]
if self.user_has_groups('project.group_project_milestone'):
service_policies.insert(1, ('delivered_milestones', 'Based on Milestones'))
return service_policies
service_tracking = fields.Selection(
selection=[
('no', 'Nothing'),
@@ -36,7 +39,7 @@ class ProductTemplate(models.Model):
'project.project', 'Project Template', company_dependent=True, copy=True,
domain="[('company_id', '=', current_company_id)]",
help='Select a billable project to be the skeleton of the new created project when selling the current product. Its stages and tasks will be duplicated.')
service_policy = fields.Selection(SERVICE_POLICY, string="Service Invoicing Policy", compute='_compute_service_policy', inverse='_inverse_service_policy')
service_policy = fields.Selection('_selection_service_policy', string="Service Invoicing Policy", compute='_compute_service_policy', inverse='_inverse_service_policy')
service_type = fields.Selection(selection_add=[
('milestones', 'Project Milestones'),
])
+2 -2
View File
@@ -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
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
def set_values(self):
super().set_values()
if self.group_project_milestone:
# Search the milestones containing a SOL and change the qty_delivered_method field of the SOL and the
# service_policy field set on the product to convert from manual to milestones.
milestone_read_group = self.env['project.milestone'].read_group(
[('sale_line_id', '!=', False)],
['sale_line_ids:array_agg(sale_line_id)'],
[],
)
sale_line_ids = milestone_read_group[0]['sale_line_ids'] if milestone_read_group else []
sale_lines = self.env['sale.order.line'].browse(sale_line_ids)
sale_lines.product_id.service_policy = 'delivered_milestones'
else:
product_domain = [('type', '=', 'service'), ('service_type', '=', 'milestones')]
products = self.env['product.product'].search(product_domain)
products.service_policy = 'delivered_manual'
self.env['sale.order.line'].search([('product_id', 'in', products.ids)]).qty_delivered_method = 'manual'
+2
View File
@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
from . import test_child_tasks
from . import test_project_profitability
from . import test_res_config_settings
from . import test_sale_project
from . import test_so_line_milestones
+136
View File
@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale.tests.common import TestSaleCommon
class TestSaleProjectCommon(TestSaleCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.env['res.config.settings'] \
.create({'group_project_milestone': True}) \
.execute()
cls.uom_hour = cls.env.ref('uom.product_uom_hour')
cls.account_sale = cls.company_data['default_account_revenue']
cls.analytic_account_sale = cls.env['account.analytic.account'].create({
'name': 'Project for selling timesheet - AA',
'code': 'AA-2030',
'company_id': cls.company_data['company'].id,
})
Project = cls.env['project.project'].with_context(tracking_disable=True)
cls.project_global = Project.create({
'name': 'Project Global',
'analytic_account_id': cls.analytic_account_sale.id,
'allow_billable': True,
})
cls.project_template = Project.create({
'name': 'Project TEMPLATE for services',
})
cls.project_template_state = cls.env['project.task.type'].create({
'name': 'Only stage in project template',
'sequence': 1,
'project_ids': [(4, cls.project_template.id)]
})
# -- manual (delivered, manual)
cls.product_delivery_manual1 = cls.env['product.product'].create({
'name': "Service delivered, create no task",
'standard_price': 11,
'list_price': 13,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI1',
'service_type': 'manual',
'service_tracking': 'no',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual2 = cls.env['product.product'].create({
'name': "Service delivered, create task in global project",
'standard_price': 30,
'list_price': 90,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI2',
'service_type': 'manual',
'service_tracking': 'task_global_project',
'project_id': cls.project_global.id,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual3 = cls.env['product.product'].create({
'name': "Service delivered, create task in new project",
'standard_price': 10,
'list_price': 20,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI3',
'service_type': 'manual',
'service_tracking': 'task_in_project',
'project_id': False, # will create a project
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual4 = cls.env['product.product'].create({
'name': "Service delivered, create project only",
'standard_price': 15,
'list_price': 30,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI4',
'service_type': 'manual',
'service_tracking': 'project_only',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual5 = cls.env['product.product'].create({
'name': "Service delivered, create project only with template",
'standard_price': 17,
'list_price': 34,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI4',
'service_type': 'manual',
'service_tracking': 'project_only',
'project_id': False,
'project_template_id': cls.project_template.id,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
# -- devliered_milestones (delivered, milestones)
product_milestone_vals = {
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-MILES',
'service_type': 'milestones',
'service_tracking': 'no',
'property_account_income_id': cls.account_sale.id,
}
cls.product_milestone, cls.product_milestone2 = cls.env['product.product'].create([
{**product_milestone_vals, 'name': 'Milestone Product', 'list_price': 20},
{**product_milestone_vals, 'name': 'Milestone Product 2', 'list_price': 15},
])
def set_project_milestone_feature(self, value):
self.env['res.config.settings'] \
.create({'group_project_milestone': value}) \
.execute()
@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.tests import tagged
from .common import TestSaleProjectCommon
@tagged('post_install', '-at_install')
class TestResConfigSettings(TestSaleProjectCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.sale_order = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
'partner_id': cls.partner_b.id,
'partner_invoice_id': cls.partner_b.id,
'partner_shipping_id': cls.partner_b.id,
'order_line': [
Command.create({
'product_id': cls.product_milestone.id,
'product_uom_qty': 3,
}),
Command.create({
'product_id': cls.product_delivery_manual1.id,
'product_uom_qty': 2,
})
]
})
cls.product_milestone_sale_line = cls.sale_order.order_line.filtered(lambda sol: sol.product_id == cls.product_milestone)
cls.product_delivery_manual1_sale_line = cls.sale_order.order_line.filtered(lambda sol: sol.product_id == cls.product_delivery_manual1)
cls.sale_order.action_confirm()
cls.milestone = cls.env['project.milestone'].create({
'name': 'Test Milestone',
'sale_line_id': cls.product_milestone_sale_line.id,
'project_id': cls.project_global.id,
})
def test_disable_and_enable_project_milestone_feature(self):
self.assertTrue(self.env.user.has_group('project.group_project_milestone'), 'The Project Milestones feature should be enabled.')
self.set_project_milestone_feature(False)
self.assertFalse(self.env.user.has_group('project.group_project_milestone'), 'The Project Milestones feature should be disabled.')
product_milestones = self.product_milestone + self.product_milestone2
self.assertEqual(
product_milestones.mapped('service_policy'),
['delivered_manual'] * 2,
'Both milestone products should become a manual product when the project milestones feature is disabled')
self.assertEqual(
product_milestones.mapped('service_type'),
['manual'] * 2,
'Both milestone products should become a manual product when the project milestones feature is disabled')
self.assertEqual(
self.product_milestone_sale_line.qty_delivered_method,
'manual',
'The quantity delivered method of SOL containing milestone product should be changed to manual when the project milestones feature is disabled')
# Since the quantity delivered manual is manual then the user can manually change the quantity delivered
self.product_milestone_sale_line.qty_delivered = 2
# Enable the project milestones feature
self.set_project_milestone_feature(True)
self.assertEqual(
self.product_milestone.service_policy,
'delivered_milestones',
'The product has been updated and considered as milestones product since a SOL containing this product is linked to a milestone.')
self.assertEqual(
self.product_milestone.service_type,
'milestones',
'The product has been updated and considered as milestones product since a SOL containing this product is linked to a milestone.')
self.assertEqual(
self.product_milestone2.service_policy,
'delivered_manual',
'The product should not be updated since we cannot assume this product was a milestone when the feature'
' was enabled because no SOL with this product is linked to a milestone.')
self.assertEqual(
self.product_milestone2.service_type,
'manual',
'The product should not be updated since we cannot assume this product was a milestone when the feature'
' was enabled because no SOL with this product is linked to a milestone.')
self.assertEqual(
self.product_milestone_sale_line.qty_delivered_method,
'manual',
'The quantity delivered method of SOL containing milestone product should keep the same quantity delivered method even if the project milestones feature is renabled.')
self.assertEqual(self.product_milestone_sale_line.qty_delivered, 2, 'The quantity delivered should be the one set by the user.')
@@ -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.')
@@ -111,7 +111,7 @@
<field name="type">service</field>
<field name="uom_id" ref="uom.product_uom_unit"/>
<field name="uom_po_id" ref="uom.product_uom_unit"/>
<field name="service_policy">delivered_milestones</field>
<field name="service_type">milestones</field>
<field name="service_tracking">no</field>
</record>
@@ -258,6 +258,11 @@
</record>
<!-- AGR Milestones -->
<record id="agr_milestone_0" model="project.milestone">
<field name="name">Preparation and Delivery Phase</field>
<field name="project_id" search="[('sale_line_id', '=', ref('sale_line_13'))]"/>
<field name="deadline" eval="(datetime.now() + relativedelta(weekday=0, weeks=-1)).strftime('%Y-%m-%d')"/>
</record>
<record id="agr_milestone_1" model="project.milestone">
<field name="name">Cabinets</field>
<field name="project_id" search="[('sale_line_id', '=', ref('sale_line_13'))]"/>
@@ -291,6 +296,7 @@
<field name="name">Kitchen Assembly</field>
<field name="sale_line_id" ref="sale_line_12"/>
<field name="project_id" search="[('sale_line_id', '=', ref('sale_line_13'))]"/>
<field name="milestone_id" ref="agr_milestone_1" />
<field name="user_ids" eval="[Command.link(ref('base.user_admin'))]"/>
</record>
@@ -814,6 +820,7 @@
<field name="stage_id" ref="project.project_stage_2"/>
<field name="partner_id" ref="base.res_partner_2"/>
<field name="user_ids" eval="[Command.link(ref('base.user_admin'))]"/>
<field name="milestone_id" ref="sale_timesheet.agr_milestone_0" />
</record>
<record id="project_agr_task_3" model="project.task">
@@ -824,6 +831,7 @@
<field name="stage_id" ref="project.project_stage_1"/>
<field name="partner_id" ref="base.res_partner_2"/>
<field name="user_ids" eval="[Command.link(ref('base.user_admin'))]"/>
<field name="milestone_id" ref="sale_timesheet.agr_milestone_0" />
</record>
<record id="project_agr_task_4" model="project.task">
@@ -835,6 +843,7 @@
<field name="sale_order_id" ref="sale_timesheet.sale_order_1"/>
<field name="partner_id" ref="base.res_partner_2"/>
<field name="user_ids" eval="[Command.link(ref('base.user_admin'))]"/>
<field name="milestone_id" ref="sale_timesheet.agr_milestone_0" />
</record>
<!-- SO2 project tasks -->
+5 -6
View File
@@ -6,16 +6,15 @@ import threading
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.addons.sale_project.models.product import SERVICE_POLICY
SERVICE_POLICY = [policy[:1] for policy in SERVICE_POLICY]
SERVICE_POLICY.insert(1, ('delivered_timesheet', 'Based on Timesheets'))
class ProductTemplate(models.Model):
_inherit = 'product.template'
service_policy = fields.Selection(selection_add=SERVICE_POLICY)
def _selection_service_policy(self):
service_policies = super()._selection_service_policy()
service_policies.insert(1, ('delivered_timesheet', 'Based on Timesheets'))
return service_policies
service_type = fields.Selection(selection_add=[
('timesheet', 'Timesheets on project (one fare per SO/Project)'),
], ondelete={'timesheet': 'set manual'})
+3 -2
View File
@@ -598,8 +598,9 @@ class ProjectTask(models.Model):
billable_tasks = self.filtered('allow_billable')
(self - billable_tasks).update({'sale_line_id': False})
super(ProjectTask, billable_tasks)._compute_sale_line()
for task in billable_tasks.filtered(lambda t: not t.sale_line_id):
task.sale_line_id = task._get_last_sol_of_customer()
for task in billable_tasks:
if not task.sale_line_id:
task.sale_line_id = task._get_last_sol_of_customer()
@api.depends('project_id.sale_line_employee_ids')
def _compute_is_project_map_empty(self):
+25 -129
View File
@@ -2,10 +2,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.sale.tests.common import TestSaleCommon
from odoo.addons.sale_project.tests.common import TestSaleProjectCommon
class TestCommonSaleTimesheet(TestSaleCommon):
class TestCommonSaleTimesheet(TestSaleProjectCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
@@ -54,12 +54,7 @@ class TestCommonSaleTimesheet(TestSaleCommon):
})
# Account and project
cls.account_sale = cls.company_data['default_account_revenue']
cls.analytic_account_sale = cls.env['account.analytic.account'].create({
'name': 'Project for selling timesheet - AA',
'code': 'AA-2030',
'company_id': cls.company_data['company'].id,
})
cls.analytic_account_sale.name = 'Project for selling timesheet - AA'
cls.analytic_account_sale_company_B = cls.env['account.analytic.account'].create({
'name': 'Project for selling timesheet Company B - AA',
'code': 'AA-2030',
@@ -68,20 +63,12 @@ class TestCommonSaleTimesheet(TestSaleCommon):
# Create projects
Project = cls.env['project.project'].with_context(tracking_disable=True)
cls.project_global = Project.create({
cls.project_global.write({
'name': 'Project for selling timesheets',
'allow_timesheets': True,
'analytic_account_id': cls.analytic_account_sale.id,
'allow_billable': True,
})
cls.project_template = Project.create({
cls.project_template.write({
'name': 'Project TEMPLATE for services',
'allow_timesheets': True,
})
cls.project_template_state = cls.env['project.task.type'].create({
'name': 'Only stage in project template',
'sequence': 1,
'project_ids': [(4, cls.project_template.id)]
})
# Projects: at least one per billable type
cls.project_task_rate = Project.create({
@@ -106,7 +93,6 @@ class TestCommonSaleTimesheet(TestSaleCommon):
})
# Create service products
uom_hour = cls.env.ref('uom.product_uom_hour')
# -- ordered quantities (ordered, timesheet)
cls.product_order_timesheet1 = cls.env['product.product'].create({
@@ -115,8 +101,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 13,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-ORDERED1',
'service_type': 'timesheet',
'service_tracking': 'no',
@@ -130,8 +116,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 90,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-ORDERED2',
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
@@ -145,8 +131,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 20,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-ORDERED3',
'service_type': 'timesheet',
'service_tracking': 'task_in_project',
@@ -160,8 +146,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 30,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-ORDERED4',
'service_type': 'timesheet',
'service_tracking': 'project_only',
@@ -175,8 +161,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 34,
'type': 'service',
'invoice_policy': 'order',
'uom_id': cls.env.ref('uom.product_uom_hour').id,
'uom_po_id': cls.env.ref('uom.product_uom_hour').id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-ORDERED4',
'service_type': 'timesheet',
'service_tracking': 'project_only',
@@ -193,8 +179,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 13,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI1',
'service_type': 'timesheet',
'service_tracking': 'no',
@@ -208,8 +194,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 90,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI2',
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
@@ -223,8 +209,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 20,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI3',
'service_type': 'timesheet',
'service_tracking': 'task_in_project',
@@ -238,8 +224,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 30,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI4',
'service_type': 'timesheet',
'service_tracking': 'project_only',
@@ -253,8 +239,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 34,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('uom.product_uom_hour').id,
'uom_po_id': cls.env.ref('uom.product_uom_hour').id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI5',
'service_type': 'timesheet',
'service_tracking': 'project_only',
@@ -264,96 +250,6 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'property_account_income_id': cls.account_sale.id,
})
# -- manual (delivered, manual)
cls.product_delivery_manual1 = cls.env['product.product'].create({
'name': "Service delivered, create no task",
'standard_price': 11,
'list_price': 13,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-DELI1',
'service_type': 'manual',
'service_tracking': 'no',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual2 = cls.env['product.product'].create({
'name': "Service delivered, create task in global project",
'standard_price': 30,
'list_price': 90,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-DELI2',
'service_type': 'manual',
'service_tracking': 'task_global_project',
'project_id': cls.project_global.id,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual3 = cls.env['product.product'].create({
'name': "Service delivered, create task in new project",
'standard_price': 10,
'list_price': 20,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-DELI3',
'service_type': 'manual',
'service_tracking': 'task_in_project',
'project_id': False, # will create a project
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual4 = cls.env['product.product'].create({
'name': "Service delivered, create project only",
'standard_price': 15,
'list_price': 30,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-DELI4',
'service_type': 'manual',
'service_tracking': 'project_only',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual5 = cls.env['product.product'].create({
'name': "Service delivered, create project only with template",
'standard_price': 17,
'list_price': 34,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-DELI4',
'service_type': 'manual',
'service_tracking': 'project_only',
'project_id': False,
'project_template_id': cls.project_template.id,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_milestone = cls.env['product.product'].create({
'name': 'Milestone Product',
'list_price': 20,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-MILES',
'service_type': 'milestones',
'service_tracking': 'no',
'property_account_income_id': cls.account_sale.id,
})
def setUp(self):
super().setUp()
self.so = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
@@ -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)