From 46efe7df1bb15fb6bf6a46eb1bd0e48f42721bea Mon Sep 17 00:00:00 2001 From: William Braeckman Date: Fri, 20 May 2022 08:55:25 +0000 Subject: [PATCH] [IMP] project: allow modification of personal stages This commit allows project users to create and modify their own personal stages. A lot of overrides have been done on the kanban view to handle our multiple cases. Project user will now only see the options to create columns/edit stages when grouping by personal stage. This is hardcoded and does not follow possible custom access rules however. Any stage created while grouping by personal stage will directly be assigned to the user and will be seen in the 'My Tasks' menu. A special method has been added to delete a personal stage. Upon deletion any task assigned to this personal stage will move to a lower sequence stage if possible otherwise the next in line. TaskId-2858445 closes odoo/odoo#92103 X-original-commit: 7b3acbc0b0c21ab106b1b34d9f5c8bc1ceb2fe58 Signed-off-by: William Braeckman (wbr) Signed-off-by: Laurent Stukkens (ltu) --- addons/project/i18n/project.pot | 18 ++ addons/project/models/project.py | 31 +++ .../project/static/src/js/project_kanban.js | 182 ++++++++++++++++-- .../static/tests/tours/personal_stage_tour.js | 63 ++++++ addons/project/tests/test_personal_stages.py | 9 +- addons/project/tests/test_project_base.py | 3 +- addons/project/views/project_views.xml | 27 +++ 7 files changed, 317 insertions(+), 16 deletions(-) create mode 100644 addons/project/static/tests/tours/personal_stage_tour.js diff --git a/addons/project/i18n/project.pot b/addons/project/i18n/project.pot index 10e729dbed6..b7af3277989 100644 --- a/addons/project/i18n/project.pot +++ b/addons/project/i18n/project.pot @@ -666,6 +666,7 @@ msgid "Add Milestone" msgstr "" #. module: project +#: model_terms:ir.ui.view,arch_db:project.personal_task_type_edit #: model_terms:ir.ui.view,arch_db:project.task_type_edit msgid "Add a description..." msgstr "" @@ -839,6 +840,7 @@ msgstr "" #. module: project #: model_terms:ir.ui.view,arch_db:project.edit_project +#: model_terms:ir.ui.view,arch_db:project.personal_task_type_edit #: model_terms:ir.ui.view,arch_db:project.project_project_stage_view_form #: model_terms:ir.ui.view,arch_db:project.project_project_stage_view_search #: model_terms:ir.ui.view,arch_db:project.project_sharing_project_task_view_form @@ -1774,6 +1776,13 @@ msgstr "" msgid "Edit" msgstr "" +#. module: project +#. openerp-web +#: code:addons/project/static/src/js/project_kanban.js:0 +#, python-format +msgid "Edit Personal Stage" +msgstr "" + #. module: project #: model_terms:ir.ui.view,arch_db:project.view_task_form2 msgid "Edit recurring task" @@ -4441,6 +4450,7 @@ msgstr "" #. module: project #: model:ir.model,name:project.model_project_task_type +#: model_terms:ir.ui.view,arch_db:project.personal_task_type_edit #: model_terms:ir.ui.view,arch_db:project.task_type_edit #: model_terms:ir.ui.view,arch_db:project.task_type_tree msgid "Task Stage" @@ -5232,6 +5242,14 @@ msgstr "" msgid "You have not write access of %s field." msgstr "" +#. module: project +#: code:addons/project/models/project.py:0 +#, python-format +msgid "" +"You should at least have one personal stage. Create a new stage to which the" +" tasks can be transferred after this one is deleted." +msgstr "" + #. module: project #: model_terms:ir.ui.view,arch_db:project.project_sharing_project_task_view_kanban msgid "assignee" diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 020d0cac990..b0fea1b5869 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -144,6 +144,37 @@ class ProjectTaskType(models.Model): else: stage.disabled_rating_warning = False + 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() + class Project(models.Model): _name = "project.project" _description = "Project" diff --git a/addons/project/static/src/js/project_kanban.js b/addons/project/static/src/js/project_kanban.js index 4289c5ddcf5..330d17d44c1 100644 --- a/addons/project/static/src/js/project_kanban.js +++ b/addons/project/static/src/js/project_kanban.js @@ -9,6 +9,11 @@ import KanbanModel from 'web.KanbanModel'; import viewRegistry from 'web.view_registry'; import { ProjectControlPanel } from '@project/js/project_control_panel'; import viewUtils from 'web.viewUtils'; +import { Domain } from '@web/core/domain'; +import view_dialogs from 'web.view_dialogs'; +import core from 'web.core'; + +const _t = core._t; // PROJECTS @@ -66,9 +71,33 @@ const ProjectTaskKanbanColumn = KanbanColumn.extend({ if (this.groupedBy === 'stage_id') { event.preventDefault(); this.trigger_up('kanban_column_delete_wizard'); + } else { + this._super(...arguments); + } + }, + + /** + * Open alternative view when editing personal stages. + * + * @private + * @override + */ + _onEditColumn: function (event) { + if (this.groupedBy !== 'personal_stage_type_ids') { + this._super(...arguments); return; } - this._super.apply(this, arguments); + event.preventDefault(); + const context = Object.assign({}, this.getSession().user_context, { + form_view_ref: 'project.personal_task_type_edit', + }); + new view_dialogs.FormViewDialog(this, { + res_model: this.relation, + res_id: this.id, + context: context, + title: _t("Edit Personal Stage"), + on_saved: this.trigger_up.bind(this, 'reload'), + }).open(); }, }); @@ -77,6 +106,23 @@ const ProjectTaskKanbanRenderer = KanbanRenderer.extend({ KanbanColumn: ProjectTaskKanbanColumn, }), + init: function () { + this._super.apply(this, arguments); + this.isProjectManager = false; + }, + + willStart: function () { + const superPromise = this._super.apply(this, arguments); + + const isProjectManager = this.getSession().user_has_group('project.group_project_manager').then((hasGroup) => { + this.isProjectManager = hasGroup; + this._setState(); + return Promise.resolve(); + }); + + return Promise.all([superPromise, isProjectManager]); + }, + /** * Allows record drag when grouping by `personal_stage_type_ids` * @@ -92,12 +138,25 @@ const ProjectTaskKanbanRenderer = KanbanRenderer.extend({ const grouped_by_date = ["date", "datetime"].includes(field.type); const grouped_by_m2m = field.type === "many2many"; const readonly = !!field.readonly || !!fieldInfo.readonly; + const groupedByPersonalStage = (groupByFieldName === 'personal_stage_type_ids'); - const draggable = !readonly && (!grouped_by_m2m || groupByFieldName == 'personal_stage_type_ids') && + const draggable = !readonly && (!grouped_by_m2m || groupedByPersonalStage) && (!grouped_by_date || fieldInfo.allowGroupRangeValue); + // When grouping by personal stage we allow any project user to create + let editable = this.columnOptions.editable; + let deletable = this.columnOptions.deletable; + if (['stage_id', 'personal_stage_type_ids'].includes(groupByFieldName)) { + this.groupedByM2O = groupedByPersonalStage || this.groupedByM2O; + const allow_crud = this.isProjectManager || groupedByPersonalStage; + this.createColumnEnabled = editable = deletable = allow_crud; + } + Object.assign(this.columnOptions, { draggable, + grouped_by_m2o: this.groupedByM2O, + editable: editable, + deletable: deletable, }); } }); @@ -121,6 +180,24 @@ export const ProjectKanbanController = KanbanController.extend({ self.do_action(res); }); }, + + /** + * @override + */ + _onDeleteColumn: function (ev) { + const state = this.model.get(this.handle, {raw: true}); + const groupedByFieldname = state.groupedBy[0]; + if (groupedByFieldname !== 'personal_stage_type_ids') { + this._super(...arguments); + return; + } + const column = ev.target; + this._rpc({ + model: 'project.task.type', + method: 'remove_personal_stage', + args: [[column.id]], + }).then(this.update.bind(this, {}, {})); + }, }); const ProjectTaskKanbanModel = KanbanModel.extend({ @@ -132,12 +209,12 @@ const ProjectTaskKanbanModel = KanbanModel.extend({ * @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]; + const self = this; + const parent = this.localData[parentID]; + const new_group = this.localData[groupID]; + const changes = {}; + const groupedFieldName = viewUtils.getGroupByField(parent.groupedBy[0]); + const 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); @@ -147,7 +224,7 @@ const ProjectTaskKanbanModel = KanbanModel.extend({ display_name: new_group.value, }; } else if (groupedField.type === 'selection') { - var value = _.findWhere(groupedField.selection, {1: new_group.value}); + const 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'] = { @@ -161,13 +238,13 @@ const ProjectTaskKanbanModel = KanbanModel.extend({ // 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; + const record = self.localData[recordID]; + const resID = record.res_id; // Remove record from its current group - var old_group; - for (var i = 0; i < parent.data.length; i++) { + let old_group; + for (let i = 0; i < parent.data.length; i++) { old_group = self.localData[parent.data[i]]; - var index = _.indexOf(old_group.data, recordID); + const index = _.indexOf(old_group.data, recordID); if (index >= 0) { old_group.data.splice(index, 1); old_group.count--; @@ -193,6 +270,83 @@ const ProjectTaskKanbanModel = KanbanModel.extend({ return [old_group.id, new_group.id]; }); }, + + /** + * When grouped by personal stage create a new personal stage instead of + * a regular stage. + * Meaning setting `user_id` on the stage. + * + * @override + */ + createGroup: function (name, parentID) { + const parent = this.localData[parentID]; + const groupedFieldName = viewUtils.getGroupByField(parent.groupedBy[0]); + if (groupedFieldName !== 'personal_stage_type_ids') { + return this._super(...arguments); + } + const groupBy = parent.groupedBy[0]; + const context = Object.assign({}, parent.context, { + default_user_id: this.getSession().user_id[0], + }); + // In case it's a personal stage we don't want to assign it to the project. + delete context.default_project_id; + return this._rpc({ + model: 'project.task.type', + method: 'name_create', + args: [name], + context: context, + }) + .then((result) => { + const createGroupDataPoint = (model, parent) => { + const newGroup = model._makeDataPoint({ + modelName: parent.model, + context: parent.context, + domain: parent.domain.concat([[groupBy, "=", result[0]]]), + fields: parent.fields, + fieldsInfo: parent.fieldsInfo, + isOpen: true, + limit: parent.limit, + parentID: parent.id, + openGroupByDefault: true, + orderedBy: parent.orderedBy, + value: result, + viewType: parent.viewType, + }); + if (parent.progressBar) { + newGroup.progressBarValues = _.extend({ + counts: {}, + }, parent.progressBar); + } + return newGroup; + }; + const newGroup = createGroupDataPoint(this, parent); + parent.data.push(newGroup.id); + if (this.isInSampleMode()) { + // in sample mode, create the new group in both models (main + sample) + const sampleParent = this.sampleModel.localData[parentID]; + const newSampleGroup = createGroupDataPoint(this.sampleModel, sampleParent); + sampleParent.data.push(newSampleGroup.id); + } + return newGroup.id; + }); + }, + + /** + * Force tasks assigned to the user when grouping by personal stage. + * + * @override + * @private + */ + _readGroup: function (list) { + const groupedBy = list.groupedBy[0]; + if (groupedBy === 'personal_stage_type_ids') { + list.domain = Domain.and([ + [['user_ids', 'in', this.getSession().user_id[0]]], + list.domain + ]).toList(); + } + return this._super(...arguments); + }, }) const ProjectKanbanView = KanbanView.extend({ diff --git a/addons/project/static/tests/tours/personal_stage_tour.js b/addons/project/static/tests/tours/personal_stage_tour.js new file mode 100644 index 00000000000..134f40dcc63 --- /dev/null +++ b/addons/project/static/tests/tours/personal_stage_tour.js @@ -0,0 +1,63 @@ +/** @odoo-module */ + +import tour from 'web_tour.tour'; + +tour.register('personal_stage_tour', { + test: true, + url: '/web', +}, +[tour.stepUtils.showAppsMenuItem(), { + trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]', +}, { + content: "Open Pig Project", + trigger: '.o_kanban_record:contains("Pig")', +}, { + // Default is grouped by stage, user should not be able to create/edit a column + content: "Check that there is no create column", + trigger: "body:not(.o_column_quick_create)", + run: function () {}, +}, { + content: "Check that there is no create column", + trigger: "body:not(.o_column_edit)", + run: function () {}, +}, { + content: "Check that there is no create column", + trigger: "body:not(.o_column_delete)", + run: function () {}, +}, { + content: "Go to my tasks", // My tasks is grouped by personal stage by default + trigger: 'a[data-menu-xmlid="project.menu_project_management"]', +}, { + content: "Check that we can create a new stage", + trigger: '.o_column_quick_create .o_quick_create_folded' +}, { + content: "Create a new personal stage", + trigger: 'input[placeholder="Column title"]', + run: 'text Never', +}, { + content: "Confirm create", + trigger: '.o_kanban_add', +}, { + content: "Check that column exists", + trigger: '.o_kanban_header:contains("Never")', + run: function () {}, +}, { + content: 'Open column edit dropdown', + trigger: '.o_kanban_header:eq(0)', + run: function () { + $('.o_kanban_config.dropdown .dropdown-toggle').eq(0).click(); + }, +}, { + content: "Try editing inbox", + trigger: ".dropdown-item.o_column_edit", +}, { + content: "Change title", + trigger: 'input.o_field_char[name="name"]', + run: 'text (Todo)', +}, { + content: "Save changes", + trigger: '.btn-primary:contains("Save")', +}, { + content: "Check that column was updated", + trigger: '.o_kanban_header:contains("Todo")', +}]); diff --git a/addons/project/tests/test_personal_stages.py b/addons/project/tests/test_personal_stages.py index 3fc3f59b8a9..20dbaacd191 100644 --- a/addons/project/tests/test_personal_stages.py +++ b/addons/project/tests/test_personal_stages.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from odoo.tests import tagged +from odoo.tests import tagged, HttpCase from .test_project_base import TestProjectCommon @@ -89,3 +89,10 @@ class TestPersonalStages(TestProjectCommon): '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) + +@tagged('-at_install', 'post_install') +class TestPersonalStageTour(HttpCase, TestProjectCommon): + + def test_personal_stage_tour(self): + # Test customizing personal stages as a project user + self.start_tour('/web', 'personal_stage_tour', login="armandel") diff --git a/addons/project/tests/test_project_base.py b/addons/project/tests/test_project_base.py index 8ba3c66640d..394e8fe8925 100644 --- a/addons/project/tests/test_project_base.py +++ b/addons/project/tests/test_project_base.py @@ -41,7 +41,8 @@ class TestProjectCommon(TransactionCase): 'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])]}) cls.user_projectuser = Users.create({ 'name': 'Armande ProjectUser', - 'login': 'Armande', + 'login': 'armandel', + 'password': 'armandel', 'email': 'armande.projectuser@example.com', 'groups_id': [(6, 0, [user_group_employee.id, user_group_project_user.id])] }) diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 3f7562122e4..5b1ab44ede2 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -274,6 +274,33 @@ + + project.task.type.form + project.task.type + +
+ + + + + + + + + + + + + + + + + + + +
+
+ project.task.type.tree project.task.type