From 7563b448da7a973128aa1901db5235dfb956ff74 Mon Sep 17 00:00:00 2001 From: William Braeckman Date: Mon, 19 Jul 2021 14:55:59 +0000 Subject: [PATCH] [IMP] project: add personal task stages and multiple assignments Adds personal stages to tasks. Users assigned to tasks will be able to have their own pipeline to handle their tasks independently of the project's pipeline. The assignee field on Tasks has also been changed to a Many2many to support multiple assignees on one task. The same table is used to store those information, essentially a triplet (task, user, stage), to ensure that: 1) personal stages only apply to tasks to which you are assigned to. 2) synchronizing to make sure that you don't have a personal stage for a task on which you are not assigned anymore. Alongside those changes, some minor changes have also been made: - Modified the task's tree view. - Renamed the Tasks menu to 'My Tasks'. - The default view is now the kanban view for the 'My Tasks' action. - project_id is not required anymore, tasks with no project are considered 'private', those tasks are only visible to those that are assigned to it. - Added tracking of both user_ids and depend_on_ids in the chatter. Closes odoo/odoo#74087 Task ID: 2398734 --- .../report/project_report_view.xml | 2 +- .../views/project_sharing_views.xml | 2 +- addons/hr_timesheet/views/project_views.xml | 2 +- addons/project/data/project_demo.xml | 124 ++++++++++++--- addons/project/models/__init__.py | 2 + addons/project/models/project.py | 150 +++++++++++++++--- .../project/models/project_task_recurrence.py | 2 +- .../models/project_task_stage_personal.py | 18 +++ addons/project/report/project_report.py | 5 +- .../project/report/project_report_views.xml | 2 +- .../project_task_burndown_chart_report.py | 6 +- addons/project/security/ir.model.access.csv | 3 +- addons/project/security/project_security.xml | 44 ++++- .../project/static/src/js/project_kanban.js | 101 +++++++++++- .../project/static/src/scss/project_form.scss | 5 + .../static/src/scss/project_widgets.scss | 19 +++ addons/project/tests/__init__.py | 1 + addons/project/tests/test_access_rights.py | 8 +- addons/project/tests/test_multicompany.py | 4 +- addons/project/tests/test_personal_stages.py | 91 +++++++++++ addons/project/tests/test_project_base.py | 4 +- addons/project/tests/test_project_flow.py | 6 +- addons/project/tests/test_project_sharing.py | 8 +- addons/project/tests/test_project_subtasks.py | 8 - .../project/tests/test_task_dependencies.py | 5 +- .../views/project_portal_templates.xml | 28 ++-- .../project/views/project_sharing_views.xml | 20 +-- addons/project/views/project_views.xml | 74 +++++---- addons/sale_project/models/sale_order.py | 2 +- addons/sale_project/tests/test_child_tasks.py | 42 ++--- .../sale_timesheet/data/sale_service_demo.xml | 6 +- .../sale_timesheet/tests/test_sale_service.py | 2 +- .../views/project_task_views.xml | 2 +- 33 files changed, 640 insertions(+), 158 deletions(-) create mode 100644 addons/project/models/project_task_stage_personal.py create mode 100644 addons/project/tests/test_personal_stages.py diff --git a/addons/hr_timesheet/report/project_report_view.xml b/addons/hr_timesheet/report/project_report_view.xml index cf1e959fac2..698c22704a7 100644 --- a/addons/hr_timesheet/report/project_report_view.xml +++ b/addons/hr_timesheet/report/project_report_view.xml @@ -42,7 +42,7 @@ - + diff --git a/addons/hr_timesheet/views/project_sharing_views.xml b/addons/hr_timesheet/views/project_sharing_views.xml index efbb9bad9ac..95f82d8be2e 100644 --- a/addons/hr_timesheet/views/project_sharing_views.xml +++ b/addons/hr_timesheet/views/project_sharing_views.xml @@ -147,7 +147,7 @@ project.task - + diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index 410188b785d..56e68eb062a 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -296,7 +296,7 @@ - + diff --git a/addons/project/data/project_demo.xml b/addons/project/data/project_demo.xml index fadbb16cf5f..2bba5e2e0a3 100644 --- a/addons/project/data/project_demo.xml +++ b/addons/project/data/project_demo.xml @@ -88,10 +88,88 @@ + + + 1 + Inbox + + + + 2 + Today + + + + 3 + This Week + + + + 4 + This Month + + + + 5 + Later + + + + 6 + Done + + + + + 7 + Canceled + + + + + + + 1 + Inbox + + + + 2 + Today + + + + 3 + This Week + + + + 4 + This Month + + + + 5 + Later + + + + 6 + Done + + + + + 7 + Canceled + + + + - + 0 Meeting Room Furnitures @@ -101,7 +179,7 @@ - + 0 Lunch Room: kitchen @@ -147,7 +225,7 @@ - + 0 Noise Reduction @@ -195,7 +273,7 @@ - + 0 Black Chairs for managers @@ -228,7 +306,7 @@ - + 1 Room 1: Decoration @@ -261,7 +339,7 @@ - + 1 Room 2: Decoration @@ -289,7 +367,7 @@ - + 1 Energy Certificate @@ -317,7 +395,7 @@ - + 1 New portal system @@ -366,7 +444,7 @@ - + 1 Document management @@ -375,7 +453,7 @@ - + 1 Social network integration @@ -404,7 +482,7 @@ - + 1 User interface improvements @@ -435,7 +513,7 @@ - + 1 Planning and budget @@ -447,7 +525,7 @@ 24.0 - + Basic outline 42.0 - + Create new components @@ -502,7 +580,7 @@ 14.0 - + Useablity review @@ -532,7 +610,7 @@ 12.0 - + 0 Customer analysis + Architecture @@ -562,7 +640,7 @@ 17 8.0 - + 1 Modifications asked by the customer @@ -610,7 +688,7 @@ 20 20.0 - + Office planning @@ -620,7 +698,7 @@ 20 35.0 - + Unit Testing @@ -665,7 +743,7 @@ - + 0 Entry Hall @@ -674,7 +752,7 @@ - + 0 Check Lift @@ -684,7 +762,7 @@ - + 1 Room 1: Paint @@ -696,7 +774,7 @@ - + 1 Bathroom diff --git a/addons/project/models/__init__.py b/addons/project/models/__init__.py index 2f66f8d42bc..83a5980fd1c 100644 --- a/addons/project/models/__init__.py +++ b/addons/project/models/__init__.py @@ -5,6 +5,8 @@ from . import analytic_account from . import project_milestone from . import project_project_stage from . import project_task_recurrence +# `project_task_stage_personal` has to be loaded before `project` +from . import project_task_stage_personal from . import project from . import project_collaborator from . import project_update diff --git a/addons/project/models/project.py b/addons/project/models/project.py index bfa20b1f942..02cf6c2beed 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -43,7 +43,7 @@ PROJECT_TASK_WRITABLE_FIELDS = { 'name', 'partner_id', 'partner_email', - 'user_id', + 'user_ids', 'date_deadline', 'tag_ids', 'sequence', @@ -53,7 +53,6 @@ PROJECT_TASK_WRITABLE_FIELDS = { 'parent_id', } - class ProjectTaskType(models.Model): _name = 'project.task.type' _description = 'Task Stage' @@ -97,6 +96,8 @@ class ProjectTaskType(models.Model): is_closed = fields.Boolean('Closing Stage', help="Tasks in this stage are considered as closed.") disabled_rating_warning = fields.Text(compute='_compute_disabled_rating_warning') + user_id = fields.Many2one('res.users', 'Stage Owner', index=True) + def unlink_wizard(self, stage_view=False): self = self.with_context(active_test=False) # retrieves all the projects with a least 1 task in that stage @@ -846,6 +847,10 @@ class Task(models.Model): stage_ids = stages._search(search_domain, order=order, access_rights_uid=SUPERUSER_ID) return stages.browse(stage_ids) + @api.model + def _read_group_personal_stage_type_ids(self, stages, domain, order): + return stages.search(['|', ('id', 'in', stages.ids), ('user_id', '=', self.env.user.id)]) + active = fields.Boolean(default=True) name = fields.Char(string='Title', tracking=True, required=True, index=True) description = fields.Html(string='Description') @@ -886,10 +891,24 @@ class Task(models.Model): 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.") - user_id = fields.Many2one('res.users', - string='Assigned to', - default=lambda self: self.env.uid, - index=True, tracking=True) + # Tracking of this field is done in the write function + user_ids = fields.Many2many('res.users', relation='project_task_user_rel', column1='task_id', column2='user_id', + string='Assignees', default=lambda self: self.env.user) + # Second Many2many containing the actual personal stage for the current user + # See project_task_stage_personal.py for the model defininition + personal_stage_type_ids = fields.Many2many('project.task.type', 'project_task_user_rel', column1='task_id', column2='stage_id', + ondelete='restrict', group_expand='_read_group_personal_stage_type_ids', + domain="[('user_id', '=', user.id)]", depends=['user_ids'], string='Personal Stage') + # Personal Stage computed from the user + personal_stage_id = fields.Many2one('project.task.stage.personal', string='Personal Stage State', compute_sudo=False, + compute='_compute_personal_stage_id', help="The current user's personal stage.") + # This field is actually a related field on personal_stage_id.stage_id + # However due to the fact that personal_stage_id is computed, the orm throws out errors + # saying the field cannot be searched. + personal_stage_type_id = fields.Many2one('project.task.type', string='Personal User Stage', + compute='_compute_personal_stage_type_id', inverse='_inverse_personal_stage_type_id', store=False, + search='_search_personal_stage_type_id', + help="The current user's personal task stage.") partner_id = fields.Many2one('res.partner', string='Customer', compute='_compute_partner_id', recursive=True, store=True, readonly=False, tracking=True, @@ -908,7 +927,6 @@ class Task(models.Model): 'res.company', string='Company', compute='_compute_company_id', store=True, readonly=False, required=True, copy=True, default=_default_company_id) color = fields.Integer(string='Color Index') - user_email = fields.Char(related='user_id.email', string='User Email', readonly=True, related_sudo=False) attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment_ids', string="Main Attachments", help="Attachments that don't come from a message.") # In the domain of displayed_image_id, we couln't use attachment_ids because a one2many is represented as a list of commands so we used res_model & res_id @@ -932,9 +950,11 @@ 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') # Task Dependencies fields allow_task_dependencies = fields.Boolean(related='project_id.allow_task_dependencies') + # Tracking of this field is done in the write function depend_on_ids = fields.Many2many('project.task', relation="task_dependencies_rel", column1="task_id", column2="depends_on_id", string="Blocked By", domain="[('allow_task_dependencies', '=', True), ('id', '!=', id)]") # recurrence fields @@ -1028,6 +1048,64 @@ class Task(models.Model): def SELF_WRITABLE_FIELDS(self): return PROJECT_TASK_WRITABLE_FIELDS + @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_context('uid') + @api.depends('user_ids') + def _compute_personal_stage_id(self): + # An user may only access his own 'personal stage' and there can only be one pair (user, task_id) + personal_stages = self.env['project.task.stage.personal'].search([('user_id', '=', self.env.uid), ('task_id', 'in', self.ids)]) + self.personal_stage_id = False + for personal_stage in personal_stages: + personal_stage.task_id.personal_stage_id = personal_stage + + @api.depends('personal_stage_id') + def _compute_personal_stage_type_id(self): + for task in self: + task.personal_stage_type_id = task.personal_stage_id.stage_id + + def _inverse_personal_stage_type_id(self): + for task in self: + task.personal_stage_id.stage_id = task.personal_stage_type_id + + @api.model + def _search_personal_stage_type_id(self, operator, value): + return [('personal_stage_type_ids', operator, value)] + + @api.model + def _get_default_personal_stage_create_vals(self, user_id): + return [ + {'sequence': 1, 'name': _('Inbox'), 'user_id': user_id, 'fold': False}, + {'sequence': 2, 'name': _('Today'), 'user_id': user_id, 'fold': False}, + {'sequence': 3, 'name': _('This Week'), 'user_id': user_id, 'fold': False}, + {'sequence': 4, 'name': _('This Month'), 'user_id': user_id, 'fold': False}, + {'sequence': 5, 'name': _('Later'), 'user_id': user_id, 'fold': False}, + {'sequence': 6, 'name': _('Done'), 'user_id': user_id, 'fold': True}, + {'sequence': 7, 'name': _('Canceled'), 'user_id': user_id, 'fold': True}, + ] + + def _populate_missing_personal_stages(self): + # Assign the default personal stage for those that are missing + personal_stages_without_stage = self.env['project.task.stage.personal'].sudo().search([('task_id', 'in', self.ids), ('stage_id', '=', False)]) + if personal_stages_without_stage: + user_ids = personal_stages_without_stage.user_id + personal_stage_by_user = defaultdict(lambda: self.env['project.task.stage.personal']) + for personal_stage in personal_stages_without_stage: + personal_stage_by_user[personal_stage.user_id] |= personal_stage + for user_id in user_ids: + stage = self.env['project.task.type'].sudo().search([('user_id', '=', user_id.id)], limit=1) + # In the case no stages have been found, we create the default stages for the user + if not stage: + stages = self.env['project.task.type'].sudo().with_context(lang=user_id.partner_id.lang, default_project_id=False).create( + self._get_default_personal_stage_create_vals(user_id.id) + ) + stage = stages[0] + personal_stage_by_user[user_id].sudo().write({'stage_id': stage.id}) + @api.constrains('depend_on_ids') def _check_no_cyclic_dependencies(self): if not self._check_m2m_recursion('depend_on_ids'): @@ -1449,8 +1527,8 @@ class Task(models.Model): default_project_id=project_id ).default_get(['stage_id']).get('stage_id') vals["stage_id"] = default_stage[project_id] - # user_id change: update date_assign - if vals.get('user_id'): + # user_ids change: update date_assign + if vals.get('user_ids'): vals['date_assign'] = fields.Datetime.now() # Stage change: Update date_end if folded stage and date_last_stage_update if vals.get('stage_id'): @@ -1475,6 +1553,7 @@ class Task(models.Model): } self = self.with_context(ctx).sudo() tasks = super(Task, self).create(vals_list) + tasks._populate_missing_personal_stages() if is_portal_user: # since we use sudo to create tasks, we need to check # if the portal user could really create the tasks based on the ir rule. @@ -1506,8 +1585,8 @@ class Task(models.Model): # reset kanban state when changing stage if 'kanban_state' not in vals: vals['kanban_state'] = 'normal' - # user_id change: update date_assign - if vals.get('user_id') and 'date_assign' not in vals: + # user_ids change: update date_assign + if vals.get('user_ids') and 'date_assign' not in vals: vals['date_assign'] = now # recurrence fields @@ -1542,7 +1621,45 @@ class Task(models.Model): if portal_can_write: tasks = tasks.sudo() + # X2Many Field Tracking + # Extract to a separate function if necessary + x2m_tracked_fields = {'user_ids', 'depend_on_ids'} + x2m_vals_common_fields = vals.keys() & x2m_tracked_fields + x2m_tracking_values = dict() + # Structured like so + # { + # task: { + # field_name: (value, display_value) + # } + # } + if not self._context.get('mail_notrack') and x2m_vals_common_fields: + # Compute the value before the changes + for task in self: + task_values = x2m_tracking_values.setdefault(task, {}) + for field in x2m_vals_common_fields: + task_values[field] = (task[field], ', '.join(record.display_name for record in task[field])) + result = super(Task, tasks).write(vals) + if x2m_tracking_values: + for task, tracking_values in x2m_tracking_values.items(): + # Compile the different changes + MailTracking = self.env['mail.tracking.value'] + tracking_value_ids = [] + # Use mail.tracking.value to track our changes, this is to use the same format as the default tracking one + # we just hack the record to think it is a text field and compile the data ourself beforehand + for field in tracking_values: + if task[field] != tracking_values[field][0]: + field_desc = task._fields[field]._description_string(self.env) + old_value = tracking_values[field][1] + new_value = ', '.join(record.display_name for record in task[field]) + tracking_value_ids.append((0, 0, MailTracking.create_tracking_values( + old_value, new_value, field, {'type': 'char', 'string': field_desc}, 100, self._name))) + if tracking_value_ids: + task._message_log(tracking_value_ids=tracking_value_ids) + + if 'user_ids' in vals: + tasks._populate_missing_personal_stages() + # 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) @@ -1568,11 +1685,6 @@ 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', 'project_id', 'display_project_id') def _compute_partner_id(self): """ Compute the partner_id when the tasks have no partner_id. @@ -1673,7 +1785,7 @@ class Task(models.Model): project_user_group_id = self.env.ref('project.group_project_user').id new_group = ('group_project_user', lambda pdata: pdata['type'] == 'user' and project_user_group_id in pdata['groups'], {}) - if not self.user_id and not self.stage_id.fold: + if not self.user_ids and not self.stage_id.fold: take_action = self._notify_get_action_link('assign', **local_msg_vals) project_actions = [{'url': take_action, 'title': _('I take it')}] new_group[2]['actions'] = project_actions @@ -1720,7 +1832,7 @@ class Task(models.Model): # want the gateway user to be responsible if no other responsible is # found. create_context = dict(self.env.context or {}) - create_context['default_user_id'] = False + create_context['default_user_ids'] = False if custom_values is None: custom_values = {} defaults = { @@ -1783,7 +1895,7 @@ class Task(models.Model): return super(Task, self)._message_post_after_hook(message, msg_vals) def action_assign_to_me(self): - self.write({'user_id': self.env.user.id}) + self.write({'user_ids': [(4, self.env.user.id)]}) # If depth == 1, return only direct children # If depth == 3, return children to third generation diff --git a/addons/project/models/project_task_recurrence.py b/addons/project/models/project_task_recurrence.py index e229ec8b28d..7a690690b14 100644 --- a/addons/project/models/project_task_recurrence.py +++ b/addons/project/models/project_task_recurrence.py @@ -209,7 +209,7 @@ class ProjectTaskRecurrence(models.Model): 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.id - create_values['user_id'] = False + create_values['user_ids'] = False return create_values def _create_next_task(self): diff --git a/addons/project/models/project_task_stage_personal.py b/addons/project/models/project_task_stage_personal.py new file mode 100644 index 00000000000..76c934b8143 --- /dev/null +++ b/addons/project/models/project_task_stage_personal.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + +class ProjectTaskStagePersonal(models.Model): + _name = 'project.task.stage.personal' + _description = 'Personal Task Stage' + _table = 'project_task_user_rel' + _rec_name = 'stage_id' + + task_id = fields.Many2one('project.task', required=True, ondelete='cascade', index=True) + user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True) + stage_id = fields.Many2one('project.task.type', domain="[('user_id', '=', user_id)]", ondelete='restrict') + + _sql_constraints = [ + ('project_personal_stage_unique', 'UNIQUE (task_id, user_id)', 'A task can only have a single personal stage per user.'), + ] diff --git a/addons/project/report/project_report.py b/addons/project/report/project_report.py index ff61446a229..080e6fa66fb 100644 --- a/addons/project/report/project_report.py +++ b/addons/project/report/project_report.py @@ -55,7 +55,7 @@ class ReportProjectTaskUser(models.Model): t.date_end as date_end, t.date_last_stage_update as date_last_stage_update, t.date_deadline as date_deadline, - t.user_id, + tu.user_id, t.project_id, t.priority, t.name as name, @@ -82,7 +82,7 @@ class ReportProjectTaskUser(models.Model): t.date_end, t.date_deadline, t.date_last_stage_update, - t.user_id, + tu.user_id, t.project_id, t.priority, t.name, @@ -98,6 +98,7 @@ class ReportProjectTaskUser(models.Model): CREATE view %s as %s FROM project_task t + LEFT JOIN project_task_user_rel tu on t.id=tu.task_id WHERE t.active = 'true' %s """ % (self._table, self._select(), self._group_by())) diff --git a/addons/project/report/project_report_views.xml b/addons/project/report/project_report_views.xml index ce424c68e0d..9c696950104 100644 --- a/addons/project/report/project_report_views.xml +++ b/addons/project/report/project_report_views.xml @@ -54,7 +54,7 @@ - + diff --git a/addons/project/report/project_task_burndown_chart_report.py b/addons/project/report/project_task_burndown_chart_report.py index b215566d526..ecc5ced3258 100644 --- a/addons/project/report/project_task_burndown_chart_report.py +++ b/addons/project/report/project_task_burndown_chart_report.py @@ -59,7 +59,7 @@ WITH all_moves_stage_task AS ( COALESCE(LAG(mm.date) OVER (PARTITION BY mm.res_id ORDER BY mm.id), pt.create_date) as date_begin, mm.date as date_end, mtv.old_value_integer as stage_id, - pt.user_id, + usr_rel.user_id, pt.date_assign, pt.date_deadline, pt.partner_id @@ -71,6 +71,7 @@ WITH all_moves_stage_task AS ( JOIN ir_model_fields imf ON mtv.field = imf.id AND imf.model = 'project.task' AND imf.name = 'stage_id' + JOIN project_task_user_rel usr_rel ON pt.id=usr_rel.task_id WHERE pt.active --We compute the last reached stage @@ -82,7 +83,7 @@ WITH all_moves_stage_task AS ( COALESCE(md.date, pt.create_date) as date_begin, (CURRENT_DATE + interval '1 month')::date as date_end, pt.stage_id, - pt.user_id, + usr_rel.user_id, pt.date_assign, pt.date_deadline, pt.partner_id @@ -98,6 +99,7 @@ WITH all_moves_stage_task AS ( AND mm.model = 'project.task' ORDER BY mm.id DESC FETCH FIRST ROW ONLY) md ON TRUE + JOIN project_task_user_rel usr_rel ON pt.id=usr_rel.task_id WHERE pt.active ) SELECT (task_id*10^7 + 10^6 + to_char(d, 'YYMMDD')::integer)::bigint as id, diff --git a/addons/project/security/ir.model.access.csv b/addons/project/security/ir.model.access.csv index 35c80887da6..2a121607701 100644 --- a/addons/project/security/ir.model.access.csv +++ b/addons/project/security/ir.model.access.csv @@ -4,7 +4,7 @@ access_project_project_manager,project.project,model_project_project,project.gro access_project_project_stage,project.project_stage,model_project_project_stage,base.group_user,1,0,0,0 access_project_project_stage_manager,project.project_stage.manager,model_project_project_stage,project.group_project_manager,1,1,1,1 access_project_task_type_user,project.task.type.user,model_project_task_type,base.group_user,1,0,0,0 -access_project_task_type_project_user,project.task.type.project.user,model_project_task_type,project.group_project_user,1,0,0,0 +access_project_task_type_project_user,project.task.type.project.user,model_project_task_type,project.group_project_user,1,1,1,1 access_project_task_type_manager,project.task.type manager,model_project_task_type,project.group_project_manager,1,1,1,1 access_project_task_type_portal,task_type_portal,project.model_project_task_type,base.group_portal,1,0,0,0 access_project_task,project.task,model_project_task,project.group_project_user,1,1,1,1 @@ -39,3 +39,4 @@ access_project_milestone_project_manager,project.milestone.project.manager,model access_project_collaborator_manager,project.collaborator.manager,model_project_collaborator,project.group_project_manager,1,1,1,1 access_project_collaborator_portal,project.collaborator.portal,model_project_collaborator,base.group_portal,1,0,0,0 access_project_share_manager,project.share.wizard.manager,model_project_share_wizard,project.group_project_manager,1,1,1,0 +access_project_personal_stage,project.personal.stage.user,model_project_task_stage_personal,base.group_user,1,1,1,1 diff --git a/addons/project/security/project_security.xml b/addons/project/security/project_security.xml index 84a5865ae16..cb02df589b0 100644 --- a/addons/project/security/project_security.xml +++ b/addons/project/security/project_security.xml @@ -89,7 +89,7 @@ '|', ('message_partner_ids', 'in', [user.partner_id.id]), # to subscribe check access to the record, follower is not enough at creation - ('user_id', '=', user.id) + ('user_ids', 'in', user.id) ] @@ -101,12 +101,54 @@ + + Project/Task Type: manager sees all + + [(1, '=', 1)] + + + + + Project/Task Type: see own or unowned stages + + [('user_id', 'in', (False, user.id))] + + + + Project/Task Type: write own stages + + [('user_id', '=', user.id)] + + + + + + + Task Analysis multi-company [('company_id', 'in', company_ids)] + + Project: See my own personal stage + + [('user_id', '=', user.id)] + + + + Project: See private tasks + + [ + ('project_id.privacy_visibility', '!=', 'followers'), + '|', '|', ('project_id', '!=', False), + ('parent_id', '!=', False), + ('user_ids', 'in', user.id), + ] + + + Project: portal users: portal and following diff --git a/addons/project/static/src/js/project_kanban.js b/addons/project/static/src/js/project_kanban.js index 312b1d3c452..0b2ec385ff7 100644 --- a/addons/project/static/src/js/project_kanban.js +++ b/addons/project/static/src/js/project_kanban.js @@ -4,9 +4,11 @@ import KanbanController from 'web.KanbanController'; import KanbanRenderer from 'web.KanbanRenderer'; import KanbanView from 'web.KanbanView'; import KanbanColumn from 'web.KanbanColumn'; -import viewRegistry from 'web.view_registry'; import KanbanRecord from 'web.KanbanRecord'; +import KanbanModel from 'web.KanbanModel'; +import viewRegistry from 'web.view_registry'; import { ProjectControlPanel } from '@project/js/project_control_panel'; +import viewUtils from 'web.viewUtils'; // PROJECTS @@ -70,6 +72,30 @@ const ProjectTaskKanbanRenderer = KanbanRenderer.extend({ config: Object.assign({}, KanbanRenderer.prototype.config, { KanbanColumn: ProjectTaskKanbanColumn, }), + + /** + * Allows record drag when grouping by `personal_stage_type_ids` + * + * @override + */ + _setState() { + this._super(...arguments); + const groupedBy = this.state.groupedBy[0]; + const groupByFieldName = viewUtils.getGroupByField(groupedBy); + const field = this.state.fields[groupByFieldName] || {}; + const fieldInfo = this.state.fieldsInfo.kanban[groupByFieldName] || {}; + + const grouped_by_date = ["date", "datetime"].includes(field.type); + const grouped_by_m2m = field.type === "many2many"; + const readonly = !!field.readonly || !!fieldInfo.readonly; + + const draggable = !readonly && (!grouped_by_m2m || groupByFieldName == 'personal_stage_type_ids') && + (!grouped_by_date || fieldInfo.allowGroupRangeValue); + + Object.assign(this.columnOptions, { + draggable, + }); + } }); export const ProjectKanbanController = KanbanController.extend({ @@ -93,8 +119,81 @@ export const ProjectKanbanController = KanbanController.extend({ }, }); +const ProjectTaskKanbanModel = KanbanModel.extend({ + + /** + * Upon updating `personal_stage_type_ids` we actually want to update the `personal_stage_type_id` field. + * + * @override + * @private + */ + moveRecord: function (recordID, groupID, parentID) { + var self = this; + var parent = this.localData[parentID]; + var new_group = this.localData[groupID]; + var changes = {}; + var groupedFieldName = viewUtils.getGroupByField(parent.groupedBy[0]); + var groupedField = parent.fields[groupedFieldName]; + // for a date/datetime field, we take the last moment of the group as the group value + if (['date', 'datetime'].includes(groupedField.type)) { + changes[groupedFieldName] = viewUtils.getGroupValue(new_group, groupedFieldName); + } else if (groupedField.type === 'many2one') { + changes[groupedFieldName] = { + id: new_group.res_id, + display_name: new_group.value, + }; + } else if (groupedField.type === 'selection') { + var value = _.findWhere(groupedField.selection, {1: new_group.value}); + changes[groupedFieldName] = value && value[0] || false; + } else if (groupedField.type == 'many2many' && groupedFieldName == 'personal_stage_type_ids') { + changes['personal_stage_type_id'] = { + id: new_group.res_id, + display_name: new_group.value, + } + } else { + changes[groupedFieldName] = new_group.value; + } + + // Manually updates groups data. Note: this is done before the actual + // save as it might need to perform a read group in some cases so those + // updated data might be overridden again. + var record = self.localData[recordID]; + var resID = record.res_id; + // Remove record from its current group + var old_group; + for (var i = 0; i < parent.data.length; i++) { + old_group = self.localData[parent.data[i]]; + var index = _.indexOf(old_group.data, recordID); + if (index >= 0) { + old_group.data.splice(index, 1); + old_group.count--; + if (!old_group.activeFilter || old_group.activeFilter.value === record.data[parent.progressBar.field]) { + // Here, the record leaving the old group matches its domain, + // so we must decrease the domainCount too. + old_group.domainCount--; + } + old_group.res_ids = _.without(old_group.res_ids, resID); + self._updateParentResIDs(old_group); + break; + } + } + // Add record to its new group + new_group.data.push(recordID); + new_group.res_ids.push(resID); + new_group.count++; + + return this.notifyChanges(recordID, changes).then(function () { + return self.save(recordID); + }).then(function () { + record.parentID = new_group.id; + return [old_group.id, new_group.id]; + }); + }, +}) + const ProjectKanbanView = KanbanView.extend({ config: _.extend({}, KanbanView.prototype.config, { + Model: ProjectTaskKanbanModel, Controller: ProjectKanbanController, Renderer: ProjectTaskKanbanRenderer, ControlPanel: ProjectControlPanel, diff --git a/addons/project/static/src/scss/project_form.scss b/addons/project/static/src/scss/project_form.scss index a00a230a58e..4426d802fa9 100644 --- a/addons/project/static/src/scss/project_form.scss +++ b/addons/project/static/src/scss/project_form.scss @@ -34,3 +34,8 @@ color: $o-main-text-color; } } + +.o_form_project_tasks .o_project_task_project_field input::placeholder { + color: darkred; + font-style: italic; +} diff --git a/addons/project/static/src/scss/project_widgets.scss b/addons/project/static/src/scss/project_widgets.scss index 3271bb3e9d3..db6e353e636 100644 --- a/addons/project/static/src/scss/project_widgets.scss +++ b/addons/project/static/src/scss/project_widgets.scss @@ -62,3 +62,22 @@ } } } + +.o_kanban_project_tasks .o_field_many2manytags, .o_kanban_tags{ + margin: 0px; +} + +.o_project_m2m_avatar { + width: 20px; + height: 20px; + margin-right: -1px; + display: inline-block; + + img { + border-radius: 50%; + width: 20px; + height: 20px; + object-fit: cover; + margin-right: 4px; + } +} diff --git a/addons/project/tests/__init__.py b/addons/project/tests/__init__.py index ee0446182b4..b79de62503e 100644 --- a/addons/project/tests/__init__.py +++ b/addons/project/tests/__init__.py @@ -14,4 +14,5 @@ from . import test_project_update_flow from . import test_project_update_ui from . import test_portal from . import test_multicompany +from . import test_personal_stages from . import test_task_dependencies diff --git a/addons/project/tests/test_access_rights.py b/addons/project/tests/test_access_rights.py index 0026c15d5a3..b9ab34ded48 100644 --- a/addons/project/tests/test_access_rights.py +++ b/addons/project/tests/test_access_rights.py @@ -235,13 +235,13 @@ class TestProjectPortalCommon(TestProjectCommon): 'groups_id': [(6, 0, [])]}) self.task_3 = self.env['project.task'].with_context({'mail_create_nolog': True}).create({ - 'name': 'Test3', 'user_id': self.user_portal.id, 'project_id': self.project_pigs.id}) + 'name': 'Test3', 'user_ids': self.user_portal, 'project_id': self.project_pigs.id}) self.task_4 = self.env['project.task'].with_context({'mail_create_nolog': True}).create({ - 'name': 'Test4', 'user_id': self.user_public.id, 'project_id': self.project_pigs.id}) + 'name': 'Test4', 'user_ids': self.user_public, 'project_id': self.project_pigs.id}) self.task_5 = self.env['project.task'].with_context({'mail_create_nolog': True}).create({ - 'name': 'Test5', 'user_id': False, 'project_id': self.project_pigs.id}) + 'name': 'Test5', 'user_ids': False, 'project_id': self.project_pigs.id}) self.task_6 = self.env['project.task'].with_context({'mail_create_nolog': True}).create({ - 'name': 'Test5', 'user_id': False, 'project_id': self.project_pigs.id}) + 'name': 'Test5', 'user_ids': False, 'project_id': self.project_pigs.id}) class TestPortalProject(TestProjectPortalCommon): diff --git a/addons/project/tests/test_multicompany.py b/addons/project/tests/test_multicompany.py index b68d26c4110..85703c442e7 100644 --- a/addons/project/tests/test_multicompany.py +++ b/addons/project/tests/test_multicompany.py @@ -179,12 +179,12 @@ class TestMultiCompanyProject(TestMultiCompanyCommon): Task = cls.env['project.task'].with_context({'mail_create_nolog': True, 'tracking_disable': True}) cls.task_1 = Task.create({ 'name': 'Task 1 in Project A', - 'user_id': cls.user_employee_company_a.id, + 'user_ids': cls.user_employee_company_a, 'project_id': cls.project_company_a.id }) cls.task_2 = Task.create({ 'name': 'Task 2 in Project B', - 'user_id': cls.user_employee_company_b.id, + 'user_ids': cls.user_employee_company_b, 'project_id': cls.project_company_b.id }) diff --git a/addons/project/tests/test_personal_stages.py b/addons/project/tests/test_personal_stages.py new file mode 100644 index 00000000000..3fc3f59b8a9 --- /dev/null +++ b/addons/project/tests/test_personal_stages.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- + +from odoo.tests import tagged + +from .test_project_base import TestProjectCommon + +@tagged('-at_install', 'post_install', 'personal_stages') +class TestPersonalStages(TestProjectCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user_stages = cls.env['project.task.type'].search([('user_id', '=', cls.user_projectuser.id)]) + cls.manager_stages = cls.env['project.task.type'].search([('user_id', '=', cls.user_projectmanager.id)]) + + def test_personal_stage_base(self): + # Project User is assigned to task_1 he should be able to see a personal stage + self.task_1.with_user(self.user_projectuser)._compute_personal_stage_id() + self.assertTrue(self.task_1.with_user(self.user_projectuser).personal_stage_type_id, + 'Project User is assigned to task 1, he should have a personal stage assigned.') + + self.task_1.with_user(self.user_projectmanager)._compute_personal_stage_id() + self.assertFalse(self.env['project.task'].browse(self.task_1.id).with_user(self.user_projectmanager).personal_stage_type_id, + 'Project Manager is not assigned to task 1, he should not have a personal stage assigned.') + + # Now assign a second user to our task_1 + self.task_1.user_ids += self.user_projectmanager + self.assertTrue(self.task_1.with_user(self.user_projectmanager).personal_stage_type_id, + 'Project Manager has now been assigned to task 1 and should have a personal stage assigned.') + + self.task_1.with_user(self.user_projectmanager)._compute_personal_stage_id() + task_1_manager_stage = self.task_1.with_user(self.user_projectmanager).personal_stage_type_id + + self.task_1.with_user(self.user_projectuser)._compute_personal_stage_id() + self.task_1.with_user(self.user_projectuser).personal_stage_type_id = self.user_stages[1] + self.assertEqual(self.task_1.with_user(self.user_projectuser).personal_stage_type_id, self.user_stages[1], + 'Assigning another personal stage to the task should have changed it for user 1.') + + self.task_1.with_user(self.user_projectmanager)._compute_personal_stage_id() + self.assertEqual(self.task_1.with_user(self.user_projectmanager).personal_stage_type_id, task_1_manager_stage, + 'Modifying the personal stage of Project User should not have affected the personal stage of Project Manager.') + + self.task_2.with_user(self.user_projectmanager).personal_stage_type_id = self.manager_stages[1] + self.assertEqual(self.task_1.with_user(self.user_projectmanager).personal_stage_type_id, task_1_manager_stage, + 'Modifying the personal stage on task 2 for Project Manager should not have affected the stage on task 1.') + + def test_personal_stage_search(self): + self.task_2.user_ids += self.user_projectuser + # Make sure both personal stages are different + self.task_1.with_user(self.user_projectuser).personal_stage_type_id = self.user_stages[0] + self.task_2.with_user(self.user_projectuser).personal_stage_type_id = self.user_stages[1] + tasks = self.env['project.task'].with_user(self.user_projectuser).search([('personal_stage_type_id', '=', self.user_stages[0].id)]) + self.assertTrue(tasks, 'The search result should not be empty.') + for task in tasks: + self.assertEqual(task.personal_stage_type_id, self.user_stages[0], + 'The search should only have returned task that are in the inbox personal stage.') + + def test_personal_stage_read_group(self): + self.task_1.user_ids += self.user_projectmanager + self.task_1.with_user(self.user_projectmanager).personal_stage_type_id = self.manager_stages[1] + #Makes sure the personal stage for project manager is saved in the database + self.env['project.task'].flush() + read_group_user = self.env['project.task'].with_user(self.user_projectuser).read_group( + [('user_ids', '=', self.user_projectuser.id)], fields=['sequence:avg'], groupby=['personal_stage_type_ids']) + # Check that the result is at least a bit coherent + self.assertEqual(len(self.user_stages), len(read_group_user), + 'read_group should return %d groups' % len(self.user_stages)) + # User has only one task assigned the sum of all counts should be 1 + total = 0 + for group in read_group_user: + total += group['personal_stage_type_ids_count'] + self.assertEqual(1, total, + 'read_group should not have returned more tasks than the user is assigned to.') + read_group_manager = self.env['project.task'].with_user(self.user_projectmanager).read_group( + [('user_ids', '=', self.user_projectmanager.id)], fields=['sequence:avg'], groupby=['personal_stage_type_ids']) + self.assertEqual(len(self.manager_stages), len(read_group_manager), + 'read_group should return %d groups' % len(self.user_stages)) + total = 0 + total_stage_0 = 0 + total_stage_1 = 0 + for group in read_group_manager: + total += group['personal_stage_type_ids_count'] + # Check that we have a task in both stages + if group['personal_stage_type_ids'][0] == self.manager_stages[0].id: + total_stage_0 += 1 + elif group['personal_stage_type_ids'][0] == self.manager_stages[1].id: + total_stage_1 += 1 + self.assertEqual(2, total, + 'read_group should not have returned more tasks than the user is assigned to.') + self.assertEqual(1, total_stage_0) + self.assertEqual(1, total_stage_1) diff --git a/addons/project/tests/test_project_base.py b/addons/project/tests/test_project_base.py index 0c62144e98a..8ba3c66640d 100644 --- a/addons/project/tests/test_project_base.py +++ b/addons/project/tests/test_project_base.py @@ -60,11 +60,11 @@ class TestProjectCommon(TransactionCase): # Already-existing tasks in Pigs cls.task_1 = cls.env['project.task'].with_context({'mail_create_nolog': True}).create({ 'name': 'Pigs UserTask', - 'user_id': cls.user_projectuser.id, + 'user_ids': cls.user_projectuser, 'project_id': cls.project_pigs.id}) cls.task_2 = cls.env['project.task'].with_context({'mail_create_nolog': True}).create({ 'name': 'Pigs ManagerTask', - 'user_id': cls.user_projectmanager.id, + 'user_ids': cls.user_projectmanager, 'project_id': cls.project_pigs.id}) # Test 'Goats' project, same as 'Pigs', but with 2 stages diff --git a/addons/project/tests/test_project_flow.py b/addons/project/tests/test_project_flow.py index 4fa67397c0e..488809e08a2 100644 --- a/addons/project/tests/test_project_flow.py +++ b/addons/project/tests/test_project_flow.py @@ -112,7 +112,7 @@ class TestProjectFlow(TestProjectCommon): parent_task = Task.create({ 'name': 'Mother Task', - 'user_id': self.user_projectuser.id, + 'user_ids': self.user_projectuser, 'project_id': self.project_pigs.id, 'partner_id': self.partner_2.id, 'planned_hours': 12, @@ -120,7 +120,7 @@ class TestProjectFlow(TestProjectCommon): another_parent_task = Task.create({ 'name': 'Another Mother Task', - 'user_id': self.user_projectuser.id, + 'user_ids': self.user_projectuser, 'project_id': self.project_pigs.id, 'partner_id': self.partner_3.id, 'planned_hours': 0, @@ -210,7 +210,7 @@ class TestProjectFlow(TestProjectCommon): Task = self.env['project.task'].with_context({'tracking_disable': True}) first_task = Task.create({ 'name': 'first task', - 'user_id': self.user_projectuser.id, + 'user_ids': self.user_projectuser, 'project_id': self.project_pigs.id, 'partner_id': self.partner_2.id, }) diff --git a/addons/project/tests/test_project_sharing.py b/addons/project/tests/test_project_sharing.py index 04af79e5333..ff3ff4f948b 100644 --- a/addons/project/tests/test_project_sharing.py +++ b/addons/project/tests/test_project_sharing.py @@ -42,12 +42,12 @@ class TestProjectSharingCommon(TestProjectCommon): cls.task_cow = cls.env['project.task'].with_context({'mail_create_nolog': True}).create({ 'name': 'Cow UserTask', - 'user_id': cls.user_projectuser.id, + 'user_ids': cls.user_projectuser, 'project_id': cls.project_cows.id, }) cls.task_portal = cls.env['project.task'].with_context({'mail_create_nolog': True}).create({ 'name': 'Portal UserTask', - 'user_id': cls.user_projectuser.id, + 'user_ids': cls.user_projectuser, 'project_id': cls.project_portal.id, }) @@ -123,7 +123,7 @@ class TestProjectSharing(TestProjectSharingCommon): task = form.save() self.assertEqual(task.name, 'Test') self.assertEqual(task.project_id, self.project_portal) - self.assertEqual(task.user_id, self.user_portal) + self.assertEqual(task.user_ids, self.user_portal) # 3.1) Try to change the project of the new task with this user. with self.assertRaises(AssertionError, msg="Should not accept the portal user changes the project of the task."): form.project_id = self.project_cows @@ -172,4 +172,4 @@ class TestProjectSharing(TestProjectSharingCommon): form.save() self.assertEqual(task.child_ids.name, 'Test Subtask') self.assertEqual(task.child_ids.project_id, self.project_cows) - self.assertEqual(task.child_ids.user_id, self.user_portal) + self.assertEqual(task.child_ids.user_ids, self.user_portal) diff --git a/addons/project/tests/test_project_subtasks.py b/addons/project/tests/test_project_subtasks.py index ff6be35a93f..d2de2489f82 100644 --- a/addons/project/tests/test_project_subtasks.py +++ b/addons/project/tests/test_project_subtasks.py @@ -10,10 +10,6 @@ class TestProjectSubtasks(TestProjectCommon): """ Create a task in the default task form should take the project set in the form or the default project in the context """ - with self.assertRaises(AssertionError, msg="Should not accept a form without project. Project is required"): - with Form(self.env['project.task'].with_context({'tracking_disable': True})) as task_form: - task_form.name = 'Test Task 1' - with Form(self.env['project.task'].with_context({'tracking_disable': True})) as task_form: task_form.name = 'Test Task 1' task_form.project_id = self.project_pigs @@ -33,10 +29,6 @@ class TestProjectSubtasks(TestProjectCommon): """ Create a task in the task form 2 should take the project set in the form or the default project in the context """ - with self.assertRaises(AssertionError, msg="Should not accept a form without project. Project is required"): - with Form(self.env['project.task'].with_context({'tracking_disable': True}), view="project.view_task_form2") as task_form: - task_form.name = 'Test Task 1' - with Form(self.env['project.task'].with_context({'tracking_disable': True}), view="project.view_task_form2") as task_form: task_form.name = 'Test Task 1' task_form.project_id = self.project_pigs diff --git a/addons/project/tests/test_task_dependencies.py b/addons/project/tests/test_task_dependencies.py index 5ddc6e640c3..af8feb89028 100644 --- a/addons/project/tests/test_task_dependencies.py +++ b/addons/project/tests/test_task_dependencies.py @@ -20,7 +20,7 @@ class TestTaskDependencies(TestProjectCommon): }) cls.task_3 = cls.env['project.task'].with_context({'mail_create_nolog': True}).create({ 'name': 'Pigs UserTask 2', - 'user_id': cls.user_projectuser.id, + 'user_ids': cls.user_projectuser, 'project_id': cls.project_pigs.id, }) @@ -97,7 +97,8 @@ class TestTaskDependencies(TestProjectCommon): self.env['res.config.settings'].create({ 'group_project_task_dependencies': True }).execute() - self.task_1.write({ + # `depend_on_ids` is tracked + self.task_1.with_context(mail_notrack=True).write({ 'depend_on_ids': [Command.link(self.task_2.id)] }) self.cr.precommit.clear() diff --git a/addons/project/views/project_portal_templates.xml b/addons/project/views/project_portal_templates.xml index ff01397ecae..6e8d3f0dbcc 100644 --- a/addons/project/views/project_portal_templates.xml +++ b/addons/project/views/project_portal_templates.xml @@ -103,8 +103,8 @@ in stage: - assigned to: - + assigned to: + for customer: @@ -119,7 +119,7 @@ # - + @@ -177,18 +177,20 @@ -
-
+
+
Assigned to
-
- Contact -
-
-
-
-
-
+ +
+ Contact +
+
+
+
+
+
+
diff --git a/addons/project/views/project_sharing_views.xml b/addons/project/views/project_sharing_views.xml index ea603592e99..125ee028442 100644 --- a/addons/project/views/project_sharing_views.xml +++ b/addons/project/views/project_sharing_views.xml @@ -31,7 +31,7 @@ - + @@ -90,7 +90,7 @@
- +
@@ -116,7 +116,7 @@ - + @@ -151,10 +151,10 @@ - @@ -173,7 +173,7 @@ - + @@ -182,7 +182,7 @@ - + @@ -208,11 +208,11 @@ - + - + @@ -223,7 +223,7 @@ domain="[('activity_ids.date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> - + diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 7d74d523fc6..b48ab46a6cb 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -18,14 +18,13 @@ - + - - - + + @@ -46,7 +45,8 @@ domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> - + + @@ -78,7 +78,7 @@ kanban,tree,form,calendar,pivot,graph,activity [('display_project_id', '=', active_id)] { - 'pivot_row_groupby': ['user_id'], + 'pivot_row_groupby': ['user_ids'], 'default_project_id': active_id, 'show_project_update': True, } @@ -184,6 +184,7 @@ + @@ -783,7 +784,7 @@
@@ -816,12 +817,18 @@
- +
- + @@ -854,7 +861,7 @@ - + @@ -871,7 +878,7 @@ - + @@ -980,7 +987,8 @@
- + @@ -993,11 +1001,12 @@ project.task.kanban project.task - + - + @@ -1013,6 +1022,7 @@ + @@ -1024,7 +1034,13 @@ -
+ +
+
+ +
+ Private +

@@ -1070,7 +1086,7 @@
- +
@@ -1098,7 +1114,7 @@ - + @@ -1131,10 +1147,10 @@ project.task - - - + + @@ -1181,10 +1197,10 @@ project.task - +
- +
@@ -1213,10 +1229,10 @@ - Tasks + My Tasks project.task - tree,kanban,form,calendar,pivot,graph,activity - {'search_default_my_tasks': 1, 'all_task': 0} + kanban,tree,form,calendar,pivot,graph,activity + {'search_default_my_tasks': 1, 'search_default_personal_stage': 1, 'all_task': 0}

@@ -1239,7 +1255,7 @@ - @@ -1283,7 +1299,7 @@ Assigned Tasks project.task tree,form,calendar,graph - {'search_default_user_id': [active_id], 'default_user_id': active_id} + {'search_default_user_ids': [active_id], 'default_user_ids': active_id} [('display_project_id', '!=', False)] form diff --git a/addons/sale_project/models/sale_order.py b/addons/sale_project/models/sale_order.py index 32ec48d5aba..b91be8db811 100644 --- a/addons/sale_project/models/sale_order.py +++ b/addons/sale_project/models/sale_order.py @@ -259,7 +259,7 @@ class SaleOrderLine(models.Model): 'sale_line_id': self.id, 'sale_order_id': self.order_id.id, 'company_id': project.company_id.id, - 'user_id': False, # force non assigned task, as created as sudo() + 'user_ids': False, # force non assigned task, as created as sudo() } def _timesheet_create_task(self, project): diff --git a/addons/sale_project/tests/test_child_tasks.py b/addons/sale_project/tests/test_child_tasks.py index 03d9e1dcde6..73a91a47be2 100644 --- a/addons/sale_project/tests/test_child_tasks.py +++ b/addons/sale_project/tests/test_child_tasks.py @@ -34,9 +34,9 @@ class TestNestedTaskUpdate(TransactionCase): #---------------------------------- def test_creating_subtask_user_id_on_parent_dont_go_on_child(self): - parent = self.env['project.task'].create({'name': 'parent', 'user_id': self.user.id}) - child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id, 'user_id': False}) - self.assertFalse(child.user_id) + parent = self.env['project.task'].create({'name': 'parent', 'user_ids': [(4, self.user.id)]}) + child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id, 'user_ids': False}) + self.assertFalse(child.user_ids) def test_creating_subtask_partner_id_on_parent_goes_on_child(self): parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.user.partner_id.id}) @@ -88,13 +88,13 @@ class TestNestedTaskUpdate(TransactionCase): #---------------------------------------- def test_write_user_id_on_parent_dont_write_on_child(self): - parent = self.env['project.task'].create({'name': 'parent', 'user_id': False}) - child = self.env['project.task'].create({'name': 'child', 'user_id': False, 'parent_id': parent.id}) - self.assertFalse(child.user_id) - parent.write({'user_id': self.user.id}) - self.assertFalse(child.user_id) - parent.write({'user_id': False}) - self.assertFalse(child.user_id) + parent = self.env['project.task'].create({'name': 'parent', 'user_ids': False}) + child = self.env['project.task'].create({'name': 'child', 'user_ids': False, 'parent_id': parent.id}) + self.assertFalse(child.user_ids) + parent.write({'user_ids': [(4, self.user.id)]}) + self.assertFalse(child.user_ids) + parent.write({'user_ids': False}) + self.assertFalse(child.user_ids) def test_write_partner_id_on_parent_write_on_child(self): parent = self.env['project.task'].create({'name': 'parent', 'partner_id': False}) @@ -148,11 +148,11 @@ class TestNestedTaskUpdate(TransactionCase): #---------------------------------- def test_linking_user_id_on_parent_dont_write_on_child(self): - parent = self.env['project.task'].create({'name': 'parent', 'user_id': self.user.id}) - child = self.env['project.task'].create({'name': 'child', 'user_id': False}) - self.assertFalse(child.user_id) + parent = self.env['project.task'].create({'name': 'parent', 'user_ids': [(4, self.user.id)]}) + child = self.env['project.task'].create({'name': 'child', 'user_ids': False}) + self.assertFalse(child.user_ids) child.write({'parent_id': parent.id}) - self.assertFalse(child.user_id) + self.assertFalse(child.user_ids) def test_linking_partner_id_on_parent_write_on_child(self): parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.user.partner_id.id}) @@ -195,8 +195,8 @@ class TestNestedTaskUpdate(TransactionCase): self.assertFalse(child.sale_line_id) def test_writing_on_parent_with_multiple_tasks(self): - parent = self.env['project.task'].create({'name': 'parent', 'user_id': False, 'partner_id': self.partner.id}) - children_values = [{'name': 'child%s' % i, 'user_id': False, 'parent_id': parent.id} for i in range(5)] + parent = self.env['project.task'].create({'name': 'parent', 'user_ids': False, 'partner_id': self.partner.id}) + children_values = [{'name': 'child%s' % i, 'user_ids': False, 'parent_id': parent.id} for i in range(5)] children = self.env['project.task'].create(children_values) children._compute_partner_id() # test writing sale_line_id @@ -207,17 +207,17 @@ class TestNestedTaskUpdate(TransactionCase): self.assertEqual(child.sale_line_id, self.order_line) def test_linking_on_parent_with_multiple_tasks(self): - parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id, 'sale_line_id': self.order_line.id, 'user_id': self.user.id}) - children_values = [{'name': 'child%s' % i, 'user_id': False} for i in range(5)] + parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id, 'sale_line_id': self.order_line.id, 'user_ids': [(4, self.user.id)]}) + children_values = [{'name': 'child%s' % i, 'user_ids': False} for i in range(5)] children = self.env['project.task'].create(children_values) - # test writing user_id and sale_line_id + # test writing user_ids and sale_line_id for child in children: - self.assertFalse(child.user_id) + self.assertFalse(child.user_ids) self.assertFalse(child.sale_line_id) children.write({'parent_id': parent.id}) for child in children: self.assertEqual(child.sale_line_id, self.order_line) - self.assertFalse(child.user_id) + self.assertFalse(child.user_ids) diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml index 498bf7feae1..e1339f27893 100644 --- a/addons/sale_timesheet/data/sale_service_demo.xml +++ b/addons/sale_timesheet/data/sale_service_demo.xml @@ -29,7 +29,7 @@ Internal training - + @@ -167,7 +167,7 @@ - + @@ -482,7 +482,7 @@ - + diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index 53d54c0b40b..f3c85bcacb4 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -246,7 +246,7 @@ class TestSaleService(TestCommonSaleTimesheet): self.assertTrue(so_line1.project_id, "SO confirmation should create a project and link it to SOL") self.assertEqual(self.sale_order.tasks_count, 1, "The SO should have only one task") self.assertEqual(so_line1.task_id.sale_line_id, so_line1, "The created task is also linked to its origin sale line, for invoicing purpose.") - self.assertFalse(so_line1.task_id.user_id, "The created task should be unassigned") + self.assertFalse(so_line1.task_id.user_ids, "The created task should be unassigned") self.assertEqual(so_line1.product_uom_qty, so_line1.task_id.planned_hours, "The planned hours should be the same as the ordered quantity of the native SO line") so_line1.write({'product_uom_qty': 20}) diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 0b2c8d0d3de..5221810b28a 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -130,7 +130,7 @@ timesheet_invoice_id != False - +