diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index 1ac8414c69b..f64423d05fa 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -73,11 +73,18 @@ + + + + + + + diff --git a/addons/project/models/digest.py b/addons/project/models/digest.py index b584ca956d6..1c5ae596093 100644 --- a/addons/project/models/digest.py +++ b/addons/project/models/digest.py @@ -20,7 +20,8 @@ class Digest(models.Model): ('stage_id.fold', '=', False), ('create_date', '>=', start), ('create_date', '<', end), - ('company_id', '=', company.id) + ('company_id', '=', company.id), + ('display_project_id', '!=', False), ]) def _compute_kpis_actions(self, company, user): diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 133df699117..73e45ea128b 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import ast +from collections import defaultdict from datetime import timedelta, datetime from random import randint @@ -115,10 +116,16 @@ class Project(models.Model): ]) def _compute_task_count(self): - task_data = self.env['project.task'].read_group([('project_id', 'in', self.ids), '|', '&', ('stage_id.is_closed', '=', False), ('stage_id.fold', '=', False), ('stage_id', '=', False)], ['project_id'], ['project_id']) - result = dict((data['project_id'][0], data['project_id_count']) for data in task_data) + task_data = self.env['project.task'].read_group(['|', '&', ('stage_id.is_closed', '=', False), ('stage_id.fold', '=', False), ('stage_id', '=', False)], + ['project_id', 'display_project_id:count'], ['project_id']) + result_wo_subtask = defaultdict(int) + result_with_subtasks = defaultdict(int) + for data in task_data: + result_wo_subtask[data['project_id'][0]] += data['display_project_id'] + result_with_subtasks[data['project_id'][0]] += data['project_id_count'] for project in self: - project.task_count = result.get(project.id, 0) + project.task_count = result_wo_subtask[project.id] + project.task_count_with_subtasks = result_with_subtasks[project.id] def attachment_tree_view(self): action = self.env['ir.actions.act_window']._for_xml_id('base.action_attachment') @@ -186,6 +193,7 @@ class Project(models.Model): related='company_id.resource_calendar_id') type_ids = fields.Many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', string='Tasks Stages') task_count = fields.Integer(compute='_compute_task_count', string="Task Count") + task_count_with_subtasks = fields.Integer(compute='_compute_task_count') task_ids = fields.One2many('project.task', 'project_id', string='Tasks', domain=['|', ('stage_id.fold', '=', False), ('stage_id', '=', False)]) color = fields.Integer(string='Color Index') @@ -215,8 +223,6 @@ class Project(models.Model): doc_count = fields.Integer(compute='_compute_attached_docs_count', string="Number of Documents Attached") date_start = fields.Date(string='Start Date') date = fields.Date(string='Expiration Date', index=True, tracking=True) - subtask_project_id = fields.Many2one('project.project', string='Sub-task Project', ondelete="restrict", - help="Project in which sub-tasks of the current project will be created. It can be the current project itself.") 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')) @@ -339,8 +345,6 @@ class Project(models.Model): if not default.get('name'): default['name'] = _("%s (copy)") % (self.name) project = super(Project, self).copy(default) - if self.subtask_project_id == self: - project.subtask_project_id = project for follower in self.message_follower_ids: project.message_subscribe(partner_ids=follower.partner_id.ids, subtype_ids=follower.subtype_ids.ids) if 'tasks' not in default: @@ -352,8 +356,6 @@ class Project(models.Model): # Prevent double project creation self = self.with_context(mail_create_nosubscribe=True) project = super(Project, self).create(vals) - if not vals.get('subtask_project_id'): - project.subtask_project_id = project.id if project.privacy_visibility == 'portal' and project.partner_id.user_ids: project.allowed_user_ids |= project.partner_id.user_ids return project @@ -598,6 +600,11 @@ class Task(models.Model): project_id = fields.Many2one('project.project', string='Project', compute='_compute_project_id', store=True, readonly=False, index=True, tracking=True, check_company=True, change_default=True) + # Defines in which project the task will be displayed / taken into account in statistics. + # Example: 1 task A with 1 subtask B in project P + # A -> project_id=P, display_project_id=P + # B -> project_id=P (to inherit from ACL/security rules), display_project_id=False + display_project_id = fields.Many2one('project.project', index=True) planned_hours = fields.Float("Initially Planned Hours", help='Time planned to achieve this task (including its sub-tasks).', tracking=True) subtask_planned_hours = fields.Float("Sub-tasks Planned Hours", compute='_compute_subtask_planned_hours', help="Sum of the time planned of all the sub-tasks linked to this task. Usually less than or equal to the initially planned time of this task.") @@ -634,7 +641,7 @@ class Task(models.Model): is_closed = fields.Boolean(related="stage_id.is_closed", string="Closing Stage", readonly=True, related_sudo=False) parent_id = fields.Many2one('project.task', string='Parent Task', index=True) child_ids = fields.One2many('project.task', 'parent_id', string="Sub-tasks", context={'active_test': False}) - subtask_project_id = fields.Many2one('project.project', related="project_id.subtask_project_id", string='Sub-task Project', readonly=True) + child_text = fields.Char(compute="_compute_child_text") allow_subtasks = fields.Boolean(string="Allow Sub-tasks", related="project_id.allow_subtasks", readonly=True) subtask_count = fields.Integer("Sub-task Count", compute='_compute_subtask_count') email_from = fields.Char(string='Email From', help="These people will receive email.", index=True, @@ -939,6 +946,16 @@ class Task(models.Model): for task in self: task.subtask_planned_hours = sum(child_task.planned_hours + child_task.subtask_planned_hours for child_task in task.child_ids) + @api.depends('child_ids') + def _compute_child_text(self): + for task in self: + if not task.subtask_count: + task.child_text = False + elif task.subtask_count == 1: + task.child_text = _("(+ 1 task)") + else: + task.child_text = _("(+ %(child_count)s tasks)", child_count=task.subtask_count) + @api.depends('child_ids') def _compute_subtask_count(self): for task in self: @@ -964,6 +981,11 @@ class Task(models.Model): else: task.stage_id = False + @api.constrains('recurring_task') + def _check_recurring_task(self): + if self.filtered(lambda task: task.parent_id and task.recurring_task): + raise ValidationError(_("A sub-task cannot be a recurring task. Please consider making its parent task, a recurring task.")) + @api.returns('self', lambda value: value.id) def copy(self, default=None): if default is None: @@ -1066,6 +1088,8 @@ class Task(models.Model): default_project_id=project_id ).default_get(['stage_id']).get('stage_id') vals["stage_id"] = default_stage[project_id] + if project_id and "display_project_id" not in vals and not vals.get('parent_id'): + vals["display_project_id"] = project_id # user_id change: update date_assign if vals.get('user_id'): vals['date_assign'] = fields.Datetime.now() @@ -1103,6 +1127,8 @@ class Task(models.Model): # user_id change: update date_assign if vals.get('user_id') and 'date_assign' not in vals: vals['date_assign'] = now + if vals.get('display_project_id') and 'project_id' not in vals: + vals['project_id'] = vals['display_project_id'] # recurrence fields rec_fields = vals.keys() & self._get_recurrence_fields() @@ -1134,6 +1160,12 @@ class Task(models.Model): # rating on stage if 'stage_id' in vals and vals.get('stage_id'): self.filtered(lambda x: x.project_id.rating_active and x.project_id.rating_status == 'stage')._send_task_rating_mail(force_send=True) + if 'project_id' in vals and 'display_project_id' not in vals: + for task in self.filtered(lambda self: self.display_project_id or self.project_id != self.parent_id.project_id): + task.display_project_id = task.project_id + if 'display_project_id' in vals and not vals['display_project_id']: + for task in self: + task.project_id = task.parent_id.project_id return result def update_date_end(self, stage_id): @@ -1152,6 +1184,11 @@ class Task(models.Model): # Subtasks # --------------------------------------------------- + @api.depends('parent_id.user_id') + def _compute_user_id(self): + for task in self: + task.user_id = task.user_id or task.parent_id.user_id or self.env.uid + @api.depends('parent_id.partner_id', 'project_id.partner_id') def _compute_partner_id(self): """ @@ -1172,11 +1209,11 @@ class Task(models.Model): for task in self: task.email_from = task.partner_id.email or ((task.partner_id or task.parent_id) and task.email_from) or task.parent_id.email_from - @api.depends('parent_id.project_id.subtask_project_id') + @api.depends('parent_id.project_id') def _compute_project_id(self): for task in self: - if not task.project_id: - task.project_id = task.parent_id.project_id.subtask_project_id + if not task.project_id or not task.display_project_id: + task.project_id = task.parent_id.project_id # --------------------------------------------------- # Mail gateway @@ -1218,7 +1255,7 @@ class Task(models.Model): project_user_group_id = self.env.ref('project.group_project_user').id group_func = lambda pdata: pdata['type'] == 'user' and project_user_group_id in pdata['groups'] - if self.project_id.privacy_visibility == 'followers': + if self.project_privacy_visibility == 'followers': allowed_user_ids = self.project_id.allowed_internal_user_ids.partner_id.ids group_func = lambda pdata: pdata['type'] == 'user' and project_user_group_id in pdata['groups'] and pdata['id'] in allowed_user_ids new_group = ('group_project_user', group_func, {}) @@ -1356,31 +1393,18 @@ class Task(models.Model): 'res_model': 'project.task', 'res_id': self.parent_id.id, 'type': 'ir.actions.act_window', - 'context': dict(self._context, create=False) + 'context': self._context } - def action_subtask(self): - action = self.env["ir.actions.actions"]._for_xml_id("project.project_task_action_sub_task") - - # display all subtasks of current task - action['domain'] = [('id', 'child_of', self.id), ('id', '!=', self.id)] - - # update context, with all default values as 'quick_create' does not contains all field in its view - if self._context.get('default_project_id'): - default_project = self.env['project.project'].browse(self.env.context['default_project_id']) - else: - default_project = self.project_id.subtask_project_id or self.project_id - ctx = dict(self.env.context) - ctx = {k: v for k, v in ctx.items() if not k.startswith('search_default_')} - ctx.update({ - 'default_name': self.env.context.get('name', self.name) + ':', - 'default_parent_id': self.id, # will give default subtask field in `default_get` - 'default_company_id': default_project.company_id.id if default_project else self.env.company.id, - }) - - action['context'] = ctx - - return action + def action_open_task(self): + return { + 'name': _('View Task'), + 'view_mode': 'form', + 'res_model': 'project.task', + 'res_id': self.id, + 'type': 'ir.actions.act_window', + 'context': self._context + } # ------------ # Actions diff --git a/addons/project/models/project_task_recurrence.py b/addons/project/models/project_task_recurrence.py index 17cb4da0722..5e23ac1a124 100644 --- a/addons/project/models/project_task_recurrence.py +++ b/addons/project/models/project_task_recurrence.py @@ -208,7 +208,7 @@ class ProjectTaskRecurrence(models.Model): create_values = { field: value[0] if isinstance(value, tuple) else value for field, value in task_values.items() } - create_values['stage_id'] = task.project_id.type_ids[0].id if task.project_id.type_ids else task.stage_id + create_values['stage_id'] = task.project_id.type_ids[0].id if task.project_id.type_ids else task.stage_id.id create_values['user_id'] = False return create_values diff --git a/addons/project/static/src/css/project.css b/addons/project/static/src/css/project.css index 1d8b3588024..d883af59217 100644 --- a/addons/project/static/src/css/project.css +++ b/addons/project/static/src/css/project.css @@ -66,6 +66,11 @@ border-color: var(--danger); } +.o_kanban_project_tasks .o_field_one2many_sub_task { + margin-top:2px; + margin-right: 6px; +} + .o_form_project_tasks .ribbon:not(.o_invisible_modifier) + div > h1 > div[name="kanban_state"] { margin-right: 150px; padding-left: 1rem; @@ -88,3 +93,6 @@ width: 96%; float: left; } +.o_btn_onhover { + margin-left: -1rem; +} diff --git a/addons/project/static/src/js/project_form.js b/addons/project/static/src/js/project_form.js index 9d82d876793..6d6a0b8ef3f 100644 --- a/addons/project/static/src/js/project_form.js +++ b/addons/project/static/src/js/project_form.js @@ -94,9 +94,8 @@ odoo.define('project.ProjectFormView', function (require) { }), }).open(); } - }); - + const ProjectFormView = FormView.extend({ config: _.extend({}, FormView.prototype.config, { Controller: ProjectFormController, diff --git a/addons/project/static/src/js/project_name_with_subtask_count_widget.js b/addons/project/static/src/js/project_name_with_subtask_count_widget.js new file mode 100644 index 00000000000..48594c32c0b --- /dev/null +++ b/addons/project/static/src/js/project_name_with_subtask_count_widget.js @@ -0,0 +1,23 @@ +odoo.define('project.name_with_subtask_count', function (require) { + "use strict"; + + const fieldRegistry = require('web.field_registry'); + const FieldChar = require('web.basic_fields').FieldChar; + + const FieldNameWithSubTaskCount = FieldChar.extend({ + _render: function () { + let result = this._super.apply(this, arguments); + if (this.recordData.child_text) { + this.$el.append($('') + .addClass("text-muted ml-2") + .text(this.recordData.child_text) + .css('font-weight', 'normal')); + } + return result; + } + }); + + fieldRegistry.add('name_with_subtask_count', FieldNameWithSubTaskCount); + + return FieldNameWithSubTaskCount; +}); diff --git a/addons/project/tests/test_multicompany.py b/addons/project/tests/test_multicompany.py index b63072758e7..b68d26c4110 100644 --- a/addons/project/tests/test_multicompany.py +++ b/addons/project/tests/test_multicompany.py @@ -5,7 +5,6 @@ from contextlib import contextmanager from odoo.tests.common import TransactionCase, Form from odoo.exceptions import AccessError, UserError - class TestMultiCompanyCommon(TransactionCase): @classmethod @@ -117,7 +116,6 @@ class TestMultiCompanyCommon(TransactionCase): context = dict(self.env.context, allowed_company_ids=old_companies) self.env = self.env(context=context) - class TestMultiCompanyProject(TestMultiCompanyCommon): @classmethod @@ -252,13 +250,9 @@ class TestMultiCompanyProject(TestMultiCompanyCommon): with self.sudo('employee-a'): with self.allow_companies([self.company_a.id, self.company_b.id]): # create subtask, set parent; the onchange will set the correct company and subtask project - with Form(self.env['project.task'].with_context({'tracking_disable': True})) as task_form: + with Form(self.env['project.task'].with_context({'tracking_disable': True, 'default_parent_id': self.task_1.id, 'default_project_id': self.project_company_b.id})) as task_form: task_form.name = 'Test Subtask in company B' - task_form.parent_id = self.task_1 - task_form.project_id = self.project_company_b - task = task_form.save() - self.assertEqual(task.company_id, self.project_company_b.company_id, "The company of the subtask should be the one from its project, and not from its parent.") # set parent on existing orphan task; the onchange will set the correct company and subtask project @@ -272,7 +266,7 @@ class TestMultiCompanyProject(TestMultiCompanyCommon): def test_cross_subtask_project(self): # set up default subtask project - self.project_company_a.write({'allow_subtasks': True, 'subtask_project_id': self.project_company_b.id}) + self.project_company_a.write({'allow_subtasks': True}) with self.sudo('employee-a'): with self.allow_companies([self.company_a.id, self.company_b.id]): @@ -282,8 +276,8 @@ class TestMultiCompanyProject(TestMultiCompanyCommon): task = task_form.save() - self.assertEqual(task.project_id, self.task_1.project_id.subtask_project_id, "The default project of a subtask should be the default subtask project of the project from the mother task") - self.assertEqual(task.company_id, task.project_id.subtask_project_id.company_id, "The company of the orphan subtask should be the one from its project.") + self.assertEqual(task.project_id, self.task_1.project_id, "The default project of a subtask should be the default subtask project of the project from the mother task") + self.assertEqual(task.company_id, task.project_id.company_id, "The company of the orphan subtask should be the one from its project.") self.assertEqual(self.task_1.child_ids.ids, [task.id]) with self.sudo('employee-a'): diff --git a/addons/project/tests/test_project_flow.py b/addons/project/tests/test_project_flow.py index b5d5e277054..175221890b9 100644 --- a/addons/project/tests/test_project_flow.py +++ b/addons/project/tests/test_project_flow.py @@ -138,6 +138,7 @@ class TestProjectFlow(TestProjectCommon): 'name': 'Task Child with project', 'parent_id': parent_task.id, 'project_id': self.project_goats.id, + 'display_project_id': self.project_goats.id, 'planned_hours': 3, }) @@ -146,6 +147,7 @@ class TestProjectFlow(TestProjectCommon): 'name': 'Task Child without project', 'parent_id': parent_task.id, 'project_id': self.project_pigs.id, + 'display_project_id': self.project_pigs.id, 'planned_hours': 5, }) diff --git a/addons/project/views/project_assets.xml b/addons/project/views/project_assets.xml index fdec23a8dfd..762bc909bfa 100644 --- a/addons/project/views/project_assets.xml +++ b/addons/project/views/project_assets.xml @@ -7,6 +7,7 @@ + diff --git a/addons/project/views/project_portal_templates.xml b/addons/project/views/project_portal_templates.xml index 3c4298ce131..e4a2b42e5f8 100644 --- a/addons/project/views/project_portal_templates.xml +++ b/addons/project/views/project_portal_templates.xml @@ -54,7 +54,7 @@ - + @@ -79,7 +79,7 @@ - + diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index c5119628dce..26ea6fc6d1b 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -21,8 +21,7 @@ - - + @@ -56,7 +55,7 @@ Tasks project.task kanban,tree,form,calendar,pivot,graph,activity - [('project_id', '=', active_id)] + [('display_project_id', '=', active_id)] { 'pivot_row_groupby': ['user_id'], 'default_project_id': active_id, @@ -72,21 +71,6 @@ - - Sub-tasks - project.task - tree,kanban,form,calendar,pivot,graph,activity - - -

- No tasks found. Let's create one! -

- To get things done, use activities and status on tasks.
- Chat in real time or by email to collaborate efficiently. -

-
-
- project.task.type.search @@ -366,10 +350,6 @@
Split your tasks to organize your work into sub-milestones
-
-
@@ -441,7 +421,6 @@ - @@ -664,7 +643,7 @@

Edit recurring task

@@ -672,10 +651,7 @@
- + @@ -699,18 +675,19 @@
- + + - - + @@ -727,6 +704,26 @@ + + + + + + + + + + + + + + + + +