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 @@ -