Purpose ======= Purpose of this commit is to add a new group for the mail template designer. Goal is to make roles clearer: managers edit templates, users use them. This commit allow some designers / managers to make email template and to let others users use those email templates. Specifications ============== When this feature is enabled in the Settings page, a new group is required to modify email templates in a composer like wizard or to make dynamic content. This allows to separate managers editing / composing templates from standard users that use them. If the current does not have this group, the email body will be in readonly mode if he selected an email template. That way we force him to use the email template that the manager made. Technical ========= New Group --------- Only users in this group will be able to create / write email template or to write Jinja code in the mail composer (including other fields like subject in mailing). By default, all internal users have this group. Mass mailing users also have this group as writing mailings is about the same management level as writing templates. Mail Composer Mixin ------------------- In comment mode, the template is rendered and then saved on the body field so non-"Mail Template Editor" users can load email templates. But in mass mode, the body of the template is saved and then rendered and many things change the body (HTML sanitizer, web editor move inline CSS properties, add / remove spaces...). So in this case, we can not know if the user changed the body or not. That is why we put the body field in readonly mode so, it is not modified by the web editor. Jinja code detection -------------------- To detect dynamic Jinja content, we compile the template, and we browse the AST. If we do not have a single "Template Data" node, we assume that the template is dynamic. When we detect the template as static, we do not render it. That way we avoid unnecessary rendering. Code cleaning ------------- Move Jinja import into tools so that it is outside of mail framework code. Task-2187263 closes odoo/odoo#75840 Related: odoo/enterprise#20547 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
336 lines
16 KiB
Python
336 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import logging
|
|
import pytz
|
|
|
|
from datetime import datetime, date
|
|
from dateutil.relativedelta import relativedelta
|
|
from markupsafe import Markup
|
|
from werkzeug.urls import url_join
|
|
|
|
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([('daily', 'Daily'),
|
|
('weekly', 'Weekly'),
|
|
('monthly', 'Monthly'),
|
|
('quarterly', 'Quarterly')],
|
|
string='Periodicity', default='daily', required=True)
|
|
next_run_date = fields.Date(string='Next Send Date')
|
|
currency_id = fields.Many2one(related="company_id.currency_id", string='Currency', readonly=False)
|
|
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company.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_', 'x_kpi_', 'x_studio_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.Datetime.to_string(self._context.get('start_datetime')), fields.Datetime.to_string(self._context.get('end_datetime')), self.env.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):
|
|
discussion_subtype_id = self.env.ref('mail.mt_comment').id
|
|
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), ('subtype_id', '=', discussion_subtype_id), ('message_type', 'in', ['comment', 'email'])])
|
|
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_create_multi
|
|
def create(self, vals_list):
|
|
digests = super().create(vals_list)
|
|
for digest in digests:
|
|
if not digest.next_run_date:
|
|
digest.next_run_date = digest._get_next_run_date()
|
|
return digests
|
|
|
|
# ------------------------------------------------------------
|
|
# ACTIONS
|
|
# ------------------------------------------------------------
|
|
|
|
def action_subscribe(self):
|
|
if self.env.user.has_group('base.group_user') and self.env.user not in self.user_ids:
|
|
self.sudo().user_ids |= self.env.user
|
|
|
|
def action_unsubcribe(self):
|
|
if self.env.user.has_group('base.group_user') and self.env.user in self.user_ids:
|
|
self.sudo().user_ids -= self.env.user
|
|
|
|
def action_activate(self):
|
|
self.state = 'activated'
|
|
|
|
def action_deactivate(self):
|
|
self.state = 'deactivated'
|
|
|
|
def action_set_periodicity(self, periodicity):
|
|
self.periodicity = periodicity
|
|
|
|
def action_send(self):
|
|
to_slowdown = self._check_daily_logs()
|
|
for digest in self:
|
|
for user in digest.user_ids:
|
|
digest.with_context(
|
|
digest_slowdown=digest in to_slowdown,
|
|
lang=user.lang
|
|
)._action_send_to_user(user, tips_count=1)
|
|
if digest in to_slowdown:
|
|
digest.write({'periodicity': 'weekly'})
|
|
digest.next_run_date = digest._get_next_run_date()
|
|
|
|
def _action_send_to_user(self, user, tips_count=1, consum_tips=True):
|
|
rendered_body = self.env['mail.render.mixin']._render_template(
|
|
'digest.digest_mail_main',
|
|
'digest.digest',
|
|
self.ids,
|
|
engine='qweb_view',
|
|
add_context={
|
|
'title': self.name,
|
|
'top_button_label': _('Connect'),
|
|
'top_button_url': self.get_base_url(),
|
|
'company': user.company_id,
|
|
'user': user,
|
|
'tips_count': tips_count,
|
|
'formatted_date': datetime.today().strftime('%B %d, %Y'),
|
|
'display_mobile_banner': True,
|
|
'kpi_data': self._compute_kpis(user.company_id, user),
|
|
'tips': self._compute_tips(user.company_id, user, tips_count=tips_count, consumed=consum_tips),
|
|
'preferences': self._compute_preferences(user.company_id, user),
|
|
},
|
|
post_process=True
|
|
)[self.id]
|
|
full_mail = self.env['mail.render.mixin']._render_encapsulate(
|
|
'digest.digest_mail_layout',
|
|
rendered_body,
|
|
add_context={
|
|
'company': user.company_id,
|
|
'user': user,
|
|
},
|
|
)
|
|
# create a mail_mail based on values, without attachments
|
|
mail_values = {
|
|
'subject': '%s: %s' % (user.company_id.name, self.name),
|
|
'email_from': self.company_id.partner_id.email_formatted if self.company_id else self.env.user.email_formatted,
|
|
'email_to': user.email_formatted,
|
|
'body_html': full_mail,
|
|
'auto_delete': True,
|
|
}
|
|
mail = self.env['mail.mail'].sudo().create(mail_values)
|
|
mail.send(raise_exception=False)
|
|
return True
|
|
|
|
@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.', digest.id)
|
|
|
|
# ------------------------------------------------------------
|
|
# KPIS
|
|
# ------------------------------------------------------------
|
|
|
|
def _compute_kpis(self, company, user):
|
|
""" Compute KPIs to display in the digest template. It is expected to be
|
|
a list of KPIs, each containing values for 3 columns display.
|
|
|
|
:return list: result [{
|
|
'kpi_name': 'kpi_mail_message',
|
|
'kpi_fullname': 'Messages', # translated
|
|
'kpi_action': 'crm.crm_lead_action_pipeline', # xml id of an action to execute
|
|
'kpi_col1': {
|
|
'value': '12.0',
|
|
'margin': 32.36,
|
|
'col_subtitle': 'Yesterday', # translated
|
|
},
|
|
'kpi_col2': { ... },
|
|
'kpi_col3': { ... },
|
|
}, { ... }] """
|
|
self.ensure_one()
|
|
digest_fields = self._get_kpi_fields()
|
|
invalid_fields = []
|
|
kpis = [
|
|
dict(kpi_name=field_name,
|
|
kpi_fullname=self.env['ir.model.fields']._get(self._name, field_name).field_description,
|
|
kpi_action=False,
|
|
kpi_col1=dict(),
|
|
kpi_col2=dict(),
|
|
kpi_col3=dict(),
|
|
)
|
|
for field_name in digest_fields
|
|
]
|
|
kpis_actions = self._compute_kpis_actions(company, user)
|
|
|
|
for col_index, (tf_name, tf) in enumerate(self._compute_timeframes(company)):
|
|
digest = self.with_context(start_datetime=tf[0][0], end_datetime=tf[0][1]).with_user(user).with_company(company)
|
|
previous_digest = self.with_context(start_datetime=tf[1][0], end_datetime=tf[1][1]).with_user(user).with_company(company)
|
|
for index, field_name in enumerate(digest_fields):
|
|
kpi_values = kpis[index]
|
|
kpi_values['kpi_action'] = kpis_actions.get(field_name)
|
|
try:
|
|
compute_value = digest[field_name + '_value']
|
|
# Context start and end date is different each time so invalidate to recompute.
|
|
digest.invalidate_cache([field_name + '_value'])
|
|
previous_value = previous_digest[field_name + '_value']
|
|
# Context start and end date is different each time so invalidate to recompute.
|
|
previous_digest.invalidate_cache([field_name + '_value'])
|
|
except AccessError: # no access rights -> just skip that digest details from that user's digest email
|
|
invalid_fields.append(field_name)
|
|
continue
|
|
margin = self._get_margin_value(compute_value, previous_value)
|
|
if self._fields['%s_value' % field_name].type == 'monetary':
|
|
converted_amount = tools.format_decimalized_amount(compute_value)
|
|
compute_value = self._format_currency_amount(converted_amount, company.currency_id)
|
|
kpi_values['kpi_col%s' % (col_index + 1)].update({
|
|
'value': compute_value,
|
|
'margin': margin,
|
|
'col_subtitle': tf_name,
|
|
})
|
|
|
|
# filter failed KPIs
|
|
return [kpi for kpi in kpis if kpi['kpi_name'] not in invalid_fields]
|
|
|
|
def _compute_tips(self, company, user, tips_count=1, consumed=True):
|
|
tips = self.env['digest.tip'].search([
|
|
('user_ids', '!=', user.id),
|
|
'|', ('group_id', 'in', user.groups_id.ids), ('group_id', '=', False)
|
|
], limit=tips_count)
|
|
tip_descriptions = [
|
|
self.env['mail.render.mixin'].sudo()._render_template(tools.html_sanitize(tip.tip_description), 'digest.tip', tip.ids, post_process=True)[tip.id]
|
|
for tip in tips
|
|
]
|
|
if consumed:
|
|
tips.user_ids += user
|
|
return tip_descriptions
|
|
|
|
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 _compute_preferences(self, company, user):
|
|
""" Give an optional text for preferences, like a shortcut for configuration.
|
|
|
|
:return string: html to put in template
|
|
"""
|
|
preferences = []
|
|
if self._context.get('digest_slowdown'):
|
|
preferences.append(_("We have noticed you did not connect these last few days so we've automatically switched your preference to weekly Digests."))
|
|
elif self.periodicity == 'daily' and user.has_group('base.group_erp_manager'):
|
|
preferences.append(Markup('<p>%s<br /><a href="%s" target="_blank" style="color:#875A7B; font-weight: bold;">%s</a></p>') % (
|
|
_('Prefer a broader overview ?'),
|
|
f'/digest/{self.id:d}/set_periodicity?periodicity=weekly',
|
|
_('Switch to weekly Digests')
|
|
))
|
|
if user.has_group('base.group_erp_manager'):
|
|
preferences.append(Markup('<p>%s<br /><a href="%s" target="_blank" style="color:#875A7B; font-weight: bold;">%s</a></p>') % (
|
|
_('Want to customize this email?'),
|
|
f'/web#view_type=form&model={self._name}&id={self.id:d}',
|
|
_('Choose the metrics you care about')
|
|
))
|
|
|
|
return preferences
|
|
|
|
def _get_next_run_date(self):
|
|
self.ensure_one()
|
|
if self.periodicity == 'daily':
|
|
delta = relativedelta(days=1)
|
|
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):
|
|
start_datetime = datetime.utcnow()
|
|
tz_name = company.resource_calendar_id.tz
|
|
if tz_name:
|
|
start_datetime = pytz.timezone(tz_name).localize(start_datetime)
|
|
return [
|
|
(_('Last 24 hours'), (
|
|
(start_datetime + relativedelta(days=-1), start_datetime),
|
|
(start_datetime + relativedelta(days=-2), start_datetime + relativedelta(days=-1)))
|
|
), (_('Last 7 Days'), (
|
|
(start_datetime + relativedelta(weeks=-1), start_datetime),
|
|
(start_datetime + relativedelta(weeks=-2), start_datetime + relativedelta(weeks=-1)))
|
|
), (_('Last 30 Days'), (
|
|
(start_datetime + relativedelta(months=-1), start_datetime),
|
|
(start_datetime + relativedelta(months=-2), start_datetime + relativedelta(months=-1)))
|
|
)
|
|
]
|
|
|
|
# ------------------------------------------------------------
|
|
# FORMATTING / TOOLS
|
|
# ------------------------------------------------------------
|
|
|
|
def _get_kpi_fields(self):
|
|
return [field_name for field_name, field in self._fields.items()
|
|
if field.type == 'boolean' and field_name.startswith(('kpi_', 'x_kpi_', 'x_studio_kpi_')) and self[field_name]
|
|
]
|
|
|
|
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 _check_daily_logs(self):
|
|
three_days_ago = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - relativedelta(days=3)
|
|
to_slowdown = self.env['digest.digest']
|
|
for digest in self.filtered(lambda digest: digest.periodicity == 'daily'):
|
|
users_logs = self.env['res.users.log'].sudo().search_count([
|
|
('create_uid', 'in', digest.user_ids.ids),
|
|
('create_date', '>=', three_days_ago)
|
|
])
|
|
if not users_logs:
|
|
to_slowdown += digest
|
|
return to_slowdown
|
|
|
|
def _format_currency_amount(self, amount, currency_id):
|
|
pre = currency_id.position == 'before'
|
|
symbol = u'{symbol}'.format(symbol=currency_id.symbol or '')
|
|
return u'{pre}{0}{post}'.format(amount, pre=symbol if pre else '', post=symbol if not pre else '')
|