From f4c054e4731270a5103ec1d5a97c2789aef977e3 Mon Sep 17 00:00:00 2001 From: Bonis Modi Date: Mon, 5 Jul 2021 13:41:03 +0000 Subject: [PATCH] [IMP] project, hr_timesheet, *: improve generic ux Purpose of this commit to improve generic UX for the project app. So, In this commit done following changes: - add 'subtask_effective_hours' and 'total_hours_spent' fields in the page of subtask in task form view. - change invisible attrs to column_invisible attrs for block_by page in task form view. - add 'task in progress' subtype for project.task and move 'stage changed' subtype above 'task blocked'. - remove the 'is_close' field and all its references from project.task.type. - unarchive all of its tasks when the user unarchives the task stage. - sort projects on 'is_favorite'. - internal users following the project automatically follow its updates and their notification preferences propagated accordingly. - duplicating a task will duplicate all of its subtasks. - move parent task field in extra info page of task form view. - change the label of 'edit' to 'view' in the burger menu of project kanban view. - user can delete task without sale_order validation error. - add constraints for 'seller_ids' field. - add remaining_hours_so field in task tree view. - add unusual_days in task calendar view. - add 'no content helper' in action of burndown chart. task-2536044 closes odoo/odoo#74018 Related: odoo/enterprise#19880 Related: odoo/upgrade#2801 Signed-off-by: Laurent Stukkens (ltu) Co-authored-by: Kartik Chavda --- addons/hr/models/hr_employee.py | 18 +++------ addons/hr_timesheet/views/project_views.xml | 2 + addons/project/__manifest__.py | 1 + addons/project/data/mail_data.xml | 28 +++++++++++-- addons/project/data/project_demo.xml | 2 - addons/project/models/project.py | 39 ++++++++++++------- .../project/report/project_report_views.xml | 2 +- ...oject_task_burndown_chart_report_views.xml | 8 +++- .../static/src/js/subtasks_list_renderer.js | 34 ++++++++++++++++ .../js/widgets/subtasks_one2many_widget.js | 21 ++++++++++ addons/project/views/project_views.xml | 27 +++++++------ addons/resource/models/resource.py | 12 ++++++ addons/sale_project/models/project.py | 5 --- .../sale_purchase/models/product_template.py | 6 +++ addons/sale_purchase/tests/common.py | 31 ++++++++------- .../sale_timesheet/tests/test_sale_service.py | 6 +-- .../views/project_task_views.xml | 11 ++++++ 17 files changed, 183 insertions(+), 70 deletions(-) create mode 100644 addons/project/static/src/js/subtasks_list_renderer.js create mode 100644 addons/project/static/src/js/widgets/subtasks_one2many_widget.js diff --git a/addons/hr/models/hr_employee.py b/addons/hr/models/hr_employee.py index f61da5f56b4..edc3c4e36e3 100644 --- a/addons/hr/models/hr_employee.py +++ b/addons/hr/models/hr_employee.py @@ -1,18 +1,16 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import pytz +from pytz import UTC from datetime import datetime, time -from dateutil.rrule import rrule, DAILY from random import choice from string import digits from werkzeug.urls import url_encode from dateutil.relativedelta import relativedelta -from collections import defaultdict from odoo import api, fields, models, _ from odoo.osv.query import Query -from odoo.exceptions import ValidationError, AccessError, UserError +from odoo.exceptions import ValidationError, AccessError from odoo.osv import expression from odoo.tools.misc import format_date @@ -438,14 +436,10 @@ class HrEmployeePrivate(models.Model): if not self: return {} self.ensure_one() - calendar = self.resource_calendar_id - if not calendar: - return {} - dfrom = datetime.combine(fields.Date.from_string(date_from), time.min).replace(tzinfo=pytz.UTC) - dto = datetime.combine(fields.Date.from_string(date_to), time.max).replace(tzinfo=pytz.UTC) - - works = {d[0].date() for d in calendar._work_intervals_batch(dfrom, dto)[False]} - return {fields.Date.to_string(day.date()): (day.date() not in works) for day in rrule(DAILY, dfrom, until=dto)} + return self.resource_calendar_id._get_unusual_days( + datetime.combine(fields.Date.from_string(date_from), time.min).replace(tzinfo=UTC), + datetime.combine(fields.Date.from_string(date_to), time.max).replace(tzinfo=UTC) + ) # --------------------------------------------------------- # Messaging diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index 4998ecadc77..04c0a9538d1 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -112,6 +112,8 @@ + + diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 4ce0c2ef4cc..0c26d807ba3 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -72,6 +72,7 @@ 'project/static/src/js/project_rating_graph_view.js', 'project/static/src/js/project_rating_pivot_view.js', 'project/static/src/js/project_task_kanban_examples.js', + 'project/static/src/js/subtasks_list_renderer.js', 'project/static/src/js/tours/project.js', 'project/static/src/js/project_calendar.js', 'project/static/src/js/right_panel/*', diff --git a/addons/project/data/mail_data.xml b/addons/project/data/mail_data.xml index 23b3b7ea260..3811871c531 100644 --- a/addons/project/data/mail_data.xml +++ b/addons/project/data/mail_data.xml @@ -8,6 +8,12 @@ Task Created + + Stage Changed + project.task + + Stage changed + Task Blocked project.task @@ -20,11 +26,10 @@ Task ready for Next Stage - - Stage Changed + + Task in Progress project.task - Stage changed Task Rating @@ -37,6 +42,14 @@ + + + Update Created + project.update + + Update Created + + Project Stage Changed @@ -94,4 +107,13 @@ project_id + + Update Created + 16 + project.project + + + project_id + + diff --git a/addons/project/data/project_demo.xml b/addons/project/data/project_demo.xml index f6e7cd58e9d..4c19299b8ee 100644 --- a/addons/project/data/project_demo.xml +++ b/addons/project/data/project_demo.xml @@ -64,14 +64,12 @@ 20 Done - 30 Cancelled Ready to reopen - diff --git a/addons/project/models/project.py b/addons/project/models/project.py index d02a9a0abe0..d0e3204bdd2 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -3,8 +3,9 @@ import ast import json +from pytz import UTC from collections import defaultdict -from datetime import timedelta, datetime +from datetime import timedelta, datetime, time from random import randint from odoo import api, Command, fields, models, tools, SUPERUSER_ID, _ @@ -99,7 +100,6 @@ class ProjectTaskType(models.Model): help="Automatically modify the kanban state when the customer replies to the feedback for this stage.\n" " * Good feedback from the customer will update the kanban state to 'ready for the new stage' (green bullet).\n" " * Neutral or bad feedback will set the kanban state to 'blocked' (red bullet).\n") - is_closed = fields.Boolean('Closing Stage', help="Tasks in this stage are considered as closed.") disabled_rating_warning = fields.Text(compute='_compute_disabled_rating_warning') user_id = fields.Many2one('res.users', 'Stage Owner', index=True) @@ -548,6 +548,7 @@ class Project(models.Model): partners = set(task.message_partner_ids.ids) & set(partner_ids) if partners: task.message_subscribe(partner_ids=list(partners), subtype_ids=task_subtypes) + self.update_ids.message_subscribe(partner_ids=partner_ids, subtype_ids=subtype_ids) return res def _alias_get_creation_values(self): @@ -656,10 +657,6 @@ class Project(models.Model): 'context': {'search_default_group_date': 1, 'default_account_id': self.analytic_account_id.id} } - def action_view_kanban_project(self): - # [XBO] TODO: remove me in master - return - # --------------------------------------------- # PROJECT UPDATES # --------------------------------------------- @@ -857,7 +854,7 @@ class Task(models.Model): project_id = self.env.context.get('default_project_id') if not project_id: return False - return self.stage_find(project_id, [('fold', '=', False), ('is_closed', '=', False)]) + return self.stage_find(project_id, [('fold', '=', False)]) @api.model def _default_company_id(self): @@ -962,7 +959,7 @@ class Task(models.Model): legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True, related_sudo=False) legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True, related_sudo=False) legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True, related_sudo=False) - is_closed = fields.Boolean(related="stage_id.is_closed", string="Closing Stage", readonly=True, related_sudo=False) + is_closed = fields.Boolean(related="stage_id.fold", string="Closing Stage", related_sudo=False, help="Folded in Kanban stages are closing stages.") parent_id = fields.Many2one('project.task', string='Parent Task', index=True) child_ids = fields.One2many('project.task', 'parent_id', string="Sub-tasks") child_text = fields.Char(compute="_compute_child_text") @@ -1389,8 +1386,7 @@ class Task(models.Model): for task in self: if task.project_id: if task.project_id not in task.stage_id.project_ids: - task.stage_id = task.stage_find(task.project_id.id, [ - ('fold', '=', False), ('is_closed', '=', False)]) + task.stage_id = task.stage_find(task.project_id.id, [('fold', '=', False)]) else: task.stage_id = False @@ -1431,6 +1427,8 @@ class Task(models.Model): default['name'] = _("%s (copy)", self.name) if self.recurrence_id: default['recurrence_id'] = self.recurrence_id.copy().id + if self.allow_subtasks: + default['child_ids'] = [child.copy().id for child in self.child_ids] return super(Task, self).copy(default) @api.model @@ -1777,7 +1775,7 @@ class Task(models.Model): def update_date_end(self, stage_id): project_task_type = self.env['project.task.type'].browse(stage_id) - if project_task_type.fold or project_task_type.is_closed: + if project_task_type.fold: return {'date_end': fields.Datetime.now()} return {'date_end': False} @@ -1910,10 +1908,13 @@ class Task(models.Model): def _track_subtype(self, init_values): self.ensure_one() - if 'kanban_state_label' in init_values and self.kanban_state == 'blocked': - return self.env.ref('project.mt_task_blocked') - elif 'kanban_state_label' in init_values and self.kanban_state == 'done': - return self.env.ref('project.mt_task_ready') + mail_message_subtype_per_kanban_state = { + 'blocked': 'project.mt_task_blocked', + 'done': 'project.mt_task_ready', + 'normal': 'project.mt_task_progress', + } + if 'kanban_state_label' in init_values and self.kanban_state in mail_message_subtype_per_kanban_state: + return self.env.ref(mail_message_subtype_per_kanban_state[self.kanban_state]) elif 'stage_id' in init_values: return self.env.ref('project.mt_task_stage') return super(Task, self)._track_subtype(init_values) @@ -2171,6 +2172,14 @@ class Task(models.Model): self.ensure_one() return self.analytic_account_id or self.project_analytic_account_id + @api.model + def get_unusual_days(self, date_from, date_to=None): + calendar = self.env.company.resource_calendar_id + return calendar._get_unusual_days( + datetime.combine(fields.Date.from_string(date_from), time.min).replace(tzinfo=UTC), + datetime.combine(fields.Date.from_string(date_to), time.max).replace(tzinfo=UTC) + ) + class ProjectTags(models.Model): """ Tags of project's tasks """ _name = "project.tags" diff --git a/addons/project/report/project_report_views.xml b/addons/project/report/project_report_views.xml index dc6102fc154..d968e6086a3 100644 --- a/addons/project/report/project_report_views.xml +++ b/addons/project/report/project_report_views.xml @@ -63,7 +63,7 @@ - + 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 ef6a28a83e0..2d463204a66 100644 --- a/addons/project/report/project_task_burndown_chart_report_views.xml +++ b/addons/project/report/project_task_burndown_chart_report_views.xml @@ -16,7 +16,7 @@ - + @@ -56,7 +56,11 @@ {'search_default_project_id': active_id} [('display_project_id', '!=', False)] - + +

+ No data yet! +

+

Analyze how quickly your team is completing your project's tasks and check if everything is going according to plan.

diff --git a/addons/project/static/src/js/subtasks_list_renderer.js b/addons/project/static/src/js/subtasks_list_renderer.js new file mode 100644 index 00000000000..5eab262da0f --- /dev/null +++ b/addons/project/static/src/js/subtasks_list_renderer.js @@ -0,0 +1,34 @@ +/** @odoo-module **/ + +import ListRenderer from 'web.ListRenderer'; +import Dialog from 'web.Dialog'; +import { _t } from 'web.core'; + +export const SubTasksListRenderer = ListRenderer.extend({ + events: Object.assign({}, ListRenderer.prototype.events, { + 'click tr .o_list_record_remove': '_onClickOpenDialog', + }), + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Confirmation dialog is opened when deleted any record from list. + * + * @private + * @param {MouseEvent} ev + */ + _onClickOpenDialog(ev) { + ev.stopPropagation(); + Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), { + confirm_callback: () => { + this._onRemoveIconClick(ev); + }, + cancel_callback: () => { + return false; + }, + }); + }, + +}); diff --git a/addons/project/static/src/js/widgets/subtasks_one2many_widget.js b/addons/project/static/src/js/widgets/subtasks_one2many_widget.js new file mode 100644 index 00000000000..3a23db8f3ec --- /dev/null +++ b/addons/project/static/src/js/widgets/subtasks_one2many_widget.js @@ -0,0 +1,21 @@ +/** @odoo-module **/ + +import { FieldOne2Many } from 'web.relational_fields'; +import fieldRegistry from 'web.field_registry'; +import { SubTasksListRenderer } from '../subtasks_list_renderer'; + +const SubTasksFieldOne2Many = FieldOne2Many.extend({ + /** + * We want to use our custom renderer for the list. + * + * @override + */ + _getRenderer() { + if (this.view.arch.tag === 'tree') { + return SubTasksListRenderer; + } + return this._super.apply(...arguments); + }, +}); + +fieldRegistry.add('subtasks_one2many', SubTasksFieldOne2Many); diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 37b4c885cdc..8418c09f8ac 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -30,6 +30,8 @@ + + @@ -127,7 +129,6 @@ - @@ -691,14 +692,14 @@ -
- @@ -888,11 +889,6 @@ options="{'no_open': True}" widget="many2many_avatar_user" domain="[('share', '=', False)]"/> - @@ -911,7 +907,7 @@ - + @@ -989,6 +985,7 @@ + @@ -1207,8 +1204,10 @@ project.task - + diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index d1dd783c4a0..e1050d4a5a2 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -636,6 +636,18 @@ class ResourceCalendar(models.Model): ) return interval_dt(work_intervals[0]) if work_intervals else None + def _get_unusual_days(self, start_dt, end_dt): + if not self: + return {} + self.ensure_one() + if not start_dt.tzinfo: + start_dt = start_dt.replace(tzinfo=utc) + if not end_dt.tzinfo: + end_dt = end_dt.replace(tzinfo=utc) + + works = {d[0].date() for d in self._work_intervals_batch(start_dt, end_dt)[False]} + return {fields.Date.to_string(day.date()): (day.date() not in works) for day in rrule(DAILY, start_dt, until=end_dt)} + # -------------------------------------------------- # External API # -------------------------------------------------- diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py index 25f9eb8c61c..bb952a0ff50 100644 --- a/addons/sale_project/models/project.py +++ b/addons/sale_project/models/project.py @@ -169,11 +169,6 @@ class ProjectTask(models.Model): product_id=task.sale_line_id.product_id.display_name, )) - @api.ondelete(at_uninstall=False) - def _unlink_except_linked_so(self): - if any(task.sale_line_id for task in self): - raise ValidationError(_('You have to unlink the task from the sale order item in order to delete it.')) - # --------------------------------------------------- # Actions # --------------------------------------------------- diff --git a/addons/sale_purchase/models/product_template.py b/addons/sale_purchase/models/product_template.py index f1b730a563b..9b92440333a 100644 --- a/addons/sale_purchase/models/product_template.py +++ b/addons/sale_purchase/models/product_template.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models +from odoo.exceptions import ValidationError class ProductTemplate(models.Model): @@ -13,6 +14,11 @@ class ProductTemplate(models.Model): ('service_to_purchase', "CHECK((type != 'service' AND service_to_purchase != true) or (type = 'service'))", 'Product that is not a service can not create RFQ.'), ] + @api.constrains('service_to_purchase', 'seller_ids') + def validate_service_to_purchase(self): + if self.service_to_purchase and not self.seller_ids: + raise ValidationError("Please define the vendor from whom you would like to purchase this service automatically.") + @api.onchange('type') def _onchange_type(self): res = super(ProductTemplate, self)._onchange_type() diff --git a/addons/sale_purchase/tests/common.py b/addons/sale_purchase/tests/common.py index 44365e8ee86..008bbe0c94a 100644 --- a/addons/sale_purchase/tests/common.py +++ b/addons/sale_purchase/tests/common.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.addons.sale.tests.common import TestSaleCommon - +from odoo import Command class TestCommonSalePurchaseNoChart(TestSaleCommon): @@ -24,6 +24,19 @@ class TestCommonSalePurchaseNoChart(TestSaleCommon): 'email': 'supplier.serv@supercompany.com', }) + cls.supplierinfo1 = cls.env['product.supplierinfo'].create({ + 'partner_id': cls.partner_vendor_service.id, + 'price': 100, + 'delay': 1, + }) + cls.supplierinfo2 = cls.env['product.supplierinfo'].create({ + 'partner_id': cls.partner_vendor_service.id, + 'price': 10, + 'delay': 5, + }) + + # Create product + # When service_to_purser is True add the supplier i.e 'saller_ids' on the product to void the Validation error at product creation time cls.service_purchase_1 = cls.env['product.product'].create({ 'name': "Out-sourced Service 1", 'standard_price': 200.0, @@ -38,6 +51,7 @@ class TestCommonSalePurchaseNoChart(TestSaleCommon): 'taxes_id': False, 'categ_id': cls.product_category_purchase.id, 'service_to_purchase': True, + 'seller_ids': [Command.set(cls.supplierinfo1.ids)], }) cls.service_purchase_2 = cls.env['product.product'].create({ 'name': "Out-sourced Service 2", @@ -53,17 +67,8 @@ class TestCommonSalePurchaseNoChart(TestSaleCommon): 'taxes_id': False, 'categ_id': cls.product_category_purchase.id, 'service_to_purchase': True, + 'seller_ids': [Command.set(cls.supplierinfo2.ids)], }) - cls.supplierinfo1 = cls.env['product.supplierinfo'].create({ - 'partner_id': cls.partner_vendor_service.id, - 'price': 100, - 'product_tmpl_id': cls.service_purchase_1.product_tmpl_id.id, - 'delay': 1, - }) - cls.supplierinfo2 = cls.env['product.supplierinfo'].create({ - 'partner_id': cls.partner_vendor_service.id, - 'price': 10, - 'product_tmpl_id': cls.service_purchase_2.product_tmpl_id.id, - 'delay': 5, - }) + cls.supplierinfo1.product_tmpl_id = cls.service_purchase_1.product_tmpl_id.id + cls.supplierinfo2.product_tmpl_id = cls.service_purchase_2.product_tmpl_id.id diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index f3c85bcacb4..7b2765aa15a 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -92,9 +92,9 @@ class TestSaleService(TestCommonSaleTimesheet): # delete timesheets before deleting the task, so as to trigger the error # about linked sales order lines and not the one about linked timesheets task.timesheet_ids.unlink() - # not possible to delete a task linked to a SOL - with self.assertRaises(ValidationError): - task.unlink() + # unlink automatically task from the SOL when deleting the task + task.unlink() + self.assertFalse(sale_order_line.task_id, "Deleting the task its should automatically unlink the task from SOL.") def test_timesheet_uom(self): """ Test timesheet invoicing and uom conversion """ diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 15e3bfd69c3..b9f29741aa0 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -134,6 +134,17 @@ + + project.task.tree.inherited + project.task + + + + + + + + project.task.form.inherit.timesheet project.task