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