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