[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
This commit is contained in:
XavierDo
2018-08-10 15:20:37 +02:00
parent 83fc5dc03c
commit 1de9496f14
15 changed files with 508 additions and 43 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<field name="type">ir.actions.act_window</field>
<field name="res_model">res.partner</field>
<field name="view_type">form</field>
<field name="view_mode">kanban,tree,form</field>
<field name="view_mode">kanban,tree,form,activity</field>
<field name="search_view_id" ref="base.view_res_partner_filter"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
+1
View File
@@ -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',
+54 -15
View File
@@ -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):
+1
View File
@@ -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');
@@ -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;
});
@@ -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;
});
@@ -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($("<div>").text(_t("No schedulded activity")));
} else {
var $table = $('<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 <tbody>
*/
_renderBody: function () {
var $rows = _.map(this.state.data.res_ids, this._renderRow.bind(this));
return $('<tbody>').append($rows);
},
/**
* @private
* @returns {jQueryElement} a jquery element <thead>
*/
_renderHeader: function () {
var $tr = $('<tr>')
.append($('<th>')) //empty cell for name
.append(_.map(this.state.data.activity_types, this._renderHeaderCell.bind(this)));
return $('<thead>').append($tr);
},
/**
* @private
* @param {Object} activity_type
* @returns {jQueryElement} a <th> 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 <tr> element
*/
_renderRow: function (data) {
var self = this;
var res_id = data[0];
var name = data[1];
var $nameTD = $('<td>')
.addClass("o_res_name_cell")
.html(name)
.data('res-id', res_id);
var $cells = _.map(this.state.data.activity_types, function (node) {
var $td = $('<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 = $('<div>');
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 = $('<tr/>', {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;
});
@@ -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;
});
@@ -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');
}
}
}
}
@@ -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 {
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.ActivityViewHeaderCell">
<th class="o_activity_type_cell" t-att-data-activity-type-id="id">
<span><t t-esc="name"/></span>
<span t-if="template_list.length > 0" class="dropdown pull-right">
<i class="fa fa-ellipsis-v fa-fw" data-toggle="dropdown"/>
<div class="dropdown-menu">
<div t-foreach="template_list" t-as="template" title="This action will send an email." class="o_template_element o_send_mail_template" t-att-data-template-id="template.id">
<i class="fa fa-envelope fa-fw"/> <t t-esc="template.name"/>
</div>
</div>
</span>
</th>
</t>
</templates>
+6 -25
View File
@@ -447,7 +447,7 @@ QUnit.test('kanban activity widget popover test', function (assert) {
'</t></templates>' +
'</kanban>',
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) {
'</form>',
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();
},
},
+6
View File
@@ -57,6 +57,11 @@
<script type="text/javascript" src="/mail/static/src/js/tour.js"></script>
<script type="text/javascript" src="/mail/static/src/js/utils.js"></script>
<script type="text/javascript" src="/mail/static/src/js/activity.js"></script>
<!-- Activity view type -->
<script type="text/javascript" src="/mail/static/src/js/views/activity/activity_view.js"></script>
<script type="text/javascript" src="/mail/static/src/js/views/activity/activity_model.js"></script>
<script type="text/javascript" src="/mail/static/src/js/views/activity/activity_controller.js"></script>
<script type="text/javascript" src="/mail/static/src/js/views/activity/activity_renderer.js"></script>
<!-- Others -->
<script type="text/javascript" src="/mail/static/src/js/emojis.js"></script>
@@ -70,6 +75,7 @@
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/thread.scss"/>
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/systray.scss"/>
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/mail_activity.scss"/>
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/activity_view.scss"/>
</xpath>
</template>
+1 -1
View File
@@ -63,7 +63,7 @@
<record id="act_project_project_2_project_task_all" model="ir.actions.act_window">
<field name="name">Tasks</field>
<field name="res_model">project.task</field>
<field name="view_mode">kanban,tree,form,calendar,pivot,graph</field>
<field name="view_mode">kanban,tree,form,calendar,pivot,graph,activity</field>
<field name="context">{
'search_default_project_id': [active_id],
'default_project_id': active_id,
+9
View File
@@ -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.