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