diff --git a/addons/project/models/project_task.py b/addons/project/models/project_task.py index 59bcc965075..8465f69eac3 100644 --- a/addons/project/models/project_task.py +++ b/addons/project/models/project_task.py @@ -100,6 +100,10 @@ class Task(models.Model): default_id = self.env.context.get('default_personal_stage_type_ids') return (default_id or self.env['project.task.type'].search([('user_id', '=', self.env.user.id)], limit=1).ids or [False])[0] + @api.model + def _default_user_ids(self): + return self.env.context.keys() & {'default_personal_stage_type_ids', 'default_personal_stage_type_id'} and self.env.user + @api.model def _default_company_id(self): if self._context.get('default_project_id'): @@ -162,7 +166,8 @@ class Task(models.Model): subtask_allocated_hours = fields.Float("Sub-tasks Allocated Time", compute='_compute_subtask_allocated_hours', help="Sum of the hours allocated for all the sub-tasks (and their own sub-tasks) linked to this task. Usually less than or equal to the allocated hours of this task.") # 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', context={'active_test': False}, tracking=True) + user_ids = fields.Many2many('res.users', relation='project_task_user_rel', column1='task_id', column2='user_id', + string='Assignees', context={'active_test': False}, tracking=True, default=_default_user_ids) # User names displayed in project sharing views portal_user_names = fields.Char(compute='_compute_portal_user_names', compute_sudo=True, search='_search_portal_user_names') # Second Many2many containing the actual personal stage for the current user @@ -1690,3 +1695,16 @@ class Task(models.Model): 'url': '/web#model=project.task&id=%s&action=%s&view_type=form' % (self.id, self.env.ref('project.action_view_my_task').id), 'target': 'new', } + + @api.model + def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): + # A read_group can not be performed if records are grouped by personal_stage_type_id as it is a computed field. + # personal_stage_type_ids behaves like a M2O from the point of view of the user, we therefore use this field instead. + if 'personal_stage_type_id' in groupby and (not lazy or groupby[0] == 'personal_stage_type_id'): + groupby = ["personal_stage_type_ids" if field == "personal_stage_type_id" else field for field in groupby] # limitation: problem when both personal_stage_type_id and personal_stage_type_ids appear in read_group, but this has no functional utility + result = super().read_group(domain, fields, groupby, offset, limit, orderby, lazy) + for group in result: + group['personal_stage_type_id'] = group.pop('personal_stage_type_ids', False) + group['personal_stage_type_id_count'] = group.pop('personal_stage_type_ids_count', 0) + return result + return super().read_group(domain, fields, groupby, offset, limit, orderby, lazy) diff --git a/addons/project/models/project_task_type.py b/addons/project/models/project_task_type.py index d27a9dfce58..c57aecf7e3a 100644 --- a/addons/project/models/project_task_type.py +++ b/addons/project/models/project_task_type.py @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict + from odoo import api, fields, models, _ -from odoo.exceptions import UserError, ValidationError +from odoo.exceptions import UserError class ProjectTaskType(models.Model): @@ -14,6 +16,9 @@ class ProjectTaskType(models.Model): default_project_id = self.env.context.get('default_project_id') return [default_project_id] if default_project_id else None + def _default_user_id(self): + return 'default_project_id' not in self.env.context and self.env.uid + active = fields.Boolean('Active', default=True) name = fields.Char(string='Name', required=True, translate=True) description = fields.Text(translate=True) @@ -41,7 +46,7 @@ class ProjectTaskType(models.Model): " * Neutral or bad feedback will set the kanban state to 'Changes Requested' (orange bullet).\n") disabled_rating_warning = fields.Text(compute='_compute_disabled_rating_warning') - user_id = fields.Many2one('res.users', 'Stage Owner', index=True) + user_id = fields.Many2one('res.users', 'Stage Owner', default=_default_user_id, compute='_compute_user_id', store=True, index=True) def unlink_wizard(self, stage_view=False): self = self.with_context(active_test=False) @@ -79,6 +84,65 @@ class ProjectTaskType(models.Model): default['name'] = _("%s (copy)", self.name) return super().copy(default) + @api.ondelete(at_uninstall=False) + def _unlink_if_remaining_personal_stages(self): + """ Prepare personal stages for deletion (i.e. move task to other personal stages) and + avoid unlink if no remaining personal stages for an active internal user. + """ + # Personal stages are processed if the user still has at least one personal stage after unlink + personal_stages = self.filtered('user_id') + if not personal_stages: + return + remaining_personal_stages_all = self.env['project.task.type']._read_group( + [('user_id', 'in', personal_stages.user_id.ids), ('id', 'not in', personal_stages.ids)], + groupby=['user_id', 'sequence', 'id'], + order="user_id,sequence DESC", + ) + remaining_personal_stages_by_user = defaultdict(list) + for user, sequence, stage in remaining_personal_stages_all: + remaining_personal_stages_by_user[user].append({'id': stage.id, 'seq': sequence}) + + # For performance issue, project.task.stage.personal records that need to be modified are listed before calling _prepare_personal_stages_deletion + personal_stages_to_update = self.env['project.task.stage.personal']._read_group([('stage_id', 'in', personal_stages.ids)], ['stage_id'], ['id:recordset']) + for user in personal_stages.user_id: + if not user.active or user.share: + continue + user_stages_to_unlink = personal_stages.filtered(lambda stage: stage.user_id == user) + user_remaining_stages = remaining_personal_stages_by_user[user] + if not user_remaining_stages: + raise UserError(_("Each user should have at least one personal stage. Create a new stage to which the tasks can be transferred after the selected ones are deleted.")) + user_stages_to_unlink._prepare_personal_stages_deletion(user_remaining_stages, personal_stages_to_update) + + def _prepare_personal_stages_deletion(self, remaining_stages_dict, personal_stages_to_update): + """ _prepare_personal_stages_deletion prepare the deletion of personal stages of a single user. + Tasks using that stage will be moved to the first stage with a lower sequence if it exists + higher if not. + :param self: project.task.type recordset containing the personal stage of a user + that need to be deleted + :param remaining_stages_dict: list of dict representation of the personal stages of a user that + can be used to replace the deleted ones. Can not be empty. + e.g: [{'id': stage1_id, 'seq': stage1_sequence}, ...] + :param personal_stages_to_update: project.task.stage.personal recordset containing the records + that need to be updated after stage modification. Is passed to + this method as an argument to avoid to reload it for each users + when this method is called multiple times. + """ + stages_to_delete_dict = sorted([{'id': stage.id, 'seq': stage.sequence} for stage in self], + key=lambda stage: stage['seq']) + replacement_stage_id = remaining_stages_dict.pop()['id'] + next_replacement_stage = remaining_stages_dict and remaining_stages_dict.pop() + + personal_stages_by_stage = { + stage.id: personal_stages + for stage, personal_stages in personal_stages_to_update + } + for stage in stages_to_delete_dict: + while next_replacement_stage and next_replacement_stage['seq'] < stage['seq']: + replacement_stage_id = next_replacement_stage['id'] + next_replacement_stage = remaining_stages_dict and remaining_stages_dict.pop() + if stage['id'] in personal_stages_by_stage: + personal_stages_by_stage[stage['id']].stage_id = replacement_stage_id + def toggle_active(self): res = super().toggle_active() stage_active = self.filtered('active') @@ -109,38 +173,15 @@ class ProjectTaskType(models.Model): else: stage.disabled_rating_warning = False + @api.depends('project_ids') + def _compute_user_id(self): + """ Fields project_ids and user_id cannot be set together for a stage. It can happen that + project_ids is set after stage creation (e.g. when setting demo data). In such case, the + default user_id has to be removed. + """ + self.sudo().filtered('project_ids').user_id = False + @api.constrains('user_id', 'project_ids') def _check_personal_stage_not_linked_to_projects(self): if any(stage.user_id and stage.project_ids for stage in self): raise UserError(_('A personal stage cannot be linked to a project because it is only visible to its corresponding user.')) - - def remove_personal_stage(self): - """ - Remove a personal stage, tasks using that stage will move to the first - stage with a lower priority if it exists higher if not. - This method will not allow to delete the last personal stage. - Having no personal_stage_type_id makes the task not appear when grouping by personal stage. - """ - self.ensure_one() - assert self.user_id == self.env.user or self.env.su - - users_personal_stages = self.env['project.task.type']\ - .search([('user_id', '=', self.user_id.id)], order='sequence DESC') - if len(users_personal_stages) == 1: - raise ValidationError(_("You should at least have one personal stage. Create a new stage to which the tasks can be transferred after this one is deleted.")) - - # Find the most suitable stage, they are already sorted by sequence - new_stage = self.env['project.task.type'] - for stage in users_personal_stages: - if stage == self: - continue - if stage.sequence > self.sequence: - new_stage = stage - elif stage.sequence <= self.sequence: - new_stage = stage - break - - self.env['project.task.stage.personal'].search([('stage_id', '=', self.id)]).write({ - 'stage_id': new_stage.id, - }) - self.unlink() diff --git a/addons/project/static/src/views/project_task_kanban/project_task_kanban_header.js b/addons/project/static/src/views/project_task_kanban/project_task_kanban_header.js index 03253c95a8a..35441107379 100644 --- a/addons/project/static/src/views/project_task_kanban/project_task_kanban_header.js +++ b/addons/project/static/src/views/project_task_kanban/project_task_kanban_header.js @@ -3,7 +3,6 @@ import { _t } from "@web/core/l10n/translation"; import { useService } from '@web/core/utils/hooks'; import { KanbanHeader } from "@web/views/kanban/kanban_header"; -import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog"; import { onWillStart } from "@odoo/owl"; export class ProjectTaskKanbanHeader extends KanbanHeader { @@ -17,31 +16,11 @@ export class ProjectTaskKanbanHeader extends KanbanHeader { } async onWillStart() { - if (!this.props.list.isGroupedByPersonalStages) { // no need to check it if the group by is personal stages + if (this.props.list.isGroupedByStage) { // no need to check it if not grouped by stage this.isProjectManager = await this.userService.hasGroup('project.group_project_manager'); } } - editGroup() { - const groupBy = this.props.list.groupBy; - if (groupBy.length !== 1 || groupBy[0] !== 'personal_stage_type_ids') { - super.editGroup(); - return; - } - const context = Object.assign({}, this.group.context, { - form_view_ref: 'project.personal_task_type_edit', - }); - this.dialog.add(FormViewDialog, { - context, - resId: this.group.value, - resModel: this.group.groupByField.relation, - title: _t('Edit Personal Stage'), - onRecordSaved: async () => { - await this.props.list.load(); - }, - }); - } - async deleteGroup() { if (this.group.groupByField.name === 'stage_id') { const action = await this.group.model.orm.call( @@ -57,11 +36,11 @@ export class ProjectTaskKanbanHeader extends KanbanHeader { } canEditGroup(group) { - return super.canEditGroup(group) && (!this.props.list.isGroupedByStage || this.isProjectManager) || this.props.list.isGroupedByPersonalStages; + return super.canEditGroup(group) && (!this.props.list.isGroupedByStage || this.isProjectManager); } canDeleteGroup(group) { - return super.canDeleteGroup(group) && (!this.props.list.isGroupedByStage || this.isProjectManager) || this.props.list.isGroupedByPersonalStages; + return super.canDeleteGroup(group) && (!this.props.list.isGroupedByStage || this.isProjectManager); } /** diff --git a/addons/project/static/src/views/project_task_kanban/project_task_kanban_model.js b/addons/project/static/src/views/project_task_kanban/project_task_kanban_model.js index dd887ebac27..84d8b99547f 100644 --- a/addons/project/static/src/views/project_task_kanban/project_task_kanban_model.js +++ b/addons/project/static/src/views/project_task_kanban/project_task_kanban_model.js @@ -3,65 +3,11 @@ import { RelationalModel } from "@web/model/relational_model/relational_model"; export class ProjectTaskKanbanDynamicGroupList extends RelationalModel.DynamicGroupList { - get context() { - const context = { ...super.context }; - if (this.creatingPersonnalStage) { - context.default_user_id = context.uid; - delete context.default_project_id; - } - return context; - } - get isGroupedByStage() { return !!this.groupByField && this.groupByField.name === "stage_id"; } - - get isGroupedByPersonalStages() { - return !!this.groupByField && this.groupByField.name === "personal_stage_type_ids"; - } - - async createGroup(groupName, foldField) { - if (this.isGroupedByPersonalStages) { - return this.model.mutex.exec(async () => { - this.creatingPersonnalStage = true; - await this._createGroup(groupName, foldField); - delete this.creatingPersonnalStage; - }); - } - return super.createGroup(...arguments); - } - - async _unlinkGroups(groups) { - if (this.isGroupedByPersonalStages) { - const groupResIds = groups.map((g) => g.value); - return this.model.orm.call("project.task.type", "remove_personal_stage", groupResIds); - } - return super._deleteGroups(...arguments); - } -} - -export class ProjectTaskRecord extends RelationalModel.Record { - _update(changes, options) { - const value = changes.personal_stage_type_ids; - if (Array.isArray(value)) { - delete changes.personal_stage_type_ids; - changes.personal_stage_type_id = value; - } - return super._update(changes, options); - } - - get context() { - const context = super.context; - const value = context.default_personal_stage_type_ids; - if (Array.isArray(value)) { - context.default_personal_stage_type_id = value[0]; - delete context.default_personal_stage_type_ids; - } - return context; - } } export class ProjectTaskKanbanModel extends RelationalModel {} ProjectTaskKanbanModel.DynamicGroupList = ProjectTaskKanbanDynamicGroupList; -ProjectTaskKanbanModel.Record = ProjectTaskRecord; diff --git a/addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js b/addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js index 836a2550a62..788d5d160af 100644 --- a/addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js +++ b/addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js @@ -12,30 +12,13 @@ export class ProjectTaskKanbanRenderer extends KanbanRenderer { } - get canMoveRecords() { - let canMoveRecords = super.canMoveRecords; - if (!canMoveRecords && this.canResequenceRecords && this.props.list.isGroupedByPersonalStages) { - const { groupByField } = this.props.list; - canMoveRecords = groupByField.readonly !== "True"; - } - return canMoveRecords; - } - - get canResequenceGroups() { - let canResequenceGroups = super.canResequenceGroups; - if (!canResequenceGroups && this.props.list.isGroupedByPersonalStages) { - const { groupsDraggable } = this.props.archInfo; - canResequenceGroups = groupsDraggable && groupsDraggable.readonly !== "True"; - } - return canResequenceGroups; - } - canCreateGroup() { - return (super.canCreateGroup() && this.isProjectTasksContext() && this.props.list.isGroupedByStage) || this.props.list.isGroupedByPersonalStages; + // This restrict the creation of project stages to the kanban view of a given project + return super.canCreateGroup() && (this.isProjectTasksContext() == this.props.list.isGroupedByStage); } isProjectTasksContext() { - return this.props.list.context.active_model === "project.project" && this.props.list.context.default_project_id; + return this.props.list.context.active_model === "project.project" && !!this.props.list.context.default_project_id; } } diff --git a/addons/project/views/project_task_type_views.xml b/addons/project/views/project_task_type_views.xml index 4c401b9046f..187f3781ed4 100644 --- a/addons/project/views/project_task_type_views.xml +++ b/addons/project/views/project_task_type_views.xml @@ -25,9 +25,16 @@ - - -