mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-22 21:51:59 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
7
odoo-bringout-oca-ocb-digest/digest/models/__init__.py
Normal file
7
odoo-bringout-oca-ocb-digest/digest/models/__init__.py
Normal file
|
|
@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
415
odoo-bringout-oca-ocb-digest/digest/models/digest.py
Normal file
415
odoo-bringout-oca-ocb-digest/digest/models/digest.py
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
# -*- 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_encode, 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 Mailing 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 Sent')
|
||||
kpi_mail_message_total_value = fields.Integer(compute='_compute_kpi_mail_message_total_value')
|
||||
|
||||
@api.depends('user_ids')
|
||||
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._is_internal() and self.env.user not in self.user_ids:
|
||||
self._action_subscribe_users(self.env.user)
|
||||
|
||||
def _action_subscribe_users(self, users):
|
||||
""" Private method to manage subscriptions. Done as sudo() to speedup
|
||||
computation and avoid ACLs issues. """
|
||||
self.sudo().user_ids |= users
|
||||
|
||||
def action_unsubscribe(self):
|
||||
if self.env.user._is_internal() and self.env.user in self.user_ids:
|
||||
self._action_unsubscribe_users(self.env.user)
|
||||
|
||||
def _action_unsubscribe_users(self, users):
|
||||
""" Private method to manage subscriptions. Done as sudo() to speedup
|
||||
computation and avoid ACLs issues. """
|
||||
self.sudo().user_ids -= users
|
||||
|
||||
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):
|
||||
""" Send digests emails to all the registered users. """
|
||||
return self._action_send(update_periodicity=True)
|
||||
|
||||
def action_send_manual(self):
|
||||
""" Manually send digests emails to all registered users. In that case
|
||||
do not update periodicity as this is not an automated action that could
|
||||
be considered as unwanted spam. """
|
||||
return self._action_send(update_periodicity=False)
|
||||
|
||||
def _action_send(self, update_periodicity=True):
|
||||
""" Send digests email to all the registered users.
|
||||
|
||||
:param bool update_periodicity: if True, check user logs to update
|
||||
periodicity of digests. Purpose is to slow down digest whose users
|
||||
do not connect to avoid spam;
|
||||
"""
|
||||
to_slowdown = self._check_daily_logs() if update_periodicity else self.env['digest.digest']
|
||||
|
||||
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.periodicity = digest._get_next_periodicity()[0]
|
||||
digest.next_run_date = digest._get_next_run_date()
|
||||
|
||||
def _action_send_to_user(self, user, tips_count=1, consume_tips=True):
|
||||
unsubscribe_token = self._get_unsubscribe_token(user.id)
|
||||
|
||||
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,
|
||||
'unsubscribe_token': unsubscribe_token,
|
||||
'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=consume_tips),
|
||||
'preferences': self._compute_preferences(user.company_id, user),
|
||||
},
|
||||
post_process=True,
|
||||
options={'preserve_comments': 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
|
||||
unsub_params = url_encode({
|
||||
"token": unsubscribe_token,
|
||||
"user_id": user.id,
|
||||
})
|
||||
unsub_url = url_join(
|
||||
self.get_base_url(),
|
||||
f'/digest/{self.id}/unsubscribe_oneclik?{unsub_params}'
|
||||
)
|
||||
mail_values = {
|
||||
'auto_delete': True,
|
||||
'author_id': self.env.user.partner_id.id,
|
||||
'body_html': full_mail,
|
||||
'email_from': (
|
||||
self.company_id.partner_id.email_formatted
|
||||
or self.env.user.email_formatted
|
||||
or self.env.ref('base.user_root').email_formatted
|
||||
),
|
||||
'email_to': user.email_formatted,
|
||||
# Add headers that allow the MUA to offer a one click button to unsubscribe (requires DKIM to work)
|
||||
'headers': {
|
||||
'List-Unsubscribe': f'<{unsub_url}>',
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
'X-Auto-Response-Suppress': 'OOF', # avoid out-of-office replies from MS Exchange
|
||||
},
|
||||
'state': 'outgoing',
|
||||
'subject': '%s: %s' % (user.company_id.name, self.name),
|
||||
}
|
||||
self.env['mail.mail'].sudo().create(mail_values)
|
||||
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)
|
||||
|
||||
def _get_unsubscribe_token(self, user_id):
|
||||
"""Generate a secure hash for this digest and user. It allows to
|
||||
unsubscribe from a digest while keeping some security in that process.
|
||||
|
||||
:param int user_id: ID of the user to unsubscribe
|
||||
"""
|
||||
return tools.hmac(self.env(su=True), 'digest-unsubscribe', (self.id, user_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_model([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_model([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 = [
|
||||
tools.html_sanitize(self.env['mail.render.mixin'].sudo()._render_template(tip.tip_description, 'digest.tip', tip.ids, post_process=True, engine="qweb")[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'):
|
||||
_dummy, new_perioridicy_str = self._get_next_periodicity()
|
||||
preferences.append(
|
||||
_("We have noticed you did not connect these last few days. We have automatically switched your preference to %(new_perioridicy_str)s Digests.",
|
||||
new_perioridicy_str=new_perioridicy_str)
|
||||
)
|
||||
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)
|
||||
elif self.periodicity == 'weekly':
|
||||
delta = relativedelta(weeks=1)
|
||||
elif self.periodicity == 'monthly':
|
||||
delta = relativedelta(months=1)
|
||||
else:
|
||||
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):
|
||||
""" Badly named method that checks user logs and slowdown the sending
|
||||
of digest emails based on recipients being away. """
|
||||
today = datetime.now().replace(microsecond=0)
|
||||
to_slowdown = self.env['digest.digest']
|
||||
for digest in self:
|
||||
if digest.periodicity == 'daily': # 2 days ago
|
||||
limit_dt = today - relativedelta(days=2)
|
||||
elif digest.periodicity == 'weekly': # 1 week ago
|
||||
limit_dt = today - relativedelta(days=7)
|
||||
elif digest.periodicity == 'monthly': # 1 month ago
|
||||
limit_dt = today - relativedelta(months=1)
|
||||
elif digest.periodicity == 'quarterly': # 3 month ago
|
||||
limit_dt = today - relativedelta(months=3)
|
||||
users_logs = self.env['res.users.log'].sudo().search_count([
|
||||
('create_uid', 'in', digest.user_ids.ids),
|
||||
('create_date', '>=', limit_dt)
|
||||
])
|
||||
if not users_logs:
|
||||
to_slowdown += digest
|
||||
return to_slowdown
|
||||
|
||||
def _get_next_periodicity(self):
|
||||
if self.periodicity == 'daily':
|
||||
return 'weekly', _('weekly')
|
||||
if self.periodicity == 'weekly':
|
||||
return 'monthly', _('monthly')
|
||||
return 'quarterly', _('quarterly')
|
||||
|
||||
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 '')
|
||||
22
odoo-bringout-oca-ocb-digest/digest/models/digest_tip.py
Normal file
22
odoo-bringout-oca-ocb-digest/digest/models/digest_tip.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# -*- 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')
|
||||
name = fields.Char('Name', translate=True)
|
||||
user_ids = fields.Many2many(
|
||||
'res.users', string='Recipients',
|
||||
help='Users having already received this tip')
|
||||
tip_description = fields.Html('Tip description', translate=html_translate, sanitize=False)
|
||||
group_id = fields.Many2one(
|
||||
'res.groups', string='Authorized Group',
|
||||
default=lambda self: self.env.ref('base.group_user'))
|
||||
|
|
@ -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')
|
||||
19
odoo-bringout-oca-ocb-digest/digest/models/res_users.py
Normal file
19
odoo-bringout-oca-ocb-digest/digest/models/res_users.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# -*- 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_create_multi
|
||||
def create(self, vals_list):
|
||||
""" Automatically subscribe employee users to default digest if activated """
|
||||
users = super(ResUsers, self).create(vals_list)
|
||||
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')
|
||||
users_to_subscribe = users.filtered_domain([('share', '=', False)])
|
||||
if default_digest_emails and default_digest_id and users_to_subscribe:
|
||||
digest = self.env['digest.digest'].sudo().browse(int(default_digest_id)).exists()
|
||||
digest.user_ids |= users_to_subscribe
|
||||
return users
|
||||
Loading…
Add table
Add a link
Reference in a new issue