diff --git a/addons/digest/__init__.py b/addons/digest/__init__.py new file mode 100644 index 00000000000..7d34c7c054a --- /dev/null +++ b/addons/digest/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models diff --git a/addons/digest/__manifest__.py b/addons/digest/__manifest__.py new file mode 100644 index 00000000000..4cafa7a2cbc --- /dev/null +++ b/addons/digest/__manifest__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'KPI Digests', + 'category': 'Marketing', + 'description': """ +Send KPI Digests periodically +============================= +""", + 'version': '1.0', + 'depends': [ + 'mail', + 'portal' + ], + 'data': [ + 'security/ir.model.access.csv', + 'data/digest_template_data.xml', + 'data/digest_data.xml', + 'data/ir_cron_data.xml', + 'data/res_config_settings_data.xml', + 'views/digest_views.xml', + 'views/digest_templates.xml', + 'views/res_config_settings_views.xml', + ], + 'installable': True, +} diff --git a/addons/digest/controllers/__init__.py b/addons/digest/controllers/__init__.py new file mode 100644 index 00000000000..903b755e71e --- /dev/null +++ b/addons/digest/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import portal diff --git a/addons/digest/controllers/portal.py b/addons/digest/controllers/portal.py new file mode 100644 index 00000000000..0e7f8a5b4e5 --- /dev/null +++ b/addons/digest/controllers/portal.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.http import Controller, request, route + + +class DigestController(Controller): + + @route('/digest//unsubscribe', type='http', website=True, auth='user') + def digest_unsubscribe(self, digest_id, **post): + digest = request.env['digest.digest'].sudo().browse(digest_id) + digest.action_unsubcribe() + return request.render('digest.portal_digest_unsubscribed', { + 'digest': digest, + }) diff --git a/addons/digest/data/digest_data.xml b/addons/digest/data/digest_data.xml new file mode 100644 index 00000000000..42607cd3401 --- /dev/null +++ b/addons/digest/data/digest_data.xml @@ -0,0 +1,45 @@ + + + + + Weekly Digest + + + True + True + + + + + + 1 + +
+ % set users = object.env['res.users'].search([], limit=10, order='id desc') + % set channel_id = object.env.ref('mail.channel_all_employees').id + Did you know...? +
You can ping colleagues by tagging them in your messages using "@". They will be instantly notified.
+
+
+
+
+ ${', '.join(users.mapped('name'))} signed up. Say hello in the company's discussion channel. +
+
+
+
+
+ + 7 + +
+ Get things done with activities +
You don't have any activity scheduled. Use activities on any business document to schedule meetings, calls and todos.
+
+
+
+
+
+
+
+
diff --git a/addons/digest/data/digest_template_data.xml b/addons/digest/data/digest_template_data.xml new file mode 100644 index 00000000000..c168dd065b7 --- /dev/null +++ b/addons/digest/data/digest_template_data.xml @@ -0,0 +1,160 @@ + + + + Digest: Default main template + + + ${user.email} + ${user.lang} + + + + % set company, user = ctx['company'], ctx['user'] + % set data = object.compute_kpis(company, user) + % set tips = object.compute_tips(company, user) + % set kpi_actions = object.compute_kpis_actions(company, user) + % set kpis = data.yesterday.keys() + + + + + + +
+ ${company.name} at a glance +
${datetime.date.today().strftime('%B %d, %Y')}
+
+ +
+
+
+ % for kpi in kpis: + + + + + + + +

+ ${object.fields_get()[kpi]['string']} + %if kpi in kpi_actions: + + View more + + %endif +
+ + + + + + +
+ + + + +
+ ${data['yesterday'][kpi][kpi]}
+ Yesterday + % if data['yesterday'][kpi]['margin'] != 0.0: + + % if data['yesterday'][kpi]['margin'] > 0.0: + ▲${"%.2f" % data['yesterday'][kpi]['margin']} % + % endif + % if data['yesterday'][kpi]['margin'] < 0.0: + ▼${"%.2f" % data['yesterday'][kpi]['margin']} % + % endif + + % endif +
+
+ + + + +
+ ${data['lastweek'][kpi][kpi]}
+ Last 7 Days + % if data['lastweek'][kpi]['margin'] != 0.0: + + % if data['lastweek'][kpi]['margin'] > 0.0: + ▲${"%.2f" % data['lastweek'][kpi]['margin']} % + % endif + % if data['lastweek'][kpi]['margin'] < 0.0: + ▼${"%.2f" % data['lastweek'][kpi]['margin']} % + %endif + + %endif +
+
+ + + + +
+ ${data['lastmonth'][kpi][kpi]}
+ Last 30 Days + % if data['lastmonth'][kpi]['margin'] != 0.0: + + % if data['lastmonth'][kpi]['margin'] > 0.0: + ▲${"%.2f" % data['lastmonth'][kpi]['margin']} % + % endif + % if data['lastmonth'][kpi]['margin'] < 0.0: + ▼${"%.2f" % data['lastmonth'][kpi]['margin']} % + %endif + + %endif +
+
+
+ % endfor + % if tips: + + + + +

+
${ctx['tip_description']|safe}
+
+ % endif + + + + + + + +

+
Run your bussiness from anywhere with Odoo Mobile.
+
+
+
+
+ + + + +
+ % if ctx['user'].has_group('base.group_system'): +
+ Want to customize the email? + Choose the metrics you care about +
+
+ % endif +

+ + Send by + Odoo - Unsubscribe + +

+
+ + + + ]]>
+
+
diff --git a/addons/digest/data/ir_cron_data.xml b/addons/digest/data/ir_cron_data.xml new file mode 100644 index 00000000000..2945ad397cd --- /dev/null +++ b/addons/digest/data/ir_cron_data.xml @@ -0,0 +1,13 @@ + + + + Digest Emails + + code + model._cron_send_digest_email() + + 1 + days + -1 + + diff --git a/addons/digest/data/res_config_settings_data.xml b/addons/digest/data/res_config_settings_data.xml new file mode 100644 index 00000000000..cbd93e4fef0 --- /dev/null +++ b/addons/digest/data/res_config_settings_data.xml @@ -0,0 +1,11 @@ + + + + digest.default_digest_emails + True + + + digest.default_digest_id + + + diff --git a/addons/digest/models/__init__.py b/addons/digest/models/__init__.py new file mode 100644 index 00000000000..1882e860af3 --- /dev/null +++ b/addons/digest/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import digest +from . import digest_tip +from . import res_config_settings +from . import res_users diff --git a/addons/digest/models/digest.py b/addons/digest/models/digest.py new file mode 100644 index 00000000000..c7be29b15d9 --- /dev/null +++ b/addons/digest/models/digest.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import math +import pytz + +from datetime import datetime, date +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, tools +from odoo.addons.base.models.ir_mail_server import MailDeliveryException +from odoo.exceptions import AccessError +from odoo.tools.float_utils import float_round + +_logger = logging.getLogger(__name__) + + +class Digest(models.Model): + _name = 'digest.digest' + _description = 'Digest' + + # Digest description + name = fields.Char(string='Name', required=True, translate=True) + user_ids = fields.Many2many('res.users', string='Recipients', domain="[('share', '=', False)]") + periodicity = fields.Selection([('weekly', 'Weekly'), + ('monthly', 'Monthly'), + ('quarterly', 'Quarterly')], + string='Periodicity', default='weekly', required=True) + next_run_date = fields.Date(string='Next Send Date') + template_id = fields.Many2one('mail.template', string='Email Template', + domain="[('model','=','digest.digest')]", + default=lambda self: self.env.ref('digest.digest_mail_template'), + required=True) + currency_id = fields.Many2one(related="company_id.currency_id", string='Currency') + company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.user.company_id.id) + available_fields = fields.Char(compute='_compute_available_fields') + is_subscribed = fields.Boolean('Is user subscribed', compute='_compute_is_subscribed') + state = fields.Selection([('activated', 'Activated'), ('deactivated', 'Deactivated')], string='Status', readonly=True, default='activated') + # First base-related KPIs + kpi_res_users_connected = fields.Boolean('Connected Users') + kpi_res_users_connected_value = fields.Integer(compute='_compute_kpi_res_users_connected_value') + kpi_mail_message_total = fields.Boolean('Messages') + kpi_mail_message_total_value = fields.Integer(compute='_compute_kpi_mail_message_total_value') + + def _compute_is_subscribed(self): + for digest in self: + digest.is_subscribed = self.env.user in digest.user_ids + + def _compute_available_fields(self): + for digest in self: + kpis_values_fields = [] + for field_name, field in digest._fields.items(): + if field.type == 'boolean' and (field_name.startswith('kpi_') or field_name.startswith('x_kpi_')) and digest[field_name]: + kpis_values_fields += [field_name + '_value'] + digest.available_fields = ', '.join(kpis_values_fields) + + def _get_kpi_compute_parameters(self): + return fields.Date.to_string(self._context.get('start_date')), fields.Date.to_string(self._context.get('end_date')), self._context.get('company') + + def _compute_kpi_res_users_connected_value(self): + for record in self: + start, end, company = record._get_kpi_compute_parameters() + user_connected = self.env['res.users'].search_count([('company_id', '=', company.id), ('login_date', '>=', start), ('login_date', '<', end)]) + record.kpi_res_users_connected_value = user_connected + + def _compute_kpi_mail_message_total_value(self): + for record in self: + start, end, company = record._get_kpi_compute_parameters() + total_messages = self.env['mail.message'].search_count([('create_date', '>=',start), ('create_date', '<', end)]) + record.kpi_mail_message_total_value = total_messages + + @api.onchange('periodicity') + def _onchange_periodicity(self): + self.next_run_date = self._get_next_run_date() + + @api.model + def create(self, vals): + vals['next_run_date'] = date.today() + relativedelta(days=3) + return super(Digest, self).create(vals) + + @api.multi + def action_subscribe(self): + if self.env.user not in self.user_ids: + self.sudo().user_ids |= self.env.user + + @api.multi + def action_unsubcribe(self): + if self.env.user in self.user_ids: + self.sudo().user_ids -= self.env.user + + @api.multi + def action_activate(self): + self.state = 'activated' + + @api.multi + def action_deactivate(self): + self.state = 'deactivated' + + def action_send(self): + for digest in self: + for user in digest.user_ids: + subject = '%s: %s' % (user.company_id.name, digest.name) + digest.template_id.with_context(user=user, company=user.company_id).send_mail(digest.id, force_send=True, raise_exception=True, email_values={'email_to': user.email, 'subject': subject}) + digest.next_run_date = digest._get_next_run_date() + + def compute_kpis(self, company, user): + self.ensure_one() + res = {} + for tf_name, tf in self._compute_timeframes(company).items(): + digest = self.with_context(start_date=tf[0][0], end_date=tf[0][1], company=company).sudo(user.id) + previous_digest = self.with_context(start_date=tf[1][0], end_date=tf[1][1], company=company).sudo(user.id) + kpis = {} + for field_name, field in self._fields.items(): + if field.type == 'boolean' and (field_name.startswith('kpi_') or field_name.startswith('x_kpi_')) and self[field_name]: + + try: + compute_value = digest[field_name + '_value'] + previous_value = previous_digest[field_name + '_value'] + except AccessError: # no access rights -> just skip that digest details from that user's digest email + continue + margin = self._get_margin_value(compute_value, previous_value) + if self._fields[field_name+'_value'].type == 'monetary': + converted_amount = self._format_human_readable_amount(compute_value) + kpis.update({field_name: {field_name: self._format_currency_amount(converted_amount, company.currency_id), 'margin': margin}}) + else: + kpis.update({field_name: {field_name: compute_value, 'margin': margin}}) + + res.update({tf_name: kpis}) + return res + + def compute_tips(self, company, user): + tip = self.env['digest.tip'].search([('user_ids', '!=', user.id), '|', ('group_id', 'in', user.groups_id.ids), ('group_id', '=', False)], limit=1) + if not tip: + return False + tip.user_ids = [4, user.id] + body = tools.html_sanitize(tip.tip_description) + tip_description = self.env['mail.template'].render_template(body, 'digest.tip', self.id) + return tip_description + + def compute_kpis_actions(self, company, user): + """ Give an optional action to display in digest email linked to some KPIs. + + :return dict: key: kpi name (field name), value: an action that will be + concatenated with /web#action={action} + """ + return {} + + def _get_next_run_date(self): + self.ensure_one() + if self.periodicity == 'weekly': + delta = relativedelta(weeks=1) + elif self.periodicity == 'monthly': + delta = relativedelta(months=1) + elif self.periodicity == 'quarterly': + delta = relativedelta(months=3) + return date.today() + delta + + def _compute_timeframes(self, company): + now = datetime.utcnow() + tz_name = company.resource_calendar_id.tz + if tz_name: + now = pytz.timezone(tz_name).localize(now) + start_date = now.date() + return { + 'yesterday': ( + (start_date + relativedelta(days=-1), start_date), + (start_date + relativedelta(days=-2), start_date + relativedelta(days=-1))), + 'lastweek': ( + (start_date + relativedelta(weeks=-1), start_date), + (start_date + relativedelta(weeks=-2), start_date + relativedelta(weeks=-1))), + 'lastmonth': ( + (start_date + relativedelta(months=-1), start_date), + (start_date + relativedelta(months=-2), start_date + relativedelta(months=-1))), + } + + def _get_margin_value(self, value, previous_value=0.0): + margin = 0.0 + if (value != previous_value) and (value != 0.0 and previous_value != 0.0): + margin = float_round((float(value-previous_value) / previous_value or 1) * 100, precision_digits=2) + return margin + + def _format_currency_amount(self, amount, currency_id): + pre = post = u'' + if currency_id.position == 'before': + pre = u'{symbol}\N{NO-BREAK SPACE}'.format(symbol=currency_id.symbol or '') + else: + post = u'\N{NO-BREAK SPACE}{symbol}'.format(symbol=currency_id.symbol or '') + return u'{pre}{0}{post}'.format(amount, pre=pre, post=post) + + def _format_human_readable_amount(self, amount, suffix=''): + for unit in ['', 'K', 'M', 'G']: + if abs(amount) < 1000.0: + return "%3.1f%s%s" % (amount, unit, suffix) + amount /= 1000.0 + return "%.1f%s%s" % (amount, 'T', suffix) + + @api.model + def _cron_send_digest_email(self): + digests = self.search([('next_run_date', '=', fields.Date.today()), ('state', '=', 'activated')]) + for digest in digests: + try: + digest.action_send() + except MailDeliveryException as e: + _logger.warning('MailDeliveryException while sending digest %d. Digest is now scheduled for next cron update.') diff --git a/addons/digest/models/digest_tip.py b/addons/digest/models/digest_tip.py new file mode 100644 index 00000000000..c2547726e0f --- /dev/null +++ b/addons/digest/models/digest_tip.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models +from odoo.tools.translate import html_translate + + +class DigestTip(models.Model): + _name = 'digest.tip' + _description = 'Digest Tips' + _order = 'sequence' + + sequence = fields.Integer( + 'Sequence', default=1, + help='Used to display digest tip in email template base on order') + user_ids = fields.Many2many( + 'res.users', string='Recipients', + help='Users having already received this tip') + tip_description = fields.Html('Tip description', translate=html_translate) + group_id = fields.Many2one( + 'res.groups', string='Authorized Group', + default=lambda self: self.env.ref('base.group_user')) diff --git a/addons/digest/models/res_config_settings.py b/addons/digest/models/res_config_settings.py new file mode 100644 index 00000000000..59f13bdff4e --- /dev/null +++ b/addons/digest/models/res_config_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + digest_emails = fields.Boolean(string="Digest Emails", config_parameter='digest.default_digest_emails') + digest_id = fields.Many2one('digest.digest', string='Digest Email', config_parameter='digest.default_digest_id') diff --git a/addons/digest/models/res_users.py b/addons/digest/models/res_users.py new file mode 100644 index 00000000000..767ec444391 --- /dev/null +++ b/addons/digest/models/res_users.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import api, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + @api.model + def create(self, vals): + """ Automatically subscribe employee users to default digest if activated """ + user = super(ResUsers, self).create(vals) + default_digest_emails = self.env['ir.config_parameter'].sudo().get_param('digest.default_digest_emails') + default_digest_id = self.env['ir.config_parameter'].sudo().get_param('digest.default_digest_id') + if user.has_group('base.group_user') and default_digest_emails and default_digest_id: + digest = self.env['digest.digest'].sudo().browse(int(default_digest_id)) + digest.user_ids |= user + return user diff --git a/addons/digest/security/ir.model.access.csv b/addons/digest/security/ir.model.access.csv new file mode 100644 index 00000000000..2891b43b673 --- /dev/null +++ b/addons/digest/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_digest_digest_system,digest.digest.administration,model_digest_digest,base.group_erp_manager,1,1,1,1 +access_digest_digest_user,digest.digest.user,model_digest_digest,base.group_user,1,0,0,0 +access_digest_tip_system,digest.tip.administration,model_digest_tip,base.group_erp_manager,1,1,1,1 +access_digest_tip_user,digest.tip.user,model_digest_tip,base.group_user,0,0,0,0 diff --git a/addons/digest/static/src/img/activity.png b/addons/digest/static/src/img/activity.png new file mode 100644 index 00000000000..b1b85043de3 Binary files /dev/null and b/addons/digest/static/src/img/activity.png differ diff --git a/addons/digest/static/src/img/app_store.png b/addons/digest/static/src/img/app_store.png new file mode 100644 index 00000000000..65320714c06 Binary files /dev/null and b/addons/digest/static/src/img/app_store.png differ diff --git a/addons/digest/static/src/img/google_play.png b/addons/digest/static/src/img/google_play.png new file mode 100644 index 00000000000..229d939d728 Binary files /dev/null and b/addons/digest/static/src/img/google_play.png differ diff --git a/addons/digest/static/src/img/notification.png b/addons/digest/static/src/img/notification.png new file mode 100644 index 00000000000..3ba6e7c24a2 Binary files /dev/null and b/addons/digest/static/src/img/notification.png differ diff --git a/addons/digest/views/digest_templates.xml b/addons/digest/views/digest_templates.xml new file mode 100644 index 00000000000..7e4b0e36a2f --- /dev/null +++ b/addons/digest/views/digest_templates.xml @@ -0,0 +1,16 @@ + + + \ No newline at end of file diff --git a/addons/digest/views/digest_views.xml b/addons/digest/views/digest_views.xml new file mode 100644 index 00000000000..6020af0aca7 --- /dev/null +++ b/addons/digest/views/digest_views.xml @@ -0,0 +1,129 @@ + + + + digest.digest.view.tree + digest.digest + + + + + + + + + + digest.digest.view.form + digest.digest + +
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + digest.digest.view.search + digest.digest + + + + + + + + + + + + Digest Emails + digest.digest + form + + + + +
diff --git a/addons/digest/views/res_config_settings_views.xml b/addons/digest/views/res_config_settings_views.xml new file mode 100644 index 00000000000..ed0372110b8 --- /dev/null +++ b/addons/digest/views/res_config_settings_views.xml @@ -0,0 +1,32 @@ + + + + res.config.settings.view.form.inherit.digest + res.config.settings + + + +
+
+ +
+
+
+
+
+
+
+