From fb88a7448c75fdcbe1d9c3664402a1dea2733326 Mon Sep 17 00:00:00 2001 From: "Audric Onockx (auon)" Date: Wed, 12 Jul 2023 11:57:26 +0000 Subject: [PATCH] [IMP] project,_*: allow all project features on tasks w/o project _*: hr_timesheet,sale_project,sale_timesheet Generally, you don't want subtasks to be displayed at the same level as their parent tasks. You want to consider the subtasks as part of their parent's project, but don't want to see them directly in this project. Rather, you want them to be accessible only via their parent. (There are exceptions to that and we want to stay flexible.) First solution that comes to mind is to have `project_id` set to False for the latter tasks, but then how to get the value of the fields that are related to the project? So, second solution would be to have a field `project_root_id`, which would be the project of the parent, or the grand-parent, etc. The issue now is that we have to fields "project", and it isn't obvious when to use one or the other. The most simple way to answer this need is to keep one field "project", that will always be set for (non-private) tasks, and create a boolean field : `display_in_project`. But we want it to be technical (no checkbox in the view). So, when the user unsets the project on a subtask, the view will act as if the project was unset, but in the back-end, we'll set `display_in_project` to False and set `project_id` back. The fact that all tasks have a project allows us to know if action x can be perform on this task t, dependind on t.project_id.allow_x. task-3367246 closes odoo/odoo#128281 Related: odoo/enterprise#43996 Related: odoo/upgrade#4930 Signed-off-by: Xavier Bol (xbo) --- addons/hr_timesheet/models/hr_timesheet.py | 14 +- addons/hr_timesheet/models/project_task.py | 31 +-- addons/hr_timesheet/tests/test_timesheet.py | 41 ++-- .../hr_timesheet/views/project_task_views.xml | 2 +- addons/project/__manifest__.py | 2 +- addons/project/models/project_task.py | 188 +++++++----------- addons/project/populate/project.py | 3 +- addons/project/report/project_report.py | 1 - addons/project/security/project_security.xml | 2 +- .../project_many2one_field.js | 40 ++++ .../project_many2one_field.scss | 4 + .../project_many2one_field.xml} | 8 +- .../project_private_task_many2one_field.js | 26 --- .../project_private_task_many2one_field.scss | 4 - .../subtask_kanban_list.js | 2 +- addons/project/tests/test_multicompany.py | 9 +- addons/project/tests/test_project_base.py | 32 --- addons/project/tests/test_project_subtasks.py | 49 +---- .../project_sharing_project_task_views.xml | 6 +- addons/project/views/project_task_views.xml | 49 +++-- .../models/account_analytic.py | 4 +- addons/sale_project/models/project.py | 8 +- addons/sale_project/tests/test_child_tasks.py | 50 ++--- .../sale_project/views/project_task_views.xml | 6 +- addons/sale_timesheet/models/project.py | 4 +- .../tests/test_project_billing.py | 3 +- .../tests/test_sale_timesheet.py | 10 +- 27 files changed, 244 insertions(+), 354 deletions(-) create mode 100644 addons/project/static/src/components/project_many2one_field/project_many2one_field.js create mode 100644 addons/project/static/src/components/project_many2one_field/project_many2one_field.scss rename addons/project/static/src/components/{project_private_task_many2one_field/project_private_task_many2one_field.xml => project_many2one_field/project_many2one_field.xml} (54%) delete mode 100644 addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.js delete mode 100644 addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.scss diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index c9b01fe005c..231af5b389e 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -50,7 +50,7 @@ class AccountAnalyticLine(models.Model): task_id = fields.Many2one( 'project.task', 'Task', index='btree_not_null', compute='_compute_task_id', store=True, readonly=False, - domain="[('company_id', '=', company_id), ('project_root_id.allow_timesheets', '=', True), ('project_root_id', '=?', project_id)]") + domain="[('company_id', '=', company_id), ('project_id.allow_timesheets', '=', True), ('project_id', '=?', project_id)]") parent_task_id = fields.Many2one('project.task', related='task_id.parent_id', store=True) project_id = fields.Many2one( 'project.project', 'Project', domain=_domain_project_id, index=True, @@ -98,9 +98,9 @@ class AccountAnalyticLine(models.Model): @api.depends('task_id') def _compute_project_id(self): for line in self: - if not line.task_id.project_root_id or line.project_id == line.task_id.project_root_id: + if not line.task_id.project_id or line.project_id == line.task_id.project_id: continue - line.project_id = line.task_id.project_root_id + line.project_id = line.task_id.project_id @api.depends('project_id') def _compute_task_id(self): @@ -112,7 +112,7 @@ class AccountAnalyticLine(models.Model): # TODO KBA in master - check to do it "properly", currently: # This onchange is used to reset the task_id when the project changes. # Doing it in the compute will remove the task_id when the project of a task changes. - if self.project_id != self.task_id.project_root_id: + if self.project_id != self.task_id.project_id: self.task_id = False @api.depends('employee_id') @@ -319,9 +319,9 @@ class AccountAnalyticLine(models.Model): tasks = self.env['project.task'].sudo().browse(task_ids) for task in tasks: task_per_id[task.id] = task - if not task.project_root_id: + if not task.project_id: raise ValidationError(_('Timesheets cannot be created on a private task.')) - account_ids = account_ids.union(tasks.analytic_account_id.ids, tasks.project_root_id.analytic_account_id.ids) + account_ids = account_ids.union(tasks.analytic_account_id.ids, tasks.project_id.analytic_account_id.ids) project_per_id = {} if project_ids: @@ -341,7 +341,7 @@ class AccountAnalyticLine(models.Model): vals = vals_list[index] data = task_per_id[vals['task_id']] if vals.get('task_id') else project_per_id[vals['project_id']] if not vals.get('project_id'): - vals['project_id'] = data.project_root_id.id + vals['project_id'] = data.project_id.id if not vals.get('account_id'): account = data._get_task_analytic_account_id() if vals.get('task_id') else data.analytic_account_id if not account or not account.active: diff --git a/addons/hr_timesheet/models/project_task.py b/addons/hr_timesheet/models/project_task.py index bfec13fddf2..7109e9bf552 100644 --- a/addons/hr_timesheet/models/project_task.py +++ b/addons/hr_timesheet/models/project_task.py @@ -26,9 +26,8 @@ class Task(models.Model): _inherit = "project.task" project_id = fields.Many2one(domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id), ('is_internal_project', '=', False)]") - project_root_id = fields.Many2one(domain=[('is_internal_project', '=', False)]) analytic_account_active = fields.Boolean("Active Analytic Account", compute='_compute_analytic_account_active', compute_sudo=True, recursive=True) - allow_timesheets = fields.Boolean("Allow timesheets", related="project_root_id.allow_timesheets", help="Timesheets can be logged on this task.", readonly=True, recursive=True) + allow_timesheets = fields.Boolean("Allow timesheets", related="project_id.allow_timesheets", help="Timesheets can be logged on this task.", readonly=True, recursive=True) remaining_hours = fields.Float("Remaining Hours", compute='_compute_remaining_hours', store=True, readonly=True, help="Number of allocated hours minus the number of hours spent.") remaining_hours_percentage = fields.Float(compute='_compute_remaining_hours_percentage', search='_search_remaining_hours_percentage') effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True) @@ -49,13 +48,19 @@ class Task(models.Model): def SELF_READABLE_FIELDS(self): return super().SELF_READABLE_FIELDS | PROJECT_TASK_READABLE_FIELDS + @api.constrains('project_id') + def _check_project_root(self): + private_tasks = self.filtered(lambda t: not t.project_id) + if private_tasks and self.env['account.analytic.line'].sudo().search_count([('task_id', 'in', private_tasks.ids)], limit=1): + raise UserError(_("This task cannot be private because there are some timesheets linked to it.")) + def _uom_in_days(self): return self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day') def _compute_encode_uom_in_days(self): self.encode_uom_in_days = self._uom_in_days() - @api.depends('analytic_account_id.active', 'project_root_id.analytic_account_id.active') + @api.depends('analytic_account_id.active', 'project_id.analytic_account_id.active') def _compute_analytic_account_active(self): """ Overridden in sale_timesheet """ for task in self: @@ -161,26 +166,6 @@ class Task(models.Model): # Is override in sale_timesheet return self.timesheet_ids - def write(self, values): - # a timesheet must have an analytic account (and a project) - is_removed_project = 'project_id' in values and not values['project_id'] - is_removed_parent = 'parent_id' in values and not values['parent_id'] - if ( - ((is_removed_project and (not self.parent_id or is_removed_parent)) - or (is_removed_parent and not (self.project_id or is_removed_project))) - and self._get_timesheet() - ): - raise UserError(_('This task must be part of a project because there are some timesheets linked to it.')) - res = super(Task, self).write(values) - - if 'project_id' in values: - project = self.env['project.project'].browse(values.get('project_id')) - if project.allow_timesheets: - # We write on all non yet invoiced timesheet the new project_id (if project allow timesheet) - self._get_timesheet().write({'project_id': values.get('project_id')}) - - return res - @api.depends('allow_timesheets', 'planned_hours', 'encode_uom_in_days', 'remaining_hours') @api.depends_context('hr_timesheet_display_remaining_hours') def _compute_display_name(self): diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py index cfabaf0f511..13ba65d948a 100644 --- a/addons/hr_timesheet/tests/test_timesheet.py +++ b/addons/hr_timesheet/tests/test_timesheet.py @@ -291,17 +291,21 @@ class TestTimesheet(TestCommonTimesheet): timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_customer.id)]) timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_customer2.id)]) - self.assertEqual(timesheet_count1, 2, "There are still timesheets linked to Project1") - self.assertEqual(timesheet_count2, 1, "1 timesheets should be linked to Project2") + self.assertEqual(timesheet_count1, 3, "3 timesheets should be linked to Project1") + self.assertEqual(timesheet_count2, 0, "No timesheets should be linked to Project2") self.assertEqual(len(self.task1.timesheet_ids), 1, "The timesheet still should be linked to task1") self.assertEqual(len(task_child.timesheet_ids), 1, "The timesheet still should be linked to task_child") self.assertEqual(len(task_grandchild.timesheet_ids), 1, "The timesheet still should be linked to task_grandchild") - # it is forbidden to set a task with timesheet without project + # It is forbidden to unset the project of a task with timesheet... with self.assertRaises(UserError): self.task1.write({ 'project_id': False }) + # ...except if one of its ascendant has one. + task_child.write({ + 'project_id': False + }) def test_recompute_amount_for_multiple_timesheets(self): """ Check that amount is recomputed correctly when setting unit_amount for multiple timesheets at once. """ @@ -363,7 +367,6 @@ class TestTimesheet(TestCommonTimesheet): def test_task_with_timesheet_project_change(self): '''This test checks that no error is raised when moving a task that contains timesheet to another project. - This move implying writing on the account.analytic.line. ''' project_manager = self.env['res.users'].create({ @@ -403,7 +406,7 @@ class TestTimesheet(TestCommonTimesheet): 'project_id': second_project.id }) - self.assertEqual(timesheet.project_id, second_project, 'The project_id of timesheet should be second_project') + self.assertEqual(timesheet.project_id, project, 'The project_id of timesheet shouldn\'t have changed') def test_create_timesheet_employee_not_in_company(self): ''' ts.employee_id only if the user has an employee in the company or one employee for all companies. @@ -484,40 +487,35 @@ class TestTimesheet(TestCommonTimesheet): 3) Enter the 8 hour timesheet in the child task 4) Check subtask Effective hours in parent task """ - subtask_with_project, subtask_without_project = self.env['project.task'].create([ + subtask_1, subtask_2 = self.env['project.task'].create([ { - 'name': 'Subtask with project set', + 'name': 'Subtask 1', 'project_id': self.project_customer.id, }, { - 'name': 'Suubtask without project set', - 'child_ids': [Command.create({'name': 'subsubtask without project set'})], + 'name': 'Subtask 2', + 'project_id': self.project_customer.id, + 'child_ids': [Command.create({'name': 'Subsubtask'})], }, ]) - subsubtask = subtask_without_project.child_ids - self.task1.child_ids = subtask_with_project + subtask_without_project + subsubtask = subtask_2.child_ids + self.task1.child_ids = subtask_1 + subtask_2 self.assertTrue(self.project_customer.allow_timesheets, 'The project should be timesheetable') - self.assertEqual(subtask_with_project.allow_timesheets, self.project_customer.allow_timesheets, 'The subtask should follow the settings of its project linked.') + self.assertEqual(subtask_1.allow_timesheets, self.project_customer.allow_timesheets, 'The subtask should follow the settings of its project linked.') Timesheet = self.env['account.analytic.line'] Timesheet.create({ 'name': 'FirstTimeSheet', 'project_id': self.project_customer.id, - 'task_id': subtask_with_project.id, + 'task_id': subtask_1.id, 'unit_amount': 8.0, 'employee_id': self.empl_employee2.id, }) self.assertEqual(self.task1.subtask_effective_hours, 8, 'Hours Spent on Sub-tasks should be 8 hours in Parent Task') - self.assertEqual(subtask_without_project.project_root_id, self.task1.project_id, 'The subtask without any project set should follow the settings of the project linked to its parent task.') - self.assertTrue(subtask_without_project.allow_timesheets, 'The subtask without any project set should be timesheetable because its parent task is timesheetable.') - - self.assertEqual(subsubtask.project_root_id, self.task1.project_id, 'The subtask without any project set should follow the first project linked to its ancestors.') - self.assertTrue(subsubtask.allow_timesheets, 'The subtask without any project set should be timesheetable because its ancestor is timesheetable.') - Timesheet.create([ { 'name': '/', - 'task_id': subtask_without_project.id, + 'task_id': subtask_2.id, 'unit_amount': 1.0, 'employee_id': self.empl_employee2.id, }, @@ -658,9 +656,6 @@ class TestTimesheet(TestCommonTimesheet): self.task1.parent_id = self.task2 self.task1.project_id = False - with self.assertRaises(UserError): - self.task1.parent_id = False - self.task1.project_id = self.project_customer with self.assertRaises(UserError): diff --git a/addons/hr_timesheet/views/project_task_views.xml b/addons/hr_timesheet/views/project_task_views.xml index 2a30bc760a5..b1bd3ffda94 100644 --- a/addons/hr_timesheet/views/project_task_views.xml +++ b/addons/hr_timesheet/views/project_task_views.xml @@ -38,7 +38,7 @@ You cannot log timesheets on this project since it is linked to an inactive analytic account. Please change this account, or reactivate the current one to timesheet on the project. - + diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 9c044756702..9ded0f5de02 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -196,7 +196,7 @@ 'project/static/src/components/project_task_name_with_subtask_count_char_field/*', 'project/static/src/components/project_task_state_selection/*', - 'project/static/src/components/project_private_task_many2one_field/*', + 'project/static/src/components/project_many2one_field/*', 'partner_autocomplete/static/src/js/partner_autocomplete_core.js', 'partner_autocomplete/static/src/js/partner_autocomplete_many2one.js', 'partner_autocomplete/static/src/xml/partner_autocomplete.xml', diff --git a/addons/project/models/project_task.py b/addons/project/models/project_task.py index 7e6aef0e175..a0d5cf9e5dd 100644 --- a/addons/project/models/project_task.py +++ b/addons/project/models/project_task.py @@ -19,9 +19,9 @@ PROJECT_TASK_READABLE_FIELDS = { 'active', 'priority', 'project_id', + 'display_in_project', 'color', 'subtask_count', - 'is_private', 'email_from', 'create_date', 'write_date', @@ -34,7 +34,6 @@ PROJECT_TASK_READABLE_FIELDS = { 'allow_milestones', 'milestone_id', 'has_late_and_unreached_milestone', - 'company_id', 'date_assign', 'dependent_ids', 'message_is_follower', @@ -155,7 +154,7 @@ class Task(models.Model): "Based on this information you can identify tasks that are stalling and get statistics on the time it usually takes to move tasks from one stage/state to another.") project_id = fields.Many2one('project.project', string='Project', domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id)]", index=True, tracking=True, change_default=True) - project_root_id = fields.Many2one('project.project', compute='_compute_project_root_id', search='_search_project_root_id', recursive=True) + display_in_project = fields.Boolean(default=True, readonly=True) task_properties = fields.Properties('Properties', definition='project_id.task_properties_definition', copy=True) planned_hours = fields.Float("Allocated Time", tracking=True) subtask_planned_hours = fields.Float("Sub-tasks Planned Hours", compute='_compute_subtask_planned_hours', @@ -203,7 +202,6 @@ class Task(models.Model): working_days_close = fields.Float(compute='_compute_elapsed', string='Working Days to Close', store=True, group_operator="avg") # 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', search='_search_is_private') allow_milestones = fields.Boolean(related='project_id.allow_milestones') milestone_id = fields.Many2one( 'project.milestone', @@ -268,7 +266,8 @@ class Task(models.Model): ) _sql_constraints = [ - ('recurring_task_has_no_parent', 'CHECK (NOT (recurring_task IS TRUE AND parent_id IS NOT NULL))', "A subtask cannot be recurrent.") + ('recurring_task_has_no_parent', 'CHECK (NOT (recurring_task IS TRUE AND parent_id IS NOT NULL))', "A subtask cannot be recurrent."), + ('private_task_has_no_parent', 'CHECK (NOT (project_id IS NULL AND parent_id IS NOT NULL))', "A private task cannot have a parent."), ] @api.constrains('company_id', 'partner_id') @@ -291,96 +290,6 @@ class Task(models.Model): for task in self: task.analytic_account_id = task.project_id.analytic_account_id - @api.depends('project_id', 'parent_id') - def _compute_is_private(self): - # Modify accordingly, this field is used to display the lock on the task's kanban card - for task in self: - task.is_private = not task.project_id and not task.parent_id - - @api.depends('project_id', 'parent_id.project_id') - def _compute_project_root_id(self): - # project of the first ascendant that has one. Let's call it the root task. - for task in self: - task.project_root_id = task.project_id or task.parent_id.project_root_id - - def _search_project_root_id(self, operator, value): - if operator not in ("in", "not in", "any", "not any", "=", "!=", "=?"): - raise UserError(_("Unsupported operator for search on project_root_id")) - - where_query_1 = where_query_2 = "TRUE" - params = tuple() - if operator in ("any", "not any"): - query = self.env['project.project']._where_calc(value) - subquery_str, params = query.subselect() - where_operator = 'in' if operator == 'any' else 'not in' - where_query_1 = f""" - project_id {where_operator} ({subquery_str}) - """ - where_query_2 = f""" - project_root_id {'in' if operator == 'any' else 'not in'} ({subquery_str}) - """ - elif operator != "=?" or (value is not False and value is not None): - where_operator = operator if operator != '=?' else '=' - where_query_1 = f"project_id {where_operator} %s" - where_query_2 = f"project_root_id {where_operator} %s" - if isinstance(value, (list, tuple)): - params = (tuple(value),) - else: - params = (value,) - - self.env.cr.execute( - f""" - SELECT ARRAY_AGG(id) - FROM project_task - WHERE {where_query_1} - AND project_id IS NOT NULL - """, params - ) - task_ids = self._cr.fetchone()[0] or [] - - self.env.cr.execute( - f""" - WITH RECURSIVE project_hierarchy AS ( - SELECT pt.id, - pt.parent_id, - pt.project_id, - pt.project_id AS project_root_id - FROM project_task pt - WHERE pt.project_id IS NULL - AND pt.parent_id IS NOT NULL - - UNION ALL - - SELECT ph.id, - pt.parent_id, - ph.project_id, - COALESCE(ph.project_root_id, pt.project_id) AS project_root_id - FROM project_hierarchy ph - JOIN project_task pt ON ph.parent_id = pt.id - ) - SELECT ARRAY_AGG(id) - FROM project_hierarchy - WHERE {where_query_2} - """, params - ) - if not self._cr.rowcount and not task_ids: - return expression.FALSE_DOMAIN - task_ids += self.env.cr.fetchone()[0] or [] - if not task_ids: - return expression.FALSE_DOMAIN - return [('id', 'in', task_ids)] - - def _search_is_private(self, operator, value): - if not isinstance(value, bool): - raise ValueError(_('Value should be True or False (not %s)'), value) - if operator not in ['=', '!=']: - raise NotImplementedError(_('Operation should be = or != (not %s)'), value) - domain = expression.normalize_domain([('project_id', '=', False), ('parent_id', '=', False)]) - if (operator == '=') != value: - domain.insert(0, expression.NOT_OPERATOR) - domain = expression.distribute_not(domain) - return domain - @api.depends('depend_on_ids.state', 'project_id.allow_task_dependencies') def _compute_state(self): for task in self: @@ -623,9 +532,10 @@ class Task(models.Model): @api.depends('project_id') def _compute_stage_id(self): 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)]) + project = task.project_id or task.parent_id.project_id + if project: + if project not in task.stage_id.project_ids: + task.stage_id = task.stage_find(project.id, [('fold', '=', False)]) else: task.stage_id = False @@ -948,6 +858,28 @@ class Task(models.Model): self.check_access_rights('create') default_stage = dict() for vals in vals_list: + project_id = vals.get('project_id') + if vals.get('user_ids'): + vals['date_assign'] = fields.Datetime.now() + if not (vals.get('parent_id') or project_id or self._context.get('default_project_id')): + user_ids = self._fields['user_ids'].convert_to_cache(vals.get('user_ids', []), self) + if self.env.user.id not in list(user_ids) + [SUPERUSER_ID]: + vals['user_ids'] = [Command.set(list(user_ids) + [self.env.user.id])] + if project_id: + # set the project => "I want to display the task in the project" + # => => set `display_in_project` to True + vals['display_in_project'] = vals.get('display_in_project', True) + elif vals.get('parent_id'): + # unset the project => 2 cases: + # 1) the task has no parent => "I want it to be private" => nothing to do + # 2) the task has a parent => "I don't want to display the task in the project" + # => set `project_id` to the one of its parent and `display_in_project` to False + project_id = self.browse(vals['parent_id']).project_id.id + vals.update({ + 'project_id': project_id, + 'display_in_project': False, + }) + if default_personal_stage and 'personal_stage_type_id' not in vals: vals['personal_stage_type_id'] = default_personal_stage[0] if not vals.get('name') and vals.get('display_name'): @@ -955,7 +887,6 @@ class Task(models.Model): if is_portal_user: self._ensure_fields_are_accessible(vals.keys(), operation='write', check_group_user=False) - project_id = vals.get('project_id') or self.env.context.get('default_project_id') if project_id and not "company_id" in vals: vals["company_id"] = self.env["project.project"].browse( project_id @@ -973,12 +904,6 @@ class Task(models.Model): ).default_get(['stage_id']).get('stage_id') vals["stage_id"] = default_stage[project_id] # user_ids change: update date_assign - if vals.get('user_ids'): - vals['date_assign'] = fields.Datetime.now() - if not project_id: - user_ids = self._fields['user_ids'].convert_to_cache(vals.get('user_ids', []), self) - if self.env.user.id not in list(user_ids) + [SUPERUSER_ID]: - vals['user_ids'] = [Command.set(list(user_ids) + [self.env.user.id])] # Stage change: Update date_end if folded stage and date_last_stage_update if vals.get('stage_id'): vals.update(self.update_date_end(vals['stage_id'])) @@ -1032,10 +957,49 @@ class Task(models.Model): self.check_access_rule('write') portal_can_write = True - now = fields.Datetime.now() - if 'parent_id' in vals and vals['parent_id'] in self.ids: - raise UserError(_("Sorry. You can't set a task as its parent task.")) + if 'project_id' in vals: + project_id = vals['project_id'] + if project_id: + # set the project => "I want to display the task in the project" + # => set `display_in_project` to True + if 'display_in_project' not in vals: + vals['display_in_project'] = True + no_display_subtasks = self.child_ids.filtered(lambda t: not t.display_in_project) + if no_display_subtasks: + no_display_subtasks.write({'project_id': project_id}) + else: + # unset the project => 2 cases: + # 1) the task has no parent => "I want it to be private" => nothing to do + # 2) the task has a parent => "I don't want to display the task in the project" + # => set `project_id` back and `display_in_project` to False + if 'parent_id' in vals: + if vals['parent_id']: + vals.update({ + 'project_id': self.browse(vals['parent_id']).project_id.id, + 'display_in_project': False, + }) + else: + task_ids_per_parent_project_id = defaultdict(list) + for task in self: + task_ids_per_parent_project_id[task.parent_id.project_id.id].append(task.id) + self = self.browse(task_ids_per_parent_project_id.pop(False, False)) + for parent_project_id, task_ids in task_ids_per_parent_project_id.items(): + self.browse(task_ids).write({ + **vals, + 'project_id': parent_project_id, + 'display_in_project': False, + }) + + if 'parent_id' in vals: + parent_id = vals['parent_id'] + if parent_id in self.ids: + raise UserError(_("Sorry. You can't set a task as its parent task.")) + elif not parent_id: + # unset the parent => "I want to display the task back in the project" + # => set `display_in_project` to True + vals['display_in_project'] = True # stage change: update date_last_stage_update + now = fields.Datetime.now() if 'stage_id' in vals: if not 'project_id' in vals and self.filtered(lambda t: not t.project_id): raise UserError(_('You can only set a personal stage on a private task.')) @@ -1127,14 +1091,14 @@ class Task(models.Model): # Subtasks # --------------------------------------------------- - @api.depends('parent_id.partner_id', 'project_id', 'is_private') + @api.depends('parent_id.partner_id', 'project_id') def _compute_partner_id(self): """ Compute the partner_id when the tasks have no partner_id. Use the project partner_id if any, or else the parent task partner_id. """ for task in self: - if task.partner_id and task.is_private: + if task.partner_id and not (task.project_id or task.parent_id): task.partner_id = False continue if not task.partner_id: @@ -1143,7 +1107,7 @@ class Task(models.Model): @api.depends('project_id') def _compute_milestone_id(self): for task in self: - if task.project_id != task.milestone_id.project_id: + if (task.project_id or task.parent_id.project_id) != task.milestone_id.project_id: task.milestone_id = False def _compute_has_late_and_unreached_milestone(self): @@ -1614,7 +1578,7 @@ class Task(models.Model): # --------------------------------------------------- def _get_task_analytic_account_id(self): self.ensure_one() - return self.analytic_account_id or self.project_root_id.analytic_account_id + return self.analytic_account_id or self.project_id.analytic_account_id @api.model def get_unusual_days(self, date_from, date_to=None): diff --git a/addons/project/populate/project.py b/addons/project/populate/project.py index 7acf9059088..6d32f98ccac 100644 --- a/addons/project/populate/project.py +++ b/addons/project/populate/project.py @@ -117,7 +117,8 @@ class ProjectTask(models.Model): rand = populate.Random('project.task+children_generator') task_ids_per_company = collections.defaultdict(set) for task in tasks: - task_ids_per_company[task.company_id].add(task.id) + if task.project_id: + task_ids_per_company[task.company_id].add(task.id) for task_ids in task_ids_per_company.values(): parent_ids = set() diff --git a/addons/project/report/project_report.py b/addons/project/report/project_report.py index 2be1f410d38..274d6cd99e8 100644 --- a/addons/project/report/project_report.py +++ b/addons/project/report/project_report.py @@ -91,7 +91,6 @@ class ReportProjectTaskUser(models.Model): t.working_hours_open, t.working_hours_close, (extract('epoch' from (t.date_deadline-(now() at time zone 'UTC'))))/(3600*24) as delay_endings_days, - CASE WHEN t.project_id IS NOT NULL OR t.parent_id IS NOT NULL THEN false ELSE true END as is_private, COUNT(td.task_id) as dependent_ids_count """ diff --git a/addons/project/security/project_security.xml b/addons/project/security/project_security.xml index 9747765798e..03bb0cfd94f 100644 --- a/addons/project/security/project_security.xml +++ b/addons/project/security/project_security.xml @@ -100,7 +100,7 @@ Project/Task: project manager: see all tasks linked to a project or its own tasks [ - '|', ('is_private', '=', False), + '|', ('project_id', '!=', False), ('user_ids', 'in', user.id), ] diff --git a/addons/project/static/src/components/project_many2one_field/project_many2one_field.js b/addons/project/static/src/components/project_many2one_field/project_many2one_field.js new file mode 100644 index 00000000000..95365f9b653 --- /dev/null +++ b/addons/project/static/src/components/project_many2one_field/project_many2one_field.js @@ -0,0 +1,40 @@ +/** @odoo-module */ + +import { registry } from '@web/core/registry'; +import { Many2OneField, many2OneField } from '@web/views/fields/many2one/many2one_field'; + +export class ProjectMany2OneField extends Many2OneField { + get Many2XAutocompleteProps() { + const props = super.Many2XAutocompleteProps; + const { project_id, parent_id } = this.props.record.data; + if (!project_id && !parent_id) { + props.placeholder = this.env._t("Private"); + } + return props; + } + + get displayName() { + const { project_id, display_in_project } = this.props.record.data; + return project_id && !display_in_project ? "" : super.displayName; + } + + updateRecord(value) { + const { project_id, display_in_project } = this.props.record.data; + if (!display_in_project && value && value[0] === project_id[0]) { + this.props.record.update({ "display_in_project": true }); + } + super.updateRecord(value); + } +} +ProjectMany2OneField.template = 'project.ProjectMany2OneField'; + +export const projectMany2OneField = { + ...many2OneField, + component: ProjectMany2OneField, + fieldDependencies: [ + ...(many2OneField.fieldDependencies || []), + { name: "display_in_project", type: "boolean" }, + ], +}; + +registry.category("fields").add("project", projectMany2OneField); diff --git a/addons/project/static/src/components/project_many2one_field/project_many2one_field.scss b/addons/project/static/src/components/project_many2one_field/project_many2one_field.scss new file mode 100644 index 00000000000..8421e4f4276 --- /dev/null +++ b/addons/project/static/src/components/project_many2one_field/project_many2one_field.scss @@ -0,0 +1,4 @@ +.private_placeholder input::placeholder { + color: $red; + font-style: italic; +} diff --git a/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.xml b/addons/project/static/src/components/project_many2one_field/project_many2one_field.xml similarity index 54% rename from addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.xml rename to addons/project/static/src/components/project_many2one_field/project_many2one_field.xml index baf842a5345..0818368e81e 100644 --- a/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.xml +++ b/addons/project/static/src/components/project_many2one_field/project_many2one_field.xml @@ -1,21 +1,21 @@ - + props.record.data[props.name] - Private + Private displayName - Private + Private { - project_private_task_many2one_field: props.record.data.is_private, + private_placeholder: !props.record.data.parent_id && !props.record.data.project_id, } diff --git a/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.js b/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.js deleted file mode 100644 index 400a850b270..00000000000 --- a/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.js +++ /dev/null @@ -1,26 +0,0 @@ -/** @odoo-module */ - -import { registry } from '@web/core/registry'; -import { Many2OneField, many2OneField } from '@web/views/fields/many2one/many2one_field'; - -export class ProjectPrivateTaskMany2OneField extends Many2OneField { - get Many2XAutocompleteProps() { - const props = super.Many2XAutocompleteProps; - if (this.props.record.data.is_private) { - props.placeholder = this.env._t("Private"); - } - return props; - } -} -ProjectPrivateTaskMany2OneField.template = 'project.ProjectPrivateTaskMany2OneField'; - -export const projectPrivateTaskMany2OneField = { - ...many2OneField, - component: ProjectPrivateTaskMany2OneField, - fieldDependencies: [ - ...(many2OneField.fieldDependencies || []), - { name: "is_private", type: "boolean" }, - ], -}; - -registry.category("fields").add("project_private_task", projectPrivateTaskMany2OneField); diff --git a/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.scss b/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.scss deleted file mode 100644 index bf6ff0d1270..00000000000 --- a/addons/project/static/src/components/project_private_task_many2one_field/project_private_task_many2one_field.scss +++ /dev/null @@ -1,4 +0,0 @@ -.project_private_task_many2one_field input::placeholder { - color: $red; - font-style: italic; -} diff --git a/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.js b/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.js index a6c01f72364..073b8fddcaf 100644 --- a/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.js +++ b/addons/project/static/src/components/subtask_kanban_list/subtask_kanban_list.js @@ -68,7 +68,7 @@ export class SubtaskKanbanList extends Component { field: getFieldFromRegistry(this.fields.user_ids.type, "many2many_avatar_user", "kanban"), }, project_id: { - field: getFieldFromRegistry(this.fields.project_id.type, "project_private_task", "kanban") + field: getFieldFromRegistry(this.fields.project_id.type, "project", "kanban") }, }; } diff --git a/addons/project/tests/test_multicompany.py b/addons/project/tests/test_multicompany.py index 11922eac2cf..d5465acfe3a 100644 --- a/addons/project/tests/test_multicompany.py +++ b/addons/project/tests/test_multicompany.py @@ -432,8 +432,6 @@ class TestMultiCompanyProject(TestMultiCompanyCommon): 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 - self.task_2.write({'project_id': False}) # For `parent_id` to be visible in the view, you need # 1. The debug mode # @@ -446,10 +444,11 @@ class TestMultiCompanyProject(TestMultiCompanyCommon): with Form(self.task_2) as task_form: task_form.name = 'Test Task 2 becomes child of Task 1 (other company)' task_form.parent_id = self.task_1 + task_form.project_id = self.env['project.project'] task = task_form.save() - self.assertFalse(task.project_id, "The subtask should not have any project set") - self.assertEqual(task.company_id, self.task_1.company_id, "The company of the subtask should be the one from its parent when no project is set.") + self.assertEqual(task.project_id, task.parent_id.project_id, "The subtask should have the same project as its parents") + self.assertEqual(task.company_id, task.parent_id.company_id, "The company of the subtask should be the one from its parent when no project is set.") def test_cross_subtask_project(self): @@ -471,7 +470,7 @@ class TestMultiCompanyProject(TestMultiCompanyCommon): task = task_form.save() - self.assertFalse(task.project_id, "No project should be set on the subtask by default") + self.assertEqual(task.project_id, task.parent_id.project_id, "The subtask should have the same project as its parents") self.assertEqual(task.company_id, task.parent_id.company_id, "The company of the subtask should be the one from its parent.") self.assertEqual(self.task_1.child_ids.ids, [task.id]) diff --git a/addons/project/tests/test_project_base.py b/addons/project/tests/test_project_base.py index 8db544d07ed..731928bc4da 100644 --- a/addons/project/tests/test_project_base.py +++ b/addons/project/tests/test_project_base.py @@ -233,38 +233,6 @@ class TestProjectBase(TestProjectCommon): partner.company_id = company_1 self.assertEqual(partner.company_id, company_1, "The company of the partner should have been updated.") - def test_search_project_root_id(self): - project = self.env['project.project'].create({ - 'name': 'Test project', - 'allow_milestones': False, - }) - ProjectTask = self.env['project.task'] - parent = ProjectTask.create({ - 'name': 'Test task', - 'project_id': project.id, - }) - child = ProjectTask.create({ - 'name': 'Test subtask', - 'parent_id': parent.id, - }) - tasks = parent | child - - other_projects = self.project_goats + self.project_pigs - other_projects.allow_milestones = True - # Restrict all searches to the three test projects to avoid interacting with other data - base_domain = [('project_root_id', 'in', [project.id] + other_projects.ids)] - - self.assertFalse(child.project_id) - self.assertEqual(child.project_root_id, parent.project_id) - self.assertEqual(parent.project_root_id, parent.project_id) - self.assertEqual(ProjectTask.search(expression.AND([base_domain, [('project_root_id', '=', project.id)]])), tasks) - self.assertEqual(ProjectTask.search(expression.AND([base_domain, [('project_root_id', 'in', project.ids)]])), tasks) - self.assertEqual(ProjectTask.search(expression.AND([base_domain, [('project_root_id.allow_milestones', '=', False)]])), tasks) - self.assertEqual(ProjectTask.search(expression.AND([base_domain, ['!', ('project_root_id.allow_milestones', '=', True)]])), tasks) - self.assertEqual(ProjectTask.search(expression.AND([base_domain, [('project_root_id', '=?', project.id)]])), tasks) - self.assertEqual(ProjectTask.search(expression.AND([base_domain, [('project_root_id', 'not in', other_projects.ids), ('id', 'in', tasks.ids)]])), tasks) - self.assertEqual(ProjectTask.search(expression.AND([base_domain, [('project_root_id', '!=', self.project_pigs.id), ('id', 'in', tasks.ids)]])), tasks) - def test_add_customer_rating_project(self): """ Tests that the rating_ids field contains a rating once created """ diff --git a/addons/project/tests/test_project_subtasks.py b/addons/project/tests/test_project_subtasks.py index ccd45f93330..7aa94c5a5cf 100644 --- a/addons/project/tests/test_project_subtasks.py +++ b/addons/project/tests/test_project_subtasks.py @@ -75,9 +75,9 @@ class TestProjectSubtasks(TestProjectCommon): - Should not change parent project - Project should be correct 3) Reset the project to False - - Project should be correct + - Project should be inheritted from parent 4) Change parent task project - - Project should stay false + - Project should be inheritted from parent 5) Set project on subtask and change parent task project - Project should be the one set by the user 6) Remove parent task: @@ -91,7 +91,7 @@ class TestProjectSubtasks(TestProjectCommon): with task_form.child_ids.new() as child_task_form: child_task_form.name = 'Test Subtask 1' - self.assertFalse(self.task_1.child_ids.project_id, "The project should be False by default.") + self.assertEqual(self.task_1.child_ids.project_id, self.task_1.project_id, "The project should be inheritted from parent.") # 2) with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: @@ -106,14 +106,14 @@ class TestProjectSubtasks(TestProjectCommon): with task_form.child_ids.edit(0) as child_task_form: child_task_form.project_id = self.env['project.project'] - self.assertFalse(self.task_1.child_ids.project_id, "The project of the subtask should be False") + self.assertEqual(self.task_1.child_ids.project_id, self.task_1.project_id, "The project of the subtask should be inheritted from parent") # 4) with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: task_form.project_id = self.project_goats self.assertEqual(self.task_1.project_id, self.project_goats, "Parent project should change.") - self.assertFalse(self.task_1.child_ids.project_id, "The project of the subtask should stay False") + self.assertEqual(self.task_1.child_ids.project_id, self.task_1.project_id, "The project of the subtask should stay False") # 5) with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: @@ -160,7 +160,7 @@ class TestProjectSubtasks(TestProjectCommon): with task_form.child_ids.new() as child_task_form: child_task_form.name = 'Test Subtask 1' - self.assertFalse(self.task_1.child_ids.stage_id, "No stage should be set on the subtask since no project is set by default.") + self.assertEqual(self.task_1.child_ids.stage_id, stage_a, "Stage should be set on the subtask since it inheritted the project of its parent.") with Form(self.task_1.with_context({'tracking_disable': True})) as task_form: with task_form.child_ids.edit(0) as subtask_form: subtask_form.project_id = task_form.project_id @@ -249,42 +249,9 @@ class TestProjectSubtasks(TestProjectCommon): with Form(child_subtask.with_context(tracking_disable=True)) as subtask_form: with subtask_form.child_ids.new() as child_subtask_form: child_subtask_form.name = 'Test Subtask 2' - self.assertFalse(child_subtask_form.project_id) + self.assertEqual(child_subtask_form.project_id, subtask_form.project_id) + self.assertFalse(child_subtask_form.display_in_project) self.assertEqual(task.subtask_count, 1, "Parent task should have 1 children") task_2 = task.copy() self.assertEqual(task_2.subtask_count, 1, "If the parent task is duplicated then the sub task should be copied") - - def test_project_root_id(self): - task = self.env['project.task'].create({ - 'name': 'Task 1', - 'project_id': self.project_goats.id, - 'child_ids': [ - Command.create({ - 'name': 'Subtask 1', - 'child_ids': [Command.create({'name': 'Subsubtask 1'})], - }), - ], - }) - subtask = task.child_ids - subsubtask = subtask.child_ids - self.assertTrue(bool(subtask)) - self.assertTrue(bool(subsubtask)) - self.assertEqual(task.project_root_id, task.project_id) - self.assertFalse(subtask.project_id) - self.assertEqual(subtask.project_root_id, task.project_id) - self.assertFalse(subsubtask.project_id) - self.assertEqual(subsubtask.project_root_id, task.project_id) - - subtask.project_id = self.project_pigs - self.assertNotEqual(subtask.project_root_id, task.project_id) - self.assertEqual(subtask.project_root_id, self.project_pigs) - self.assertFalse(subsubtask.project_id) - self.assertNotEqual(subsubtask.project_root_id, task.project_id) - self.assertEqual(subsubtask.project_root_id, subtask.project_id) - - subtask.project_id = False - subsubtask.project_id = self.project_pigs - self.assertEqual(subtask.project_root_id, task.project_id) - self.assertNotEqual(subsubtask.project_root_id, task.project_id) - self.assertEqual(subsubtask.project_root_id, self.project_pigs) diff --git a/addons/project/views/project_sharing_project_task_views.xml b/addons/project/views/project_sharing_project_task_views.xml index da2a8aa143b..bbae420d36f 100644 --- a/addons/project/views/project_sharing_project_task_views.xml +++ b/addons/project/views/project_sharing_project_task_views.xml @@ -182,7 +182,7 @@ - + @@ -205,7 +205,7 @@ -