diff --git a/mail_digest/README.rst b/mail_digest/README.rst new file mode 100644 index 00000000..41e83312 --- /dev/null +++ b/mail_digest/README.rst @@ -0,0 +1,90 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +========================= +Mail digest notifications +========================= + +Features +-------- + +This module allows users/partners to: + +* select "digest" mode in their notification settings +* with digest mode on select a frequency: "daily" or "weekly" +* configure specific rules per message subtype (enabled/disabled) + +to receive or to not receive any email notification for a given subtype. + +The preference tab on user's form will look like: + +.. image:: ./images/preview.png + + +Behavior +-------- + +When a partner with digest mode on is notified with a message of type email or an email +all the messages are collected inside a `mail.digest` container. + +A daily cron and a weekly cron will take care of creating a single email per each digest, +which will be sent as a standard email. + +If the message has a specific subtype, all of this will work only +if personal settings allow to receive notification for that specific subtype. +Specifically: + +* no record for type: message passes +* record disabled for type: message don't pass +* record enabled for type: message pass + +NOTE: under the hood the digest notification logic excludes followers to be notified, +since you really want to notify only mail.digest's partner. + +Known issues / Roadmap +====================== + +* take full control of message and email template. + +Right now the notification message and the digest mail itself is wrapped inside Odoo mail template. +We should be able to customize this easily. + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Contributors +------------ + +* Simone Orsi + + +Funders +------- + +The development of this module has been financially supported by: `Fluxdock.io `_ + + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/mail_digest/__init__.py b/mail_digest/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/mail_digest/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/mail_digest/__openerp__.py b/mail_digest/__openerp__.py new file mode 100644 index 00000000..169b9c5d --- /dev/null +++ b/mail_digest/__openerp__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + 'name': 'Mail digest', + 'summary': """Basic digest mail handling.""", + 'version': '9.0.1.0.0', + 'license': 'AGPL-3', + 'author': 'Camptocamp,Odoo Community Association (OCA)', + 'depends': [ + 'mail', + ], + 'data': [ + 'data/ir_cron.xml', + 'security/ir.model.access.csv', + 'views/mail_digest_views.xml', + 'views/partner_views.xml', + 'views/user_views.xml', + 'templates/digest_default.xml', + ], +} diff --git a/mail_digest/data/ir_cron.xml b/mail_digest/data/ir_cron.xml new file mode 100644 index 00000000..39c1087a --- /dev/null +++ b/mail_digest/data/ir_cron.xml @@ -0,0 +1,27 @@ + + + + + Digest mail process - daily + + 1 + days + -1 + + + + + + + Digest mail process - weekly + + 1 + weeks + -1 + + + + + + + diff --git a/mail_digest/demo/ir_ui_view.xml b/mail_digest/demo/ir_ui_view.xml new file mode 100644 index 00000000..bf7bff5d --- /dev/null +++ b/mail_digest/demo/ir_ui_view.xml @@ -0,0 +1,39 @@ + + + + + diff --git a/mail_digest/demo/mail_template.xml b/mail_digest/demo/mail_template.xml new file mode 100644 index 00000000..d53b6aac --- /dev/null +++ b/mail_digest/demo/mail_template.xml @@ -0,0 +1,10 @@ + + + + QWeb demo + qweb + + + QWeb demo email + + diff --git a/mail_digest/i18n/de.po b/mail_digest/i18n/de.po new file mode 100644 index 00000000..48d21581 --- /dev/null +++ b/mail_digest/i18n/de.po @@ -0,0 +1,184 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mail_digest +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 9.0c\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-23 07:08+0000\n" +"PO-Revision-Date: 2017-04-27 16:55+0200\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" +"X-Generator: Poedit 1.8.9\n" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_create_uid +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_create_uid +msgid "Created by" +msgstr "Angelegt von" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_create_date +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_create_date +msgid "Created on" +msgstr "Angelegt am" + +#. module: mail_digest +#: selection:res.partner,notify_frequency:0 +msgid "Daily" +msgstr "Täglich" + +#. module: mail_digest +#: code:addons/mail_digest/models/mail_digest.py:106 +msgid "Daily update" +msgstr "täglich Übersicht" + +#. module: mail_digest +#: code:addons/mail_digest/models/res_partner.py:11 +#: model:ir.actions.act_window,name:mail_digest.action_digest_all +#: model:ir.ui.menu,name:mail_digest.menu_email_digest +#, python-format +msgid "Digest" +msgstr "Übersicht" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_display_name +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_display_name +msgid "Display Name" +msgstr "Angezeigter Name" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_enabled +msgid "Enabled" +msgstr "Aktivieren" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_frequency +#: model:ir.model.fields,field_description:mail_digest.field_res_partner_notify_frequency +msgid "Frequency" +msgstr "Häufigkeit" + +#. module: mail_digest +#: model:ir.ui.view,arch_db:mail_digest.default_digest_tmpl +msgid "Hello," +msgstr "Guten Tag" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_id +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_id +msgid "ID" +msgstr "ID" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest___last_update +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf___last_update +msgid "Last Modified on" +msgstr "Zuletzt geändert am" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_write_uid +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_write_uid +msgid "Last Updated by" +msgstr "Zuletzt aktualisiert durch" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_write_date +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_write_date +msgid "Last Updated on" +msgstr "Zuletzt aktualisiert am" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_mail_id +msgid "Mail" +msgstr "E-Mail" + +#. module: mail_digest +#: model:ir.model,name:mail_digest.model_mail_digest +#: model:ir.ui.view,arch_db:mail_digest.mail_digest_form +#: model:ir.ui.view,arch_db:mail_digest.mail_digest_tree +msgid "Mail digest" +msgstr "Übersicht" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_message_ids +#: model:ir.ui.view,arch_db:mail_digest.mail_digest_form +msgid "Messages" +msgstr "Mitteilungen" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_name +msgid "Name" +msgstr "Bezeichnung" + +#. module: mail_digest +#: model:ir.ui.view,arch_db:mail_digest.notification_form +msgid "Notification" +msgstr "Benachrichtigung" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_subtype_id +msgid "Notification type" +msgstr "Benachrichtigung" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_res_partner_notify_conf_ids +#: model:ir.ui.view,arch_db:mail_digest.notification_tree +msgid "Notifications" +msgstr "Benachrichtigungen" + +#. module: mail_digest +#: model:ir.model,name:mail_digest.model_res_partner +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_partner_id +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_partner_id +msgid "Partner" +msgstr "Partner" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_res_partner_disabled_notify_subtype_ids +msgid "Partner disabled subtypes" +msgstr "Partner disabled subtypes" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_res_partner_enabled_notify_subtype_ids +msgid "Partner enabled subtypes" +msgstr "Partner enabled subtypes" + +#. module: mail_digest +#: model:ir.model,name:mail_digest.model_partner_notification_conf +msgid "Partner notification configuration" +msgstr "Partner notification configuration" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_state +msgid "Status" +msgstr "Status" + +#. module: mail_digest +#: selection:res.partner,notify_frequency:0 +msgid "Weekly" +msgstr "Wöchentlich" + +#. module: mail_digest +#: code:addons/mail_digest/models/mail_digest.py:108 +msgid "Weekly update" +msgstr "wöchentlich Übersicht" + +#. module: mail_digest +#: sql_constraint:partner.notification.conf:0 +msgid "You can have only one configuration per subtype!" +msgstr "You can have only one configuration per subtype!" + +#~ msgid "Mail notification" +#~ msgstr "Mail notification" + +#~ msgid "Mail notifications control panel" +#~ msgstr "Mail notifications control panel" + +#~ msgid "Message subtypes" +#~ msgstr "Nachrichten-Subtyp" diff --git a/mail_digest/i18n/mail_digest.pot b/mail_digest/i18n/mail_digest.pot new file mode 100644 index 00000000..cdd22638 --- /dev/null +++ b/mail_digest/i18n/mail_digest.pot @@ -0,0 +1,175 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mail_digest +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 9.0c\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-23 07:08+0000\n" +"PO-Revision-Date: 2017-05-23 07:08+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_create_uid +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_create_uid +msgid "Created by" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_create_date +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_create_date +msgid "Created on" +msgstr "" + +#. module: mail_digest +#: selection:res.partner,notify_frequency:0 +msgid "Daily" +msgstr "" + +#. module: mail_digest +#: code:addons/mail_digest/models/mail_digest.py:106 +#, python-format +msgid "Daily update" +msgstr "" + +#. module: mail_digest +#: code:addons/mail_digest/models/res_partner.py:11 +#: model:ir.actions.act_window,name:mail_digest.action_digest_all +#: model:ir.ui.menu,name:mail_digest.menu_email_digest +#, python-format +msgid "Digest" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_display_name +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_display_name +msgid "Display Name" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_enabled +msgid "Enabled" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_frequency +#: model:ir.model.fields,field_description:mail_digest.field_res_partner_notify_frequency +msgid "Frequency" +msgstr "" + +#. module: mail_digest +#: model:ir.ui.view,arch_db:mail_digest.default_digest_tmpl +msgid "Hello," +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_id +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_id +msgid "ID" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest___last_update +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf___last_update +msgid "Last Modified on" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_write_uid +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_write_uid +msgid "Last Updated by" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_write_date +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_write_date +msgid "Last Updated on" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_mail_id +msgid "Mail" +msgstr "" + +#. module: mail_digest +#: model:ir.model,name:mail_digest.model_mail_digest +#: model:ir.ui.view,arch_db:mail_digest.mail_digest_form +#: model:ir.ui.view,arch_db:mail_digest.mail_digest_tree +msgid "Mail digest" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_message_ids +#: model:ir.ui.view,arch_db:mail_digest.mail_digest_form +msgid "Messages" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_name +msgid "Name" +msgstr "" + +#. module: mail_digest +#: model:ir.ui.view,arch_db:mail_digest.notification_form +msgid "Notification" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_subtype_id +msgid "Notification type" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_res_partner_notify_conf_ids +#: model:ir.ui.view,arch_db:mail_digest.notification_tree +msgid "Notifications" +msgstr "" + +#. module: mail_digest +#: model:ir.model,name:mail_digest.model_res_partner +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_partner_id +#: model:ir.model.fields,field_description:mail_digest.field_partner_notification_conf_partner_id +msgid "Partner" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_res_partner_disabled_notify_subtype_ids +msgid "Partner disabled subtypes" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_res_partner_enabled_notify_subtype_ids +msgid "Partner enabled subtypes" +msgstr "" + +#. module: mail_digest +#: model:ir.model,name:mail_digest.model_partner_notification_conf +msgid "Partner notification configuration" +msgstr "" + +#. module: mail_digest +#: model:ir.model.fields,field_description:mail_digest.field_mail_digest_state +msgid "Status" +msgstr "" + +#. module: mail_digest +#: selection:res.partner,notify_frequency:0 +msgid "Weekly" +msgstr "" + +#. module: mail_digest +#: code:addons/mail_digest/models/mail_digest.py:108 +#, python-format +msgid "Weekly update" +msgstr "" + +#. module: mail_digest +#: sql_constraint:partner.notification.conf:0 +msgid "You can have only one configuration per subtype!" +msgstr "" diff --git a/mail_digest/images/preview.png b/mail_digest/images/preview.png new file mode 100644 index 00000000..af147c9a Binary files /dev/null and b/mail_digest/images/preview.png differ diff --git a/mail_digest/models/__init__.py b/mail_digest/models/__init__.py new file mode 100644 index 00000000..4052c0fd --- /dev/null +++ b/mail_digest/models/__init__.py @@ -0,0 +1,2 @@ +from . import mail_digest +from . import res_partner diff --git a/mail_digest/models/mail_digest.py b/mail_digest/models/mail_digest.py new file mode 100644 index 00000000..45739bcf --- /dev/null +++ b/mail_digest/models/mail_digest.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from openerp import fields, models, api, _ + +import logging + +logger = logging.getLogger('[mail_digest]') + + +class MailDigest(models.Model): + _name = 'mail.digest' + _description = 'Mail digest' + _order = 'create_date desc' + + name = fields.Char( + string="Name", + compute="_compute_name", + readonly=True, + ) + # maybe we can retrieve the from messages? + partner_id = fields.Many2one( + string='Partner', + comodel_name='res.partner', + readonly=True, + required=True, + ondelete='cascade', + ) + frequency = fields.Selection( + related='partner_id.notify_frequency', + readonly=True, + ) + message_ids = fields.Many2many( + comodel_name='mail.message', + string='Messages' + ) + # TODO: take care of `auto_delete` feature + mail_id = fields.Many2one( + 'mail.mail', + 'Mail', + ondelete='set null', + ) + state = fields.Selection(related='mail_id.state') + + @api.multi + @api.depends("partner_id", "partner_id.notify_frequency") + def _compute_name(self): + for rec in self: + rec.name = u'{} - {}'.format( + rec.partner_id.name, rec._get_subject()) + + @api.model + def create_or_update(self, partners, message, subtype_id=None): + subtype_id = subtype_id or message.subtype_id + for partner in partners: + digest = self._get_or_create_by_partner(partner, message) + digest.message_ids |= message + return True + + @api.model + def _get_by_partner(self, partner, mail_id=False): + domain = [ + ('partner_id', '=', partner.id), + ('mail_id', '=', mail_id), + ] + return self.search(domain, limit=1) + + @api.model + def _get_or_create_by_partner(self, partner, message=None, mail_id=False): + existing = self._get_by_partner(partner, mail_id=mail_id) + if existing: + return existing + values = {'partner_id': partner.id, } + return self.create(values) + + @api.model + def _message_group_by_key(self, msg): + return msg.subtype_id.id + + @api.multi + def _message_group_by(self): + self.ensure_one() + grouped = {} + for msg in self.message_ids: + grouped.setdefault(self._message_group_by_key(msg), []).append(msg) + return grouped + + def _get_template(self): + # TODO: move this to a configurable field + return self.env.ref('mail_digest.default_digest_tmpl') + + def _get_site_name(self): + # default to company + name = self.env.user.company_id.name + if 'website' in self.env: + try: + ws = self.env['website'].get_current_website() + except RuntimeError: + # RuntimeError: object unbound -> no website request + ws = None + if ws: + name = ws.name + return name + + @api.multi + def _get_subject(self): + # TODO: shall we move this to computed field? + self.ensure_one() + subject = self._get_site_name() + ' ' + if self.partner_id.notify_frequency == 'daily': + subject += _('Daily update') + elif self.partner_id.notify_frequency == 'weekly': + subject += _('Weekly update') + return subject + + @api.multi + def _get_template_values(self): + self.ensure_one() + subject = self._get_subject() + template_values = { + 'digest': self, + 'subject': subject, + 'grouped_messages': self._message_group_by(), + 'base_url': + self.env['ir.config_parameter'].get_param('web.base.url'), + } + return template_values + + @api.multi + def _get_email_values(self, template=None): + self.ensure_one() + template = template or self._get_template() + subject = self._get_subject() + template_values = self._get_template_values() + values = { + 'email_from': self.env.user.company_id.email, + 'recipient_ids': [(4, self.partner_id.id)], + 'subject': subject, + 'body_html': template.with_context( + **self._template_context() + ).render(template_values), + } + return values + + def _create_mail_context(self): + return { + 'notify_only_recipients': True, + } + + @api.multi + def _template_context(self): + self.ensure_one() + return { + 'lang': self.partner_id.lang, + } + + @api.multi + def create_email(self, template=None): + mail_model = self.env['mail.mail'].with_context( + **self._create_mail_context()) + created = [] + for item in self: + if not item.message_ids: + # useless to create a mail for a digest w/ messages + # messages could be deleted by admin for instance. + continue + values = item.with_context( + **item._template_context() + )._get_email_values(template=template) + item.mail_id = mail_model.create(values) + created.append(item.id) + if created: + logger.info('Create email for digest IDS=%s', str(created)) + + @api.model + def process(self, frequency='daily', domain=None): + if not domain: + domain = [ + ('mail_id', '=', False), + ('partner_id.notify_frequency', '=', frequency), + ] + self.search(domain).create_email() diff --git a/mail_digest/models/res_partner.py b/mail_digest/models/res_partner.py new file mode 100644 index 00000000..b66809f0 --- /dev/null +++ b/mail_digest/models/res_partner.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from openerp import models, fields, api, _ + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + notify_email = fields.Selection(selection_add=[('digest', _('Digest'))]) + notify_frequency = fields.Selection( + string='Frequency', + selection=[ + ('daily', 'Daily'), + ('weekly', 'Weekly') + ], + default='weekly', + required=True, + ) + notify_conf_ids = fields.One2many( + string='Notifications', + inverse_name='partner_id', + comodel_name='partner.notification.conf', + ) + enabled_notify_subtype_ids = fields.Many2many( + string='Partner enabled subtypes', + comodel_name='mail.message.subtype', + compute='_compute_enabled_notify_subtype_ids', + search='_search_enabled_notify_subtype_ids', + ) + disabled_notify_subtype_ids = fields.Many2many( + string='Partner disabled subtypes', + comodel_name='mail.message.subtype', + compute='_compute_disabled_notify_subtype_ids', + search='_search_disabled_notify_subtype_ids', + ) + + @api.multi + def _compute_notify_subtypes(self, enabled): + self.ensure_one() + query = ( + 'SELECT subtype_id FROM partner_notification_conf ' + 'WHERE partner_id=%s AND enabled = %s' + ) + self.env.cr.execute( + query, (self.id, enabled)) + return [x[0] for x in self.env.cr.fetchall()] + + @api.multi + @api.depends('notify_conf_ids.subtype_id') + def _compute_enabled_notify_subtype_ids(self): + for partner in self: + partner.enabled_notify_subtype_ids = \ + partner._compute_notify_subtypes(True) + + @api.multi + @api.depends('notify_conf_ids.subtype_id') + def _compute_disabled_notify_subtype_ids(self): + for partner in self: + partner.disabled_notify_subtype_ids = \ + partner._compute_notify_subtypes(False) + + def _search_notify_subtype_ids_domain(self, operator, value, enabled): + if operator in ('in', 'not in') and \ + not isinstance(value, (tuple, list)): + value = [value, ] + conf_value = value + if isinstance(conf_value, int): + # we search conf records always w/ 'in' + conf_value = [conf_value] + _value = self.env['partner.notification.conf'].search([ + ('subtype_id', 'in', conf_value), + ('enabled', '=', enabled), + ]).mapped('partner_id').ids + return [('id', operator, _value)] + + def _search_enabled_notify_subtype_ids(self, operator, value): + return self._search_notify_subtype_ids_domain( + operator, value, True) + + def _search_disabled_notify_subtype_ids(self, operator, value): + return self._search_notify_subtype_ids_domain( + operator, value, False) + + @api.multi + def _notify(self, message, force_send=False, user_signature=True): + """Override to delegate domain generation.""" + # notify_by_email + email_domain = self._get_notify_by_email_domain(message) + # `sudo` from original odoo method + # the reason should be that anybody can write messages to a partner + # and you really want to find all ppl to be notified + partners = self.sudo().search(email_domain) + partners._notify_by_email( + message, force_send=force_send, user_signature=user_signature) + # notify_by_digest + digest_domain = self._get_notify_by_email_domain( + message, digest=True) + partners = self.sudo().search(digest_domain) + partners._notify_by_digest(message) + + # notify_by_chat + self._notify_by_chat(message) + return True + + @api.multi + def _notify_by_digest(self, message): + message_sudo = message.sudo() + if not message_sudo.message_type == 'email': + return + self.env['mail.digest'].sudo().create_or_update(self, message) + + @api.model + def _get_notify_by_email_domain(self, message, digest=False): + """Return domain to collect partners to be notified by email. + + :param message: instance of mail.message + :param digest: include/exclude digest enabled partners + + NOTE: since mail.mail inherits from mail.message + this method is called even when + we create the final email for mail.digest object. + Here we introduce a new context flag `notify_only_recipients` + to explicitely retrieve only partners among message's recipients. + """ + + message_sudo = message.sudo() + channels = message.channel_ids.filtered( + lambda channel: channel.email_send) + email = message_sudo.author_id \ + and message_sudo.author_id.email or message.email_from + + ids = self.ids + if self.env.context.get('notify_only_recipients'): + ids = [x for x in ids if x in message.partner_ids.ids] + domain = [ + '|', + ('id', 'in', ids), + ('channel_ids', 'in', channels.ids), + ('email', '!=', email) + ] + if not digest: + domain.append(('notify_email', 'not in', ('none', 'digest'))) + else: + domain.append(('notify_email', '=', 'digest')) + if message.subtype_id: + domain.extend(self._get_domain_subtype_leaf(message.subtype_id)) + return domain + + @api.model + def _get_domain_subtype_leaf(self, subtype): + return [ + '|', + ('disabled_notify_subtype_ids', 'not in', (subtype.id, )), + ('enabled_notify_subtype_ids', 'in', (subtype.id, )), + ] + + @api.multi + def _notify_update_subtype(self, subtype, enable): + self.ensure_one() + exists = self.env['partner.notification.conf'].search([ + ('subtype_id', '=', subtype.id), + ('partner_id', '=', self.id) + ], limit=1) + if exists: + exists.enabled = enable + else: + self.write({ + 'notify_conf_ids': [ + (0, 0, {'enabled': enable, 'subtype_id': subtype.id})] + }) + + @api.multi + def _notify_enable_subtype(self, subtype): + self._notify_update_subtype(subtype, True) + + @api.multi + def _notify_disable_subtype(self, subtype): + self._notify_update_subtype(subtype, False) + + +class PartnerNotificationConf(models.Model): + """Hold partner's single notification configuration.""" + _name = 'partner.notification.conf' + _description = 'Partner notification configuration' + # TODO: add friendly onchange to not yield errors when editin via UI + _sql_constraints = [ + ('unique_partner_subtype_conf', + 'unique (partner_id,subtype_id)', + 'You can have only one configuration per subtype!') + ] + + partner_id = fields.Many2one( + string='Partner', + comodel_name='res.partner', + readonly=True, + required=True, + ondelete='cascade', + index=True, + ) + subtype_id = fields.Many2one( + 'mail.message.subtype', + 'Notification type', + ondelete='cascade', + required=True, + ) + enabled = fields.Boolean(default=True, index=True) diff --git a/mail_digest/security/ir.model.access.csv b/mail_digest/security/ir.model.access.csv new file mode 100644 index 00000000..43892a41 --- /dev/null +++ b/mail_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_mail_digest_all,mail.digest.all,model_mail_digest,,1,0,0,0 +access_partner_notification_conf_all,partner.notification.all,model_partner_notification_conf,,1,0,0,0 +access_mail_digest_system,mail.digest.all,model_mail_digest,base.group_system,1,1,1,1 +access_partner_notification_conf_system,partner.notification.all,model_partner_notification_conf,base.group_system,1,1,1,1 diff --git a/mail_digest/static/description/icon.png b/mail_digest/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/mail_digest/static/description/icon.png differ diff --git a/mail_digest/templates/digest_default.xml b/mail_digest/templates/digest_default.xml new file mode 100644 index 00000000..38e9eda9 --- /dev/null +++ b/mail_digest/templates/digest_default.xml @@ -0,0 +1,47 @@ + + + + + + + diff --git a/mail_digest/tests/__init__.py b/mail_digest/tests/__init__.py new file mode 100644 index 00000000..1e4552ba --- /dev/null +++ b/mail_digest/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_digest +from . import test_partner_domains +from . import test_subtypes_conf diff --git a/mail_digest/tests/test_digest.py b/mail_digest/tests/test_digest.py new file mode 100644 index 00000000..a7460c97 --- /dev/null +++ b/mail_digest/tests/test_digest.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from openerp.tests.common import TransactionCase + + +class DigestCase(TransactionCase): + + def setUp(self): + super(DigestCase, self).setUp() + self.partner_model = self.env['res.partner'] + self.message_model = self.env['mail.message'] + self.subtype_model = self.env['mail.message.subtype'] + self.digest_model = self.env['mail.digest'] + self.conf_model = self.env['partner.notification.conf'] + + self.partner1 = self.partner_model.with_context( + tracking_disable=1).create({ + 'name': 'Partner 1', + 'email': 'partner1@test.foo.com', + }) + self.partner2 = self.partner_model.with_context( + tracking_disable=1).create({ + 'name': 'Partner 2', + 'email': 'partner2@test.foo.com', + }) + self.partner3 = self.partner_model.with_context( + tracking_disable=1).create({ + 'name': 'Partner 3', + 'email': 'partner3@test.foo.com', + }) + self.subtype1 = self.subtype_model.create({'name': 'Type 1'}) + self.subtype2 = self.subtype_model.create({'name': 'Type 2'}) + + def test_get_or_create_digest(self): + message1 = self.message_model.create({ + 'body': 'My Body 1', + 'subtype_id': self.subtype1.id, + }) + message2 = self.message_model.create({ + 'body': 'My Body 2', + 'subtype_id': self.subtype2.id, + }) + # 2 messages, 1 digest container + dig1 = self.digest_model._get_or_create_by_partner( + self.partner1, message1) + dig2 = self.digest_model._get_or_create_by_partner( + self.partner1, message2) + self.assertEqual(dig1, dig2) + + def test_create_or_update_digest(self): + partners = self.partner_model + partners |= self.partner1 + partners |= self.partner2 + message1 = self.message_model.create({ + 'body': 'My Body 1', + 'subtype_id': self.subtype1.id, + }) + message2 = self.message_model.create({ + 'body': 'My Body 2', + 'subtype_id': self.subtype2.id, + }) + # partner 1 + self.digest_model.create_or_update(self.partner1, message1) + self.digest_model.create_or_update(self.partner1, message2) + p1dig = self.digest_model._get_or_create_by_partner(self.partner1) + self.assertIn(message1, p1dig.message_ids) + self.assertIn(message2, p1dig.message_ids) + # partner 2 + self.digest_model.create_or_update(self.partner2, message1) + self.digest_model.create_or_update(self.partner2, message2) + p2dig = self.digest_model._get_or_create_by_partner(self.partner2) + self.assertIn(message1, p2dig.message_ids) + self.assertIn(message2, p2dig.message_ids) + + def test_notify_partner_digest(self): + message = self.message_model.create({ + 'body': 'My Body 1', + 'subtype_id': self.subtype1.id, + }) + self.partner1.notify_email = 'digest' + # notify partner + self.partner1._notify(message) + # we should find the message in its digest + dig1 = self.digest_model._get_or_create_by_partner( + self.partner1, message) + self.assertIn(message, dig1.message_ids) + + def test_notify_partner_digest_followers(self): + self.partner3.message_subscribe(self.partner2.ids) + self.partner1.notify_email = 'digest' + self.partner2.notify_email = 'digest' + partners = self.partner1 + self.partner2 + message = self.message_model.create({ + 'body': 'My Body 1', + 'subtype_id': self.subtype1.id, + 'res_id': self.partner3.id, + 'model': 'res.partner', + 'partner_ids': [(4, self.partner1.id)] + }) + # notify partners + partners._notify(message) + # we should find the a digest for each partner + dig1 = self.digest_model._get_by_partner(self.partner1) + dig2 = self.digest_model._get_by_partner(self.partner2) + # and the message in them + self.assertIn(message, dig1.message_ids) + self.assertIn(message, dig2.message_ids) + # now we exclude followers + dig1.unlink() + dig2.unlink() + partners.with_context(notify_only_recipients=1)._notify(message) + # we should find the a digest for each partner + self.assertTrue(self.digest_model._get_by_partner(self.partner1)) + self.assertFalse(self.digest_model._get_by_partner(self.partner2)) + + def _create_for_partner(self, partner): + messages = {} + for type_id in (self.subtype1.id, self.subtype2.id): + for k in xrange(1, 3): + key = '{}_{}'.format(type_id, k) + messages[key] = self.message_model.create({ + 'subject': 'My Subject {}'.format(key), + 'body': 'My Body {}'.format(key), + 'subtype_id': type_id, + }) + self.digest_model.create_or_update( + partner, messages[key]) + return self.digest_model._get_or_create_by_partner(partner) + + def test_digest_group_messages(self): + dig = self._create_for_partner(self.partner1) + grouped = dig._message_group_by() + for type_id in (self.subtype1.id, self.subtype2.id): + self.assertIn(type_id, grouped) + self.assertEqual(len(grouped[type_id]), 2) + + def test_digest_mail_values(self): + dig = self._create_for_partner(self.partner1) + values = dig._get_email_values() + expected = ('recipient_ids', 'subject', 'body_html') + for k in expected: + self.assertIn(k, values) + + self.assertEqual(self.env.user.company_id.email, values['email_from']) + self.assertEqual([(4, self.partner1.id)], values['recipient_ids']) diff --git a/mail_digest/tests/test_partner_domains.py b/mail_digest/tests/test_partner_domains.py new file mode 100644 index 00000000..09e5951c --- /dev/null +++ b/mail_digest/tests/test_partner_domains.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from openerp.tests.common import TransactionCase + + +class PartnerDomainCase(TransactionCase): + + def setUp(self): + super(PartnerDomainCase, self).setUp() + self.partner_model = self.env['res.partner'] + self.message_model = self.env['mail.message'] + self.subtype_model = self.env['mail.message.subtype'] + + self.partner1 = self.partner_model.with_context( + tracking_disable=1).create({ + 'name': 'Partner 1', + 'email': 'partner1@test.foo.com', + }) + self.partner2 = self.partner_model.with_context( + tracking_disable=1).create({ + 'name': 'Partner 2', + 'email': 'partner2@test.foo.com', + }) + self.partner3 = self.partner_model.with_context( + tracking_disable=1).create({ + 'name': 'Partner 3', + 'email': 'partner3@test.foo.com', + }) + self.subtype1 = self.subtype_model.create({'name': 'Type 1'}) + self.subtype2 = self.subtype_model.create({'name': 'Type 2'}) + + def _assert_found(self, domain, not_found=False, partner=None): + partner = partner or self.partner1 + if not_found: + self.assertNotIn(partner, self.partner_model.search(domain)) + else: + self.assertIn(partner, self.partner_model.search(domain)) + + def test_notify_domains_always(self): + # we don't set recipients + # because we call `_get_notify_by_email_domain` directly + message = self.message_model.create({'body': 'My Body', }) + partner = self.partner1 + partner.notify_email = 'always' + domain = partner._get_notify_by_email_domain(message) + self._assert_found(domain) + domain = partner._get_notify_by_email_domain(message, digest=1) + self._assert_found(domain, not_found=1) + + def test_notify_domains_only_recipients(self): + # we don't set recipients + # because we call `_get_notify_by_email_domain` directly + self.partner1.notify_email = 'always' + self.partner2.notify_email = 'always' + partners = self.partner1 + self.partner2 + # followers + self.partner3.message_subscribe(self.partner2.ids) + # partner1 is the only recipient + message = self.message_model.create({ + 'body': 'My Body', + 'res_id': self.partner3.id, + 'model': 'res.partner', + 'partner_ids': [(4, self.partner1.id)] + }) + domain = partners._get_notify_by_email_domain(message) + # we find both of them since partner2 is a follower + self._assert_found(domain) + self._assert_found(domain, partner=self.partner2) + # no one here in digest mode + domain = partners._get_notify_by_email_domain(message, digest=1) + self._assert_found(domain, not_found=1) + self._assert_found(domain, not_found=1, partner=self.partner2) + + # include only recipients + domain = partners.with_context( + notify_only_recipients=1)._get_notify_by_email_domain(message) + self._assert_found(domain) + self._assert_found(domain, partner=self.partner2, not_found=1) + + def test_notify_domains_digest(self): + # we don't set recipients + # because we call `_get_notify_by_email_domain` directly + message = self.message_model.create({'body': 'My Body', }) + partner = self.partner1 + partner.notify_email = 'digest' + domain = partner._get_notify_by_email_domain(message) + self._assert_found(domain, not_found=1) + domain = partner._get_notify_by_email_domain(message, digest=1) + self._assert_found(domain) + + def test_notify_domains_none(self): + message = self.message_model.create({'body': 'My Body', }) + partner = self.partner1 + partner.notify_email = 'none' + domain = partner._get_notify_by_email_domain(message) + self._assert_found(domain, not_found=1) + domain = partner._get_notify_by_email_domain(message, digest=1) + self._assert_found(domain, not_found=1) + + def test_notify_domains_match_type_digest(self): + # Test message subtype matches partner settings. + # The partner can have several `partner.notification.conf` records. + # Each records establish notification rules by type. + # If you don't have any record in it, you allow all subtypes. + # Record `typeX` with `enable=True` enables notification for `typeX`. + # Record `typeX` with `enable=False` disables notification for `typeX`. + + partner = self.partner1 + # enable digest + partner.notify_email = 'digest' + message_t1 = self.message_model.create({ + 'body': 'My Body', + 'subtype_id': self.subtype1.id, + }) + message_t2 = self.message_model.create({ + 'body': 'My Body', + 'subtype_id': self.subtype2.id, + }) + # enable subtype on partner + partner._notify_enable_subtype(self.subtype1) + domain = partner._get_notify_by_email_domain( + message_t1, digest=True) + # notification enabled: we find the partner. + self._assert_found(domain) + # for subtype2 we don't have any explicit rule: we find the partner + domain = partner._get_notify_by_email_domain( + message_t2, digest=True) + self._assert_found(domain) + # enable subtype2: find the partner anyway + partner._notify_enable_subtype(self.subtype2) + domain = partner._get_notify_by_email_domain( + message_t2, digest=True) + self._assert_found(domain) + # disable subtype2: we don't find the partner anymore + partner._notify_disable_subtype(self.subtype2) + domain = partner._get_notify_by_email_domain( + message_t2, digest=True) + self._assert_found(domain, not_found=1) + + def test_notify_domains_match_type_always(self): + message_t1 = self.message_model.create({ + 'body': 'My Body', + 'subtype_id': self.subtype1.id, + }) + message_t2 = self.message_model.create({ + 'body': 'My Body', + 'subtype_id': self.subtype2.id, + }) + # enable always + partner = self.partner1 + partner.notify_email = 'always' + # enable subtype on partner + partner._notify_enable_subtype(self.subtype1) + domain = partner._get_notify_by_email_domain(message_t1) + # notification enabled: we find the partner. + self._assert_found(domain) + # for subtype2 we don't have any explicit rule: we find the partner + domain = partner._get_notify_by_email_domain(message_t2) + self._assert_found(domain) + # enable subtype2: find the partner anyway + partner._notify_enable_subtype(self.subtype2) + domain = partner._get_notify_by_email_domain(message_t2) + self._assert_found(domain) + # disable subtype2: we don't find the partner anymore + partner._notify_disable_subtype(self.subtype2) + domain = partner._get_notify_by_email_domain(message_t2) + self._assert_found(domain, not_found=1) diff --git a/mail_digest/tests/test_subtypes_conf.py b/mail_digest/tests/test_subtypes_conf.py new file mode 100644 index 00000000..075a48c4 --- /dev/null +++ b/mail_digest/tests/test_subtypes_conf.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from openerp.tests.common import TransactionCase + + +class SubtypesCase(TransactionCase): + + def setUp(self): + super(SubtypesCase, self).setUp() + self.partner_model = self.env['res.partner'] + self.message_model = self.env['mail.message'] + self.subtype_model = self.env['mail.message.subtype'] + + self.partner1 = self.partner_model.with_context( + tracking_disable=1).create({ + 'name': 'Partner 1!', + 'email': 'partner1@test.foo.com', + }) + self.partner2 = self.partner_model.with_context( + tracking_disable=1).create({ + 'name': 'Partner 2!', + 'email': 'partner2@test.foo.com', + }) + self.subtype1 = self.subtype_model.create({'name': 'Type 1'}) + self.subtype2 = self.subtype_model.create({'name': 'Type 2'}) + + def _test_subtypes_rel(self): + # setup: + # t1, t2 enabled + # t3 disabled + # t4 no conf + self.subtype3 = self.subtype_model.create({'name': 'Type 3'}) + self.subtype4 = self.subtype_model.create({'name': 'Type 4'}) + # enable t1 t2 + self.partner1._notify_enable_subtype(self.subtype1) + self.partner1._notify_enable_subtype(self.subtype2) + # disable t3 + self.partner1._notify_disable_subtype(self.subtype3) + + def test_partner_computed_subtype(self): + self._test_subtypes_rel() + # check computed fields + self.assertIn( + self.subtype1, self.partner1.enabled_notify_subtype_ids) + self.assertNotIn( + self.subtype1, self.partner1.disabled_notify_subtype_ids) + self.assertIn( + self.subtype2, self.partner1.enabled_notify_subtype_ids) + self.assertNotIn( + self.subtype2, self.partner1.disabled_notify_subtype_ids) + self.assertIn( + self.subtype3, self.partner1.disabled_notify_subtype_ids) + self.assertNotIn( + self.subtype3, self.partner1.enabled_notify_subtype_ids) + self.assertNotIn( + self.subtype4, + self.partner1.enabled_notify_subtype_ids) + self.assertNotIn( + self.subtype4, + self.partner1.disabled_notify_subtype_ids) + + def test_partner_find_by_subtype_incl(self): + self._test_subtypes_rel() + domain = [( + 'enabled_notify_subtype_ids', + 'in', (self.subtype1.id, self.subtype2.id), + )] + self.assertIn( + self.partner1, + self.partner_model.search(domain) + ) + domain = [( + 'disabled_notify_subtype_ids', 'in', self.subtype3.id, + )] + self.assertIn( + self.partner1, + self.partner_model.search(domain) + ) + domain = [( + 'enabled_notify_subtype_ids', 'in', (self.subtype3.id, ), + )] + self.assertNotIn( + self.partner1, + self.partner_model.search(domain) + ) + domain = [( + 'enabled_notify_subtype_ids', 'in', (self.subtype4.id, ), + )] + self.assertNotIn( + self.partner1, + self.partner_model.search(domain) + ) + domain = [( + 'disabled_notify_subtype_ids', 'in', (self.subtype4.id, ), + )] + self.assertNotIn( + self.partner1, + self.partner_model.search(domain) + ) + + def test_partner_find_by_subtype_escl(self): + self._test_subtypes_rel() + domain = [( + 'enabled_notify_subtype_ids', + 'not in', (self.subtype4.id, ), + )] + self.assertIn( + self.partner1, + self.partner_model.search(domain) + ) + domain = [( + 'disabled_notify_subtype_ids', + 'not in', (self.subtype4.id, ), + )] + self.assertIn( + self.partner1, + self.partner_model.search(domain) + ) + domain = [( + 'enabled_notify_subtype_ids', + 'not in', (self.subtype3.id, ), + )] + self.assertIn( + self.partner1, + self.partner_model.search(domain) + ) + domain = [( + 'disabled_notify_subtype_ids', + 'not in', (self.subtype1.id, self.subtype2.id), + )] + self.assertIn( + self.partner1, + self.partner_model.search(domain) + ) diff --git a/mail_digest/views/mail_digest_views.xml b/mail_digest/views/mail_digest_views.xml new file mode 100644 index 00000000..a85f2705 --- /dev/null +++ b/mail_digest/views/mail_digest_views.xml @@ -0,0 +1,50 @@ + + + + + + mail_digest mail.digest.tree + mail.digest + + + + + + + + + + + + mail_digest mail.digest.form + mail.digest + +
+ + + + + + + + + + +
+
+
+ + + Digest + mail.digest + form + form,tree + + + + + +
+
diff --git a/mail_digest/views/partner_views.xml b/mail_digest/views/partner_views.xml new file mode 100644 index 00000000..0629a819 --- /dev/null +++ b/mail_digest/views/partner_views.xml @@ -0,0 +1,41 @@ + + + + + + mail.notifications res.partner.form + res.partner + + + + + + + + + + partner.notification.conf form + partner.notification.conf + +
+ + + + +
+
+
+ + + partner.notification.conf tree + partner.notification.conf + + + + + + + + +
+
diff --git a/mail_digest/views/user_views.xml b/mail_digest/views/user_views.xml new file mode 100644 index 00000000..88b44fd8 --- /dev/null +++ b/mail_digest/views/user_views.xml @@ -0,0 +1,18 @@ + + + + + + mail.notifications res.users.form + res.users + + + + + + + + + + +