From 1de9496f14a42d543ec9805dfc1d0e8df39017d2 Mon Sep 17 00:00:00 2001 From: XavierDo Date: Wed, 1 Aug 2018 14:51:01 +0200 Subject: [PATCH] [IMP] mail: add activity view A simple new view to easily display all activity on a model grouped by res_id and activity_type_id. Some action are possible like sending a mail template for all record having an activity with this activity type and present in the view (depends on the filter). The view use the Kanban activity view on cells. It could be a good idea to rename this widget to something like "DropdownActivityView" (maybe after the freeze) Task: #1870662 PR: #26272 --- addons/contacts/views/contact_views.xml | 2 +- addons/mail/__manifest__.py | 1 + addons/mail/models/mail_activity.py | 69 +++++-- addons/mail/static/src/js/activity.js | 1 + .../js/views/activity/activity_controller.js | 74 +++++++ .../src/js/views/activity/activity_model.js | 69 +++++++ .../js/views/activity/activity_renderer.js | 181 ++++++++++++++++++ .../src/js/views/activity/activity_view.js | 29 +++ .../mail/static/src/scss/activity_view.scss | 54 ++++++ .../mail/static/src/scss/mail_activity.scss | 5 +- addons/mail/static/src/xml/activity_view.xml | 18 ++ addons/mail/static/tests/chatter_tests.js | 31 +-- addons/mail/views/mail_templates.xml | 6 + addons/project/views/project_views.xml | 2 +- odoo/models.py | 9 + 15 files changed, 508 insertions(+), 43 deletions(-) create mode 100644 addons/mail/static/src/js/views/activity/activity_controller.js create mode 100644 addons/mail/static/src/js/views/activity/activity_model.js create mode 100644 addons/mail/static/src/js/views/activity/activity_renderer.js create mode 100644 addons/mail/static/src/js/views/activity/activity_view.js create mode 100644 addons/mail/static/src/scss/activity_view.scss create mode 100644 addons/mail/static/src/xml/activity_view.xml diff --git a/addons/contacts/views/contact_views.xml b/addons/contacts/views/contact_views.xml index ba0fa9b74e0..82da7aaca6b 100644 --- a/addons/contacts/views/contact_views.xml +++ b/addons/contacts/views/contact_views.xml @@ -5,7 +5,7 @@ ir.actions.act_window res.partner form - kanban,tree,form + kanban,tree,form,activity

diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index dc06344f35c..5f202f95982 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -45,6 +45,7 @@ 'application': True, 'qweb': [ 'static/src/xml/activity.xml', + 'static/src/xml/activity_view.xml', 'static/src/xml/composer.xml', 'static/src/xml/chatter.xml', 'static/src/xml/discuss.xml', diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py index fda0b5f88de..a1eb827db98 100644 --- a/addons/mail/models/mail_activity.py +++ b/addons/mail/models/mail_activity.py @@ -147,6 +147,42 @@ class MailActivity(models.Model): help='Technical field for UX purpose') mail_template_ids = fields.Many2many(related='activity_type_id.mail_template_ids') + @api.model + def get_activity_data(self, res_model, domain): + res = self.env[res_model].search(domain) + activity_domain = [('res_id', 'in', res.ids), ('res_model', '=', res_model)] + grouped_activities = self.env['mail.activity'].read_group(activity_domain, ['res_id', 'activity_type_id', 'res_name:max(res_name)', 'ids:array_agg(id)', 'date_deadline:min(date_deadline)'], ['res_id', 'activity_type_id'], lazy=False) + activity_type_ids = self.env['mail.activity.type'] + res_list = set() + activity_data = defaultdict(dict) + for group in grouped_activities: + res_id = group['res_id'] + res_name = group['res_name'] + activity_type_id = group['activity_type_id'][0] + activity_type_ids |= self.env['mail.activity.type'].browse(activity_type_id) # we will get the name when reading mail_template_ids + res_list.add((res_id, res_name)) + state = self._compute_state_from_date(group['date_deadline'], self.user_id.sudo().tz) + activity_data[res_id][activity_type_id] = { + 'count': group['__count'], + 'domain': group['__domain'], + 'ids': group['ids'], + 'state': state, + 'o_closest_deadline': group['date_deadline'], + } + activity_type_infos = [] + for elem in activity_type_ids: + mail_template_info = [] + for mail_template_id in elem.mail_template_ids: + mail_template_info.append({"id": mail_template_id.id, "name": mail_template_id.name}) + activity_type_infos.append([elem.id, elem.name, mail_template_info]) + + return { + 'activity_types': activity_type_infos, + 'res_ids': list(res_list), + 'grouped_activities': activity_data, + 'model': res_model, + } + @api.multi @api.onchange('previous_activity_type_id') def _compute_has_recommended_activities(self): @@ -167,24 +203,27 @@ class MailActivity(models.Model): @api.depends('date_deadline') def _compute_state(self): - today_default = date.today() - for record in self.filtered(lambda activity: activity.date_deadline): - today = today_default tz = record.user_id.sudo().tz - if tz: - today_utc = pytz.UTC.localize(datetime.utcnow()) - today_tz = today_utc.astimezone(pytz.timezone(tz)) - today = date(year=today_tz.year, month=today_tz.month, day=today_tz.day) + date_deadline = record.date_deadline + record.state = self._compute_state_from_date(date_deadline, tz) - date_deadline = fields.Date.from_string(record.date_deadline) - diff = (date_deadline - today) - if diff.days == 0: - record.state = 'today' - elif diff.days < 0: - record.state = 'overdue' - else: - record.state = 'planned' + @api.model + def _compute_state_from_date(self, date_deadline, tz=False): + date_deadline = fields.Date.from_string(date_deadline) + today_default = date.today() + today = today_default + if tz: + today_utc = pytz.UTC.localize(datetime.utcnow()) + today_tz = today_utc.astimezone(pytz.timezone(tz)) + today = date(year=today_tz.year, month=today_tz.month, day=today_tz.day) + diff = (date_deadline - today) + if diff.days == 0: + return 'today' + elif diff.days < 0: + return 'overdue' + else: + return 'planned' @api.onchange('activity_type_id') def _onchange_activity_type_id(self): diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index dbff89c76db..0a25fcfe194 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -146,6 +146,7 @@ var AbstractActivityField = AbstractField.extend({ context: this.record.getContext(), }).then( function(rslt_action) { + console.log(rslt_action); self.do_action(rslt_action, { on_close: function () { self.trigger_up('reload'); diff --git a/addons/mail/static/src/js/views/activity/activity_controller.js b/addons/mail/static/src/js/views/activity/activity_controller.js new file mode 100644 index 00000000000..6af4c310c01 --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_controller.js @@ -0,0 +1,74 @@ +odoo.define('mail.ActivityController', function (require) { +"use strict"; + +var AbstractController = require('web.AbstractController'); + +var ActivityController = AbstractController.extend({ + custom_events: _.extend({}, AbstractController.prototype.custom_events, { + send_mail_template: '_onSendMailTemplate', + open_view_form: '_onOpenViewForm', + reload: '_onReload', + }), + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} event + * @param {string} event.name + * @param {Object} event.data + * @param {boolean} [event.data.activity] + * @param {boolean} [event.data.followers] + * @param {boolean} [event.data.thread] + */ + _onReload: function (event) { + event.stopPropagation(); + var self = this; + this.model.reload().then(self.reload()); + }, + + /** + * @private + * @param {OdooEvent} ev + */ + _onSendMailTemplate: function (ev) { + var templateID = ev.data.templateID; + var activityTypeID = ev.data.activityTypeID; + var state = this.model.get(); + var groupedActivities = state.data.grouped_activities; + var resIDS = []; + Object.keys(groupedActivities).forEach(function (resID) { + var activityByType = groupedActivities[resID]; + var activity = activityByType[activityTypeID]; + if (activity) { + resIDS.push(parseInt(resID)); + } + }); + this._rpc({ + model: this.model.modelName, + method: 'activity_send_mail', + args: [resIDS, templateID], + }); + }, + /** + * @private + * @override + * @param {MouseEvent} ev + */ + _onOpenViewForm: function (ev) { + var resID = ev.data.resID; + this.do_action({ + type: 'ir.actions.act_window', + res_model: this.model.modelName, + res_id: resID, + views: [[false, 'form']], + target: 'current' + }); + }, +}); + +return ActivityController; + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_model.js b/addons/mail/static/src/js/views/activity/activity_model.js new file mode 100644 index 00000000000..146609bbb52 --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_model.js @@ -0,0 +1,69 @@ +odoo.define('mail.ActivityModel', function (require) { +'use strict'; + +var AbstractModel = require('web.AbstractModel'); + +var ActivityModel = AbstractModel.extend({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + get: function () { + return this.data; + }, + /** + * @override + * @param {Object} params + * @param {Array[]} params.domain + * @returns {Deferred} + */ + load: function (params) { + this.domain = params.domain; + this.modelName = params.modelName; + this.data = {}; + return this._fetchData(); + }, + /** + * @param {any} handle + * @param {Object} params + * @param {Array[]} params.domain + * @returns {Deferred} + */ + reload: function (handle, params) { + if (params && 'domain' in params) { + this.domain = params.domain; + } + return this._fetchData(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetch activity data. + * + * @private + * @returns {Deferred} + */ + _fetchData: function () { + var self = this; + return this._rpc({ + model: "mail.activity", + method: 'get_activity_data', + kwargs: { + res_model: this.modelName, + domain: this.domain, + } + }).then(function (result) { + self.data.data = result; + }); + }, +}); + +return ActivityModel; + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_renderer.js b/addons/mail/static/src/js/views/activity/activity_renderer.js new file mode 100644 index 00000000000..bba1cf86540 --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_renderer.js @@ -0,0 +1,181 @@ +odoo.define('mail.ActivityRenderer', function (require) { +"use strict"; + +var AbstractRenderer = require('web.AbstractRenderer'); +var core = require('web.core'); +var field_registry = require('web.field_registry'); + +var QWeb = core.qweb; +var _t = core._t; + +var ActivityRenderer = AbstractRenderer.extend({ + className: 'o_activity_view', + events: { + 'click .o_res_name_cell': '_onResNameClicked', + 'click .o_send_mail_template': '_onSenMailTemplateClicked', + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: function () { + this.$el + .removeClass('table-responsive') + .empty(); + + if (this.state.data.activity_types.length === 0) { + this.$el.append($("

").text(_t("No schedulded activity"))); + } else { + var $table = $('') + .addClass('table-bordered') + .append(this._renderHeader()) + .append(this._renderBody()); + this.$el + .addClass('table-responsive') + .append($table); + } + return this._super(); + }, + /** + * @private + * @returns {jQueryElement} a jquery element + */ + _renderBody: function () { + var $rows = _.map(this.state.data.res_ids, this._renderRow.bind(this)); + return $('').append($rows); + }, + /** + * @private + * @returns {jQueryElement} a jquery element + */ + _renderHeader: function () { + var $tr = $('') + .append($('').append($tr); + }, + /** + * @private + * @param {Object} activity_type + * @returns {jQueryElement} a element + */ + _renderRow: function (data) { + var self = this; + var res_id = data[0]; + var name = data[1]; + var $nameTD = $('', {class: 'o_data_row'}) + .append($nameTD) + .append($cells); + return $tr; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * @private + * @override + * @param {MouseEvent} ev + */ + _onSenMailTemplateClicked: function (ev) { + var $target = $(ev.currentTarget); + var templateID = $target.data('template-id'); + var activityTypeID = $target.closest('th').data('activity-type-id'); + this.trigger_up('send_mail_template', { + activityTypeID: activityTypeID, + templateID: templateID, + }); + }, + /** + * @private + * @override + * @param {MouseEvent} ev + */ + _onResNameClicked: function (ev) { + var resID = $(ev.currentTarget).data('res-id'); + this.trigger_up('open_view_form', {resID: resID}); + }, +}); + +return ActivityRenderer; + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_view.js b/addons/mail/static/src/js/views/activity/activity_view.js new file mode 100644 index 00000000000..fdd258caa54 --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_view.js @@ -0,0 +1,29 @@ +odoo.define('mail.ActivityView', function (require) { +"use strict"; + +var ActivityController = require('mail.ActivityController'); +var ActivityModel = require('mail.ActivityModel'); +var ActivityRenderer = require('mail.ActivityRenderer'); +var AbstractView = require('web.AbstractView'); +var core = require('web.core'); +var view_registry = require('web.view_registry'); + +var _lt = core._lt; + +var ActivityView = AbstractView.extend({ + accesskey: "a", + display_name: _lt('Activity'), + icon: 'fa-clock-o', + config: _.extend({}, AbstractView.prototype.config, { + Controller: ActivityController, + Model: ActivityModel, + Renderer: ActivityRenderer, + }), + viewType: 'activity', +}); + +view_registry.add('activity', ActivityView); + +return ActivityView; + +}); diff --git a/addons/mail/static/src/scss/activity_view.scss b/addons/mail/static/src/scss/activity_view.scss new file mode 100644 index 00000000000..d67c6019cdf --- /dev/null +++ b/addons/mail/static/src/scss/activity_view.scss @@ -0,0 +1,54 @@ +.o_activity_view { + height: 100%; + > table { + background-color: white; + } + .o_activity_summary_cell { + background-color: #FFF; + &.planned { + background-color: theme-color('success'); + } + &.overdue { + background-color: theme-color('danger'); + } + &.today { + background-color: theme-color('warning'); + } + .o_kanban_inline_block { + min-height: 42px; + } + .dropdown-toggle { + cursor: pointer; + .o_closest_deadline { + height: 42px; + width: 100%; + color: #FFF; + text-align: center; + line-height: 42px; + } + } + } + .o_res_name_cell { + padding: 0 10px; + color: #008784; + &:hover { + color: #666; + } + } + .o_activity_type_cell { + padding:10px; + min-width:100px; + .fa-ellipsis-v { + cursor: pointer; + } + + .o_template_element { + white-space: nowrap; + padding:5px; + cursor: pointer; + &:hover { + color: theme-color('success'); + } + } + } +} diff --git a/addons/mail/static/src/scss/mail_activity.scss b/addons/mail/static/src/scss/mail_activity.scss index 7fbe0e89c82..0582b5312f4 100644 --- a/addons/mail/static/src/scss/mail_activity.scss +++ b/addons/mail/static/src/scss/mail_activity.scss @@ -144,10 +144,13 @@ } /* Kanban View */ -.o_kanban_record { +.o_kanban_record{ .o_kanban_inline_block { display: inline-block; } +} + +.o_kanban_record, .o_view_controller{ .o_mail_activity { .o_activity_btn { span.fa { diff --git a/addons/mail/static/src/xml/activity_view.xml b/addons/mail/static/src/xml/activity_view.xml new file mode 100644 index 00000000000..eacb4cc7ca9 --- /dev/null +++ b/addons/mail/static/src/xml/activity_view.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js index 047a49b38c3..a46d02132c4 100644 --- a/addons/mail/static/tests/chatter_tests.js +++ b/addons/mail/static/tests/chatter_tests.js @@ -447,7 +447,7 @@ QUnit.test('kanban activity widget popover test', function (assert) { '' + '', mockRPC: function (route, args) { - if (route === '/web/dataset/call_kw/mail.activity/action_feedback') { + if (route === '/web/dataset/call_kw/mail.activity/action_done_schedule_next') { rpcCount++; var current_ids = this.data.partner.records[0].activity_ids; @@ -1195,7 +1195,7 @@ QUnit.test('form activity widget with another x2many field in view', function (a }); QUnit.test('form activity widget: schedule next activity', function (assert) { - assert.expect(5); + assert.expect(4); this.data.partner.records[0].activity_ids = [1]; this.data.partner.records[0].activity_state = 'today'; this.data['mail.activity'].records = [{ @@ -1208,7 +1208,6 @@ QUnit.test('form activity widget: schedule next activity', function (assert) { activity_type_id: 2, }]; - var checkReadArgs = false; var form = createView({ View: FormView, model: 'partner', @@ -1225,35 +1224,17 @@ QUnit.test('form activity widget: schedule next activity', function (assert) { '', res_id: 2, mockRPC: function (route, args) { - if (route === '/web/dataset/call_kw/mail.activity/action_feedback') { - assert.ok(_.isEqual(args.args[0], [1]), "should call 'action_feedback' for id 1"); + if (route === '/web/dataset/call_kw/mail.activity/action_done_schedule_next') { + assert.ok(_.isEqual(args.args[0], [1]), "should call 'action_done_schedule_next' for id 1"); assert.strictEqual(args.kwargs.feedback, 'everything is ok', "the feedback should be sent correctly"); - return $.when(); - } - if (args.method === 'read' && args.model === 'partner' && checkReadArgs) { - assert.deepEqual(args.args[1], ['activity_ids', 'message_ids', 'display_name'], - "should only read the mail fields"); + return $.when('test_result'); } return this._super.apply(this, arguments); }, intercepts: { do_action: function (event) { - assert.deepEqual(event.data.action, { - context: { - default_res_id: 2, - default_res_model: "partner", - default_previous_activity_type_id: 2, - }, - res_id: false, - res_model: 'mail.activity', - type: 'ir.actions.act_window', - target: "new", - view_mode: "form", - view_type: "form", - views: [[false, "form"]], - }, "should do a do_action with correct parameters"); - checkReadArgs = true; // should re-read the activities when closing the dialog + assert.strictEqual(event.data.action,'test_result' , "should do a do_action with correct parameters"); event.data.options.on_close(); }, }, diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml index fabda64c269..fd5867e0648 100644 --- a/addons/mail/views/mail_templates.xml +++ b/addons/mail/views/mail_templates.xml @@ -57,6 +57,11 @@ + + + + + @@ -70,6 +75,7 @@ + diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 73f5cd4767e..1fac04fdca6 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -63,7 +63,7 @@ Tasks project.task - kanban,tree,form,calendar,pivot,graph + kanban,tree,form,calendar,pivot,graph,activity { 'search_default_project_id': [active_id], 'default_project_id': active_id, diff --git a/odoo/models.py b/odoo/models.py index 73c82944f5a..4a610a25176 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -1252,6 +1252,15 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): element = E.field(name=self._rec_name_fallback()) return E.tree(element, string=self._description) + @api.model + def _get_default_activity_view(self): + """ Generates an empty activity view. + + :returns: a activity view as an lxml document + :rtype: etree._Element + """ + return E.activity(string=self._description) + @api.model def _get_default_pivot_view(self): """ Generates an empty pivot view.
')) //empty cell for name + .append(_.map(this.state.data.activity_types, this._renderHeaderCell.bind(this))); + return $('
element + */ + _renderHeaderCell: function (activity_type) { + return QWeb.render('mail.ActivityViewHeaderCell', { + id: activity_type[0], + name: activity_type[1], + template_list: activity_type[2] || [], + }); + }, + /** + * @private + * @param {Object} data + * @returns {jQueryElement} a
') + .addClass("o_res_name_cell") + .html(name) + .data('res-id', res_id); + var $cells = _.map(this.state.data.activity_types, function (node) { + var $td = $('').addClass("o_activity_summary_cell"); + var activity_type_id = node[0]; + var activity_group = self.state.data.grouped_activities[res_id][activity_type_id]; + activity_group = activity_group || {count: 0, ids: [], state: false}; + if (activity_group.state) { + $td.addClass(activity_group.state); + } + // we need to create a fake record in order to instanciate the KanbanActivity + // this is the minimal information in order to make it work + // AAB: move this to a function + var record = { + data: { + activity_ids: { + model: 'mail.activity', + res_ids: activity_group.ids, + }, + activity_state: activity_group.state, + }, + fields: { + activity_ids: {}, + activity_state: { + selection: [ + ['overdue', "Overdue"], + ['today', "Today"], + ['planned', "Planned"], + ], + }, + }, + fieldsInfo: {}, + model: self.state.data.model, + ref: res_id, // not necessary, i think + type: 'record', + res_id: res_id, + getContext: function () { + return {}; // session.user_context + }, + //todo intercept event or changes on record to update view + }; + var KanbanActivity = field_registry.get('kanban_activity'); + var widget = new KanbanActivity(self, "activity_ids", record, {}); + widget.appendTo($td); + // replace clock by closest deadline + var $date = $('
'); + var formated_date = moment(activity_group.o_closest_deadline).format('ll'); + var current_year = (new Date()).getFullYear(); + if (formated_date.endsWith(current_year)) { // Dummy logic to remove year (only if current year), we will maybe need to improve it + formated_date = formated_date.slice(0, -4); + formated_date = formated_date.replace(/( |,)*$/g, ""); + } + $date + .text(formated_date) + .addClass('o_closest_deadline'); + $td.find('a') + .empty() + .append($date); + return $td; + }); + var $tr = $('
+ + + + + +