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 + + + + + + + + + -
+
+
+ +
+
+
+
@@ -609,7 +623,7 @@ - + @@ -801,6 +815,9 @@ +
+
+
+ +
+
+
+

Time Management

diff --git a/addons/project/wizard/__init__.py b/addons/project/wizard/__init__.py index e3cb21067c6..e7f558db232 100644 --- a/addons/project/wizard/__init__.py +++ b/addons/project/wizard/__init__.py @@ -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 diff --git a/addons/project/wizard/project_milestone_reach_wizard.py b/addons/project/wizard/project_milestone_reach_wizard.py new file mode 100644 index 00000000000..b5479b5d2c9 --- /dev/null +++ b/addons/project/wizard/project_milestone_reach_wizard.py @@ -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) diff --git a/addons/project/wizard/project_milestone_reach_wizard.xml b/addons/project/wizard/project_milestone_reach_wizard.xml new file mode 100644 index 00000000000..a1f88bd6092 --- /dev/null +++ b/addons/project/wizard/project_milestone_reach_wizard.xml @@ -0,0 +1,30 @@ + + + + + project.milestone.reach.wizard.view.form + project.milestone.reach.wizard + +
+ + + Congratulations, all of the tasks linked to the milestone are now done. Would you like to mark this milestone as reached? + Congratulations, all of the tasks linked to those milestones are now done. Would you like to mark those milestones as reached? + + + + + + + + + +
+
+
+
+
+ +
diff --git a/addons/sale_project/models/__init__.py b/addons/sale_project/models/__init__.py index c7c5fa4eb1e..2ca122c0b24 100644 --- a/addons/sale_project/models/__init__.py +++ b/addons/sale_project/models/__init__.py @@ -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 diff --git a/addons/sale_project/models/product.py b/addons/sale_project/models/product.py index 8891c2877c8..a7bd6d63bc5 100644 --- a/addons/sale_project/models/product.py +++ b/addons/sale_project/models/product.py @@ -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'), ]) diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py index 9b7113a381d..3b12797eb9e 100644 --- a/addons/sale_project/models/project.py +++ b/addons/sale_project/models/project.py @@ -496,12 +496,12 @@ class ProjectTask(models.Model): task.partner_id = sale_order_id.partner_id task.sale_order_id = sale_order_id - @api.depends('commercial_partner_id', 'sale_line_id.order_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id') + @api.depends('commercial_partner_id', 'sale_line_id.order_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'milestone_id.sale_line_id') def _compute_sale_line(self): for task in self: if not task.sale_line_id: # if the display_project_id is set then it means the task is classic task or a subtask with another project than its parent. - task.sale_line_id = task.display_project_id.sale_line_id or task.parent_id.sale_line_id or task.project_id.sale_line_id + task.sale_line_id = task.display_project_id.sale_line_id or task.parent_id.sale_line_id or task.project_id.sale_line_id or task.milestone_id.sale_line_id # check sale_line_id and customer are coherent if task.sale_line_id.order_partner_id.commercial_partner_id != task.partner_id.commercial_partner_id: task.sale_line_id = False diff --git a/addons/sale_project/models/res_config_settings.py b/addons/sale_project/models/res_config_settings.py new file mode 100644 index 00000000000..b626c12e17f --- /dev/null +++ b/addons/sale_project/models/res_config_settings.py @@ -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' diff --git a/addons/sale_project/tests/__init__.py b/addons/sale_project/tests/__init__.py index 89bae4682d8..5ecbcbf1f6b 100644 --- a/addons/sale_project/tests/__init__.py +++ b/addons/sale_project/tests/__init__.py @@ -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 diff --git a/addons/sale_project/tests/common.py b/addons/sale_project/tests/common.py new file mode 100644 index 00000000000..741a58a29b1 --- /dev/null +++ b/addons/sale_project/tests/common.py @@ -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() diff --git a/addons/sale_project/tests/test_res_config_settings.py b/addons/sale_project/tests/test_res_config_settings.py new file mode 100644 index 00000000000..4ef61cc0d93 --- /dev/null +++ b/addons/sale_project/tests/test_res_config_settings.py @@ -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.') diff --git a/addons/sale_project/tests/test_so_line_milestones.py b/addons/sale_project/tests/test_so_line_milestones.py index 8b33b666592..c804e0215e5 100644 --- a/addons/sale_project/tests/test_so_line_milestones.py +++ b/addons/sale_project/tests/test_so_line_milestones.py @@ -9,8 +9,8 @@ from odoo.tests.common import tagged class TestSoLineMilestones(TestSaleCommon): @classmethod - def setUpClass(cls): - super().setUpClass() + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) uom_hour = cls.env.ref('uom.product_uom_hour') @@ -107,3 +107,17 @@ class TestSoLineMilestones(TestSaleCommon): self.milestone1.unlink() self.assertEqual(self.sol1.qty_delivered, 0.0, "Delivered quantity should update when a milestone is removed") + + def test_compute_sale_line_in_task(self): + task = self.env['project.task'].create({ + 'name': 'Test Task', + 'project_id': self.project.id, + }) + self.assertEqual(task.sale_line_id, self.sol1, 'The task should have the one of the project linked') + self.project.sale_line_id = False + task.sale_line_id = False + self.assertFalse(task.sale_line_id) + task.write({'milestone_id': self.milestone1.id}) + self.assertEqual(task.sale_line_id, self.milestone1.sale_line_id, 'The task should have the SOL from the milestone.') + self.project.sale_line_id = self.sol2 + self.assertEqual(task.sale_line_id, self.sol1, 'The task should keep the SOL linked to the milestone.') diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml index 6d4e7e42eae..0c73ab9dd57 100644 --- a/addons/sale_timesheet/data/sale_service_demo.xml +++ b/addons/sale_timesheet/data/sale_service_demo.xml @@ -111,7 +111,7 @@ service - delivered_milestones + milestones no @@ -258,6 +258,11 @@ + + Preparation and Delivery Phase + + + Cabinets @@ -291,6 +296,7 @@ Kitchen Assembly + @@ -814,6 +820,7 @@ + @@ -824,6 +831,7 @@ + @@ -835,6 +843,7 @@ + diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index 3f9d5fc8c91..e8c24be337b 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -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'}) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index fc5d69b38be..09105eee8b4 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -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): diff --git a/addons/sale_timesheet/tests/common.py b/addons/sale_timesheet/tests/common.py index 544518e899f..d8c1971d8fc 100644 --- a/addons/sale_timesheet/tests/common.py +++ b/addons/sale_timesheet/tests/common.py @@ -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({ diff --git a/addons/sale_timesheet/tests/test_sale_timesheet_ui.py b/addons/sale_timesheet/tests/test_sale_timesheet_ui.py index 716008cac30..c1a0a11fae7 100644 --- a/addons/sale_timesheet/tests/test_sale_timesheet_ui.py +++ b/addons/sale_timesheet/tests/test_sale_timesheet_ui.py @@ -29,5 +29,10 @@ class TestUi(HttpCase): 'service_tracking': 'no', }) + # Enable the "Milestones" feature to be able to create milestones on this tour. + cls.env['res.config.settings'] \ + .create({'group_project_milestone': True}) \ + .execute() + def test_ui(self): self.start_tour('/web', 'sale_timesheet_tour', login='admin', timeout=100)