diff --git a/addons/crm/controllers/main.py b/addons/crm/controllers/main.py index 4f1a6960645..f9d12a6c236 100644 --- a/addons/crm/controllers/main.py +++ b/addons/crm/controllers/main.py @@ -4,6 +4,7 @@ import logging from odoo.addons.mail.controllers.main import MailController from odoo import http +from odoo.http import request _logger = logging.getLogger(__name__) @@ -15,7 +16,7 @@ class CrmController(http.Controller): comparison, record, redirect = MailController._check_token_and_record_or_redirect('crm.lead', int(res_id), token) if comparison and record: try: - record.action_set_won() + record.action_set_won_rainbowman() except Exception: _logger.exception("Could not mark crm.lead as won") return MailController._redirect_to_messaging() diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 48008cf577c..4827eddd040 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -739,6 +739,26 @@ class Lead(models.Model): self.ensure_one() self.action_set_won() + message = self._get_rainbowman_message() + if message: + return { + 'effect': { + 'fadeout': 'slow', + 'message': message, + 'img_url': '/web/image/%s/%s/image_1024' % (self.team_id.user_id._name, self.team_id.user_id.id) if self.team_id.user_id.image_1024 else '/web/static/src/img/smile.svg', + 'type': 'rainbow_man', + } + } + return True + + def get_rainbowman_message(self): + self.ensure_one() + if self.stage_id.is_won: + return self._get_rainbowman_message() + return False + + def _get_rainbowman_message(self): + message = False if self.user_id and self.team_id and self.expected_revenue: query = """ SELECT @@ -763,7 +783,6 @@ class Lead(models.Model): 'team_id': self.team_id.id}) query_result = self.env.cr.dictfetchone() - message = False if query_result['total_won'] == 1: message = _('Go, go, go! Congrats for your first deal.') elif query_result['max_team_30'] == self.expected_revenue: @@ -774,17 +793,7 @@ class Lead(models.Model): message = _('You just beat your personal record for the past 30 days.') elif query_result['max_user_7'] == self.expected_revenue: message = _('You just beat your personal record for the past 7 days.') - - if message: - return { - 'effect': { - 'fadeout': 'slow', - 'message': message, - 'img_url': '/web/image/%s/%s/image_1024' % (self.team_id.user_id._name, self.team_id.user_id.id) if self.team_id.user_id.image_1024 else '/web/static/src/img/smile.svg', - 'type': 'rainbow_man', - } - } - return True + return message def action_schedule_meeting(self): """ Open meeting's calendar view to schedule meeting on current opportunity. diff --git a/addons/crm/static/src/js/crm_form.js b/addons/crm/static/src/js/crm_form.js new file mode 100644 index 00000000000..b8f026e618f --- /dev/null +++ b/addons/crm/static/src/js/crm_form.js @@ -0,0 +1,53 @@ +odoo.define("crm.crm_form", function (require) { + "use strict"; + + /** + * This From Controller makes sure we display a rainbowman message + * when the stage is won, even when we click on the statusbar. + * When the stage of a lead is changed and data are saved, we check + * if the lead is won and if a message should be displayed to the user + * with a rainbowman like when the user click on the button "Mark Won". + */ + + var FormController = require('web.FormController'); + var FormView = require('web.FormView'); + var viewRegistry = require('web.view_registry'); + + var CrmFormController = FormController.extend({ + /** + * After data are saved we display a possible rainbowman + * message when the stage is won. + * @override + */ + _applyChanges: async function (dataPointID, changes, event) { + var result = await this._super(...arguments); + if ('stage_id' in changes) { + const message = await this._rpc({ + model: 'crm.lead', + method : 'get_rainbowman_message', + args: [[parseInt(event.target.res_id)]], + }); + if (message) { + this.trigger_up('show_effect', { + message: message, + type: 'rainbow_man', + }); + } + } + return result; + }, + }); + + var CrmFormView = FormView.extend({ + config: _.extend({}, FormView.prototype.config, { + Controller: CrmFormController, + }), + }); + + viewRegistry.add('crm_form', CrmFormView); + + return { + CrmFormController: CrmFormController, + CrmFormView: CrmFormView, + }; +}); diff --git a/addons/crm/static/src/js/crm_kanban.js b/addons/crm/static/src/js/crm_kanban.js new file mode 100644 index 00000000000..dbc50064c7a --- /dev/null +++ b/addons/crm/static/src/js/crm_kanban.js @@ -0,0 +1,52 @@ +odoo.define('crm.crm_kanban', function (require) { + "use strict"; + + /** + * This Kanban Model make sure we display a rainbowman + * message when a lead is won after we moved it in the + * correct column and when it's grouped by stage_id (default). + */ + + var KanbanModel = require('web.KanbanModel'); + var KanbanView = require('web.KanbanView'); + var viewRegistry = require('web.view_registry'); + + var CrmKanbanModel = KanbanModel.extend({ + /** + * Check if the kanban view is grouped by "stage_id" before checking if the lead is won + * and displaying a possible rainbowman message. + * @override + */ + moveRecord: async function (recordID, groupID, parentID) { + var result = await this._super(...arguments); + if (this.localData[parentID].groupedBy[0] === this.defaultGroupedBy[0]) { + const message = await this._rpc({ + model: 'crm.lead', + method : 'get_rainbowman_message', + args: [[parseInt(this.localData[recordID].res_id)]], + }); + if (message) { + this.trigger_up('show_effect', { + message: message, + type: 'rainbow_man', + }); + } + } + return result; + }, + }); + + var CrmKanbanView = KanbanView.extend({ + config: _.extend({}, KanbanView.prototype.config, { + Model: CrmKanbanModel, + }), + }); + + viewRegistry.add('crm_kanban', CrmKanbanView); + + return { + CrmKanbanModel: CrmKanbanModel, + CrmKanbanView: CrmKanbanView, + }; + +}); diff --git a/addons/crm/static/tests/crm_rainbowman_tests.js b/addons/crm/static/tests/crm_rainbowman_tests.js new file mode 100644 index 00000000000..ff7f91967eb --- /dev/null +++ b/addons/crm/static/tests/crm_rainbowman_tests.js @@ -0,0 +1,360 @@ +odoo.define('crm.form_rainbowman_tests', function (require) { + "use strict"; + + var CrmFormView = require('crm.crm_form').CrmFormView; + var CrmKanbanView = require('crm.crm_kanban').CrmKanbanView; + var testUtils = require('web.test_utils'); + var createView = testUtils.createView; + + QUnit.module('Crm Rainbowman Triggers', { + beforeEach: function () { + const format = "YYYY-MM-DD HH:mm:ss"; + this.data = { + 'res.users': { + fields: { + display_name: { string: 'Name', type: 'char' }, + }, + records: [ + { id: 1, name: 'Mario' }, + { id: 2, name: 'Luigi' }, + { id: 3, name: 'Link' }, + { id: 4, name: 'Zelda' }, + ], + }, + 'crm.team': { + fields: { + display_name: { string: 'Name', type: 'char' }, + member_ids: { string: 'Members', type: 'many2many', relation: 'res.users' }, + }, + records: [ + { id: 1, name: 'Mushroom Kingdom', member_ids: [1, 2] }, + { id: 2, name: 'Hyrule', member_ids: [3, 4] }, + ], + }, + 'crm.stage': { + fields: { + display_name: { string: 'Name', type: 'char' }, + is_won: { string: 'Is won', type: 'boolean' }, + }, + records: [ + { id: 1, name: 'Start' }, + { id: 2, name: 'Middle' }, + { id: 3, name: 'Won', is_won: true}, + ], + }, + 'crm.lead': { + fields: { + display_name: { string: 'Name', type: 'char' }, + planned_revenue: { string: 'Revenue', type: 'float' }, + stage_id: { string: 'Stage', type: 'many2one', relation: 'crm.stage' }, + team_id: { string: 'Sales Team', type: 'many2one', relation: 'crm.team' }, + user_id: { string: 'Salesperson', type: 'many2one', relation: 'res.users' }, + date_closed: { string: 'Date closed', type: 'datetime' }, + }, + records : [ + { id: 1, name: 'Lead 1', planned_revenue: 5.0, stage_id: 1, team_id: 1, user_id: 1 }, + { id: 2, name: 'Lead 2', planned_revenue: 5.0, stage_id: 2, team_id: 2, user_id: 4 }, + { id: 3, name: 'Lead 3', planned_revenue: 3.0, stage_id: 3, team_id: 1, user_id: 1, date_closed: moment().subtract(5, 'days').format(format) }, + { id: 4, name: 'Lead 4', planned_revenue: 4.0, stage_id: 3, team_id: 2, user_id: 4, date_closed: moment().subtract(23, 'days').format(format) }, + { id: 5, name: 'Lead 5', planned_revenue: 7.0, stage_id: 3, team_id: 1, user_id: 1, date_closed: moment().subtract(20, 'days').format(format) }, + { id: 6, name: 'Lead 6', planned_revenue: 4.0, stage_id: 2, team_id: 1, user_id: 2 }, + { id: 7, name: 'Lead 7', planned_revenue: 1.8, stage_id: 3, team_id: 2, user_id: 3, date_closed: moment().subtract(23, 'days').format(format) }, + { id: 8, name: 'Lead 8', planned_revenue: 1.9, stage_id: 1, team_id: 2, user_id: 3 }, + { id: 9, name: 'Lead 9', planned_revenue: 1.5, stage_id: 3, team_id: 2, user_id: 3, date_closed: moment().subtract(5, 'days').format(format) }, + { id: 10, name: 'Lead 10', planned_revenue: 1.7, stage_id: 2, team_id: 2, user_id: 3 }, + { id: 11, name: 'Lead 11', planned_revenue: 2.0, stage_id: 3, team_id: 2, user_id: 4, date_closed: moment().subtract(5, 'days').format(format) }, + ], + }, + }; + this.testFormView = { + arch: ` +
+
+ + + + + `, + data: this.data, + model: 'crm.lead', + View: CrmFormView, + }; + this.testKanbanView = { + arch: ` + + + +
+
+
+
`, + data: this.data, + model: 'crm.lead', + View: CrmKanbanView, + groupBy: ['stage_id'], + }; + }, + }, function () { + QUnit.test("first lead won, click on statusbar", async function (assert) { + assert.expect(2); + + this.testFormView.res_id = 6; + this.testFormView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const form = await createView(this.testFormView); + + await testUtils.dom.click(form.$(".o_statusbar_status button[data-value='3']")); + assert.verifySteps(['Go, go, go! Congrats for your first deal.']); + + form.destroy(); + }); + + QUnit.test("team record 30 days, click on statusbar", async function (assert) { + assert.expect(2); + + this.testFormView.res_id = 2; + this.testFormView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const form = await createView(this.testFormView); + + await testUtils.dom.click(form.$(".o_statusbar_status button[data-value='3']")); + assert.verifySteps(['Boom! Team record for the past 30 days.']); + + form.destroy(); + }); + + QUnit.test("team record 7 days, click on statusbar", async function (assert) { + assert.expect(2); + + this.testFormView.res_id = 1; + this.testFormView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const form = await createView(this.testFormView); + + await testUtils.dom.click(form.$(".o_statusbar_status button[data-value='3']")); + assert.verifySteps(['Yeah! Deal of the last 7 days for the team.']); + + form.destroy(); + }); + + QUnit.test("user record 30 days, click on statusbar", async function (assert) { + assert.expect(2); + + this.testFormView.res_id = 8; + this.testFormView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const form = await createView(this.testFormView); + + await testUtils.dom.click(form.$(".o_statusbar_status button[data-value='3']")); + assert.verifySteps(['You just beat your personal record for the past 30 days.']); + + form.destroy(); + }); + + QUnit.test("user record 7 days, click on statusbar", async function (assert) { + assert.expect(2); + + this.testFormView.res_id = 10; + this.testFormView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const form = await createView(this.testFormView); + + await testUtils.dom.click(form.$(".o_statusbar_status button[data-value='3']")); + assert.verifySteps(['You just beat your personal record for the past 7 days.']); + + form.destroy(); + }); + + QUnit.test("click on stage (not won) on statusbar", async function (assert) { + assert.expect(2); + + this.testFormView.res_id = 1; + this.testFormView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const form = await createView(this.testFormView); + + await testUtils.dom.click(form.$(".o_statusbar_status button[data-value='2']")); + assert.verifySteps(['no rainbowman']); + + form.destroy(); + }); + + QUnit.test("first lead won, drag & drop kanban", async function (assert) { + assert.expect(2); + + this.testKanbanView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const kanban = await createView(this.testKanbanView); + + kanban.model.defaultGroupedBy = ['stage_id']; + await kanban.reload(); + + await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_record:contains("Lead 6")'), kanban.$('.o_kanban_group:eq(2)')); + assert.verifySteps(['Go, go, go! Congrats for your first deal.']); + + kanban.destroy(); + }); + + QUnit.test("team record 30 days, drag & drop kanban", async function (assert) { + assert.expect(2); + + this.testKanbanView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const kanban = await createView(this.testKanbanView); + + kanban.model.defaultGroupedBy = ['stage_id']; + await kanban.reload(); + + await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_record:contains("Lead 2")'), kanban.$('.o_kanban_group:eq(2)')); + assert.verifySteps(['Boom! Team record for the past 30 days.']); + + kanban.destroy(); + }); + + QUnit.test("team record 7 days, drag & drop kanban", async function (assert) { + assert.expect(2); + + this.testKanbanView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const kanban = await createView(this.testKanbanView); + + kanban.model.defaultGroupedBy = ['stage_id']; + await kanban.reload(); + + await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:contains("Lead 1")'), kanban.$('.o_kanban_group:eq(2)')); + assert.verifySteps(['Yeah! Deal of the last 7 days for the team.']); + + kanban.destroy(); + }); + + QUnit.test("user record 30 days, drag & drop kanban", async function (assert) { + assert.expect(2); + + this.testKanbanView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const kanban = await createView(this.testKanbanView); + + kanban.model.defaultGroupedBy = ['stage_id']; + await kanban.reload(); + + await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_record:contains("Lead 8")'), kanban.$('.o_kanban_group:eq(2)')); + assert.verifySteps(['You just beat your personal record for the past 30 days.']); + + kanban.destroy(); + }); + + QUnit.test("user record 7 days, drag & drop kanban", async function (assert) { + assert.expect(2); + + this.testKanbanView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const kanban = await createView(this.testKanbanView); + + kanban.model.defaultGroupedBy = ['stage_id']; + await kanban.reload(); + + await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_record:contains("Lead 10")'), kanban.$('.o_kanban_group:eq(2)')); + assert.verifySteps(['You just beat your personal record for the past 7 days.']); + + kanban.destroy(); + }); + + QUnit.test("drag & drop record kanban in stage not won", async function (assert) { + assert.expect(2); + + this.testKanbanView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + const kanban = await createView(this.testKanbanView); + + kanban.model.defaultGroupedBy = ['stage_id']; + await kanban.reload(); + + await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_record:contains("Lead 8")'), kanban.$('.o_kanban_group:eq(1)')); + assert.verifySteps(["no rainbowman"]); + + kanban.destroy(); + }); + + QUnit.test("drag & drop record in kanban not grouped by stage_id", async function (assert) { + assert.expect(1); + + this.testKanbanView.mockRPC = async function (route, args) { + const result = await this._super(...arguments); + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + assert.step(result || "no rainbowman"); + } + return result; + }; + this.testKanbanView.groupBy = ['user_id']; + const kanban = await createView(this.testKanbanView); + + kanban.model.defaultGroupedBy = ['stage_id']; + await kanban.reload(); + + await testUtils.dom.dragAndDrop(kanban.$('.o_kanban_group:eq(0) .o_kanban_record:first'), kanban.$('.o_kanban_group:eq(1)')); + assert.verifySteps([]); // Should never pass by the rpc + + kanban.destroy(); + }); + }); +}); diff --git a/addons/crm/static/tests/mock_server.js b/addons/crm/static/tests/mock_server.js new file mode 100644 index 00000000000..0c6c0fbb64e --- /dev/null +++ b/addons/crm/static/tests/mock_server.js @@ -0,0 +1,56 @@ +odoo.define('crm.MockServer', function (require) { + 'use strict'; + + var MockServer = require('web.MockServer'); + + MockServer.include({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + async _performRpc(route, args) { + if (args.model === 'crm.lead' && args.method === 'get_rainbowman_message') { + let message = false; + const records = this.data['crm.lead'].records; + const record = records.find(r => r.id === args.args[0][0]); + const won_stage = this.data['crm.stage'].records.find(s => s.is_won); + if (record.stage_id === won_stage.id && record.user_id && record.team_id && record.planned_revenue > 0) { + const now = moment(); + let query_result = {}; + // Total won + query_result['total_won'] = records.filter(r => r.stage_id === won_stage.id && r.user_id === record.user_id).length; + // Max team 30 days + const recordsTeam30 = records.filter(r => r.stage_id === won_stage.id && r.team_id === record.team_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 30)); + query_result['max_team_30'] = Math.max(...recordsTeam30.map(r => r.planned_revenue)); + // Max team 7 days + const recordsTeam7 = records.filter(r => r.stage_id === won_stage.id && r.team_id === record.team_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 7)); + query_result['max_team_7'] = Math.max(...recordsTeam7.map(r => r.planned_revenue)); + // Max User 30 days + const recordsUser30 = records.filter(r => r.stage_id === won_stage.id && r.user_id === record.user_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 30)); + query_result['max_user_30'] = Math.max(...recordsUser30.map(r => r.planned_revenue)); + // Max User 7 days + const recordsUser7 = records.filter(r => r.stage_id === won_stage.id && r.user_id === record.user_id && (!r.date_closed || moment.duration(now.diff(moment(r.date_closed))).days() <= 7)); + query_result['max_user_7'] = Math.max(...recordsUser7.map(r => r.planned_revenue)); + + if (query_result.total_won === 1) { + message = "Go, go, go! Congrats for your first deal."; + } else if (query_result.max_team_30 === record.planned_revenue) { + message = "Boom! Team record for the past 30 days."; + } else if (query_result.max_team_7 === record.planned_revenue) { + message = "Yeah! Deal of the last 7 days for the team."; + } else if (query_result.max_user_30 === record.planned_revenue) { + message = "You just beat your personal record for the past 30 days."; + } else if (query_result.max_user_7 === record.planned_revenue) { + message = "You just beat your personal record for the past 7 days."; + } + } + return message; + } + return this._super(...arguments); + }, + }); +}); diff --git a/addons/crm/static/tests/tours/crm_rainbowman.js b/addons/crm/static/tests/tours/crm_rainbowman.js new file mode 100644 index 00000000000..67c488b816e --- /dev/null +++ b/addons/crm/static/tests/tours/crm_rainbowman.js @@ -0,0 +1,66 @@ +odoo.define('crm.tour_crm_rainbowman', function (require) { + "use strict"; + + var tour = require('web_tour.tour'); + + tour.register('crm_rainbowman', { + test: true, + url: "/web", + }, [ + tour.stepUtils.showAppsMenuItem(), + { + trigger: ".o_app[data-menu-xmlid='crm.crm_menu_root']", + content: "open crm app", + }, { + trigger: ".o-kanban-button-new", + content: "click create", + }, { + trigger: "input[name=name]", + content: "complete name", + run: "text Test Lead 1", + }, { + trigger: "div[name=planned_revenue] > input", + content: "complete planned revenue", + run: "text 999999997", + }, { + trigger: "button.o_kanban_add", + content: "create lead", + }, { + trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Lead 1')", + content: "move to won stage", + run: "drag_and_drop .o_opportunity_kanban .o_kanban_group:eq(3) " + }, { + trigger: ".o-kanban-button-new", + extra_trigger: ".o_reward_rainbow", + content: "click create", + }, { + trigger: "input[name=name]", + content: "complete name", + run: "text Test Lead 2", + }, { + trigger: "div[name=planned_revenue] > input", + content: "complete planned revenue", + run: "text 999999998", + }, { + trigger: "button.o_kanban_add", + content: "create lead", + }, { + trigger: ".o_kanban_record .o_kanban_record_title:contains('Test Lead 2')", + content: "click on lead", + }, { + trigger: ".o_statusbar_status button[data-value='4']", + content: "move lead to won stage", + }, { + trigger: ".o_statusbar_status button[data-value='1']", + extra_trigger: ".o_reward_rainbow", + content: "move lead to previous stage & rainbowman appears", + }, { + trigger: "button[name=action_set_won_rainbowman]", + content: "click button mark won", + }, { + trigger: ".o_menu_brand", + extra_trigger: ".o_reward_rainbow", + content: "last rainbowman appears", + } + ]); +}); diff --git a/addons/crm/views/assets.xml b/addons/crm/views/assets.xml index e3edab1eee4..1ffed45b2a8 100644 --- a/addons/crm/views/assets.xml +++ b/addons/crm/views/assets.xml @@ -2,8 +2,21 @@ + + diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index cdc95c11ada..2c3357d1d47 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -4,7 +4,7 @@ crm.lead.form crm.lead -
+