[REF] mass_mailing: move mailing capability on model

PURPOSE

Remove hardcoded list of models on which mailing is possible. Indeed it is
not modular and not really smart with enterprise code not being reachable
in community.

SPECIFICATIONS

Replace hardcoded list of models on which mailing (both mail or sms) is
possible by a computed searchable field on ``ir.model`` based on a class
attribute.

It allows to cleanly define models having mass mailing capabilities and add
this attribute in bridge modules (when existing) or directly on base model
definition to avoid bridge modules.

In this commit we introduce a basic ``_mailing_enabled`` class attribute
activating mailing on model.

Mailing models may also have a ``_mailing_get_default_domain`` method allowing
to define a custom default domain when sending a marketing mailing on records
on this class.

Mailing models can now define a ``_mailing_get_opt_out_list(_sms)`` method
allowing to define custom behavior to fetch opt-outed records. Instead of
defining a model-based behavior on Mailing itself, it now calls the model
defined one. We still have two methods, one for mailing and one for SMS
opt out computation as it relies on different underlying models and fields.

LINKS

Task ID-2431217
COM PR odoo/odoo#67322
ENT PR odoo/enterprise#16876
UPG PR odoo/upgrade#2236
This commit is contained in:
Thibault Delavallée
2021-05-26 07:57:47 +00:00
parent 3ffd6acc02
commit ed508ee1bd
27 changed files with 181 additions and 75 deletions
@@ -95,6 +95,7 @@ class Applicant(models.Model):
_description = "Applicant"
_order = "priority desc, id desc"
_inherit = ['mail.thread.cc', 'mail.activity.mixin', 'utm.mixin']
_mailing_enabled = True
name = fields.Char("Subject / Application Name", required=True, help="Email subject for applications sent via email")
active = fields.Boolean("Active", default=True, help="If the active field is set to false, it will allow you to hide the case without removing it.")
+2
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_model
from . import link_tracker
from . import mailing_contact
from . import mailing_list
@@ -10,6 +11,7 @@ from . import mail_mail
from . import mail_render_mixin
from . import mail_thread
from . import res_config_settings
from . import res_partner
from . import res_users
from . import utm
from . import res_company
+34
View File
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
class IrModel(models.Model):
_inherit = 'ir.model'
is_mailing_enabled = fields.Boolean(
string="Mailing Enabled",
compute='_compute_is_mailing_enabled', search='_search_is_mailing_enabled',
help="Whether this model supports marketing mailing capabilities (notably email and SMS).",
)
def _compute_is_mailing_enabled(self):
for model in self:
model.is_mailing_enabled = getattr(self.env[model.model], '_mailing_enabled', False)
def _search_is_mailing_enabled(self, operator, value):
if operator not in ('=', '!='):
raise ValueError(_("Searching Mailing Enabled models supports only direct search using '='' or '!='."))
valid_models = self.env['ir.model']
for model in self.search([]):
if model.model not in self.env or model.is_transient():
continue
if getattr(self.env[model.model], '_mailing_enabled', False):
valid_models |= model
search_is_mailing_enabled = (operator == '=' and value) or (operator == '!=' and not value)
if search_is_mailing_enabled:
return [('id', 'in', valid_models.ids)]
return [('id', 'not in', valid_models.ids)]
+10 -26
View File
@@ -10,7 +10,6 @@ import re
import threading
import werkzeug.urls
from ast import literal_eval
from datetime import datetime
from dateutil.relativedelta import relativedelta
from werkzeug.urls import url_join
@@ -20,17 +19,6 @@ from odoo.osv import expression
_logger = logging.getLogger(__name__)
MASS_MAILING_BUSINESS_MODELS = [
'crm.lead',
'event.registration',
'hr.applicant',
'res.partner',
'event.track',
'sale.order',
'mailing.list',
'mailing.contact'
]
# Syntax of the data URL Scheme: https://tools.ietf.org/html/rfc2397#section-3
# Used to find inline images
image_re = re.compile(r"data:(image/[A-Za-z]+);base64,(.*)")
@@ -128,7 +116,7 @@ class MassMailing(models.Model):
mailing_model_real = fields.Char(string='Recipients Real Model', compute='_compute_model')
mailing_model_id = fields.Many2one(
'ir.model', string='Recipients Model', ondelete='cascade', required=True,
domain=[('model', 'in', MASS_MAILING_BUSINESS_MODELS)],
domain=[('is_mailing_enabled', '=', True)],
default=lambda self: self.env.ref('mass_mailing.model_mailing_list').id)
mailing_model_name = fields.Char(
string='Recipients Model Name', related='mailing_model_id.model',
@@ -485,20 +473,16 @@ class MassMailing(models.Model):
# ------------------------------------------------------
def _get_opt_out_list(self):
"""Returns a set of emails opted-out in target model"""
""" Give list of opt-outed emails, depending on specific model-based
computation if available.
:return list: opt-outed emails, preferably normalized (aka not records)
"""
self.ensure_one()
opt_out = {}
target = self.env[self.mailing_model_real]
if self.mailing_model_real == "mailing.contact":
# if user is opt_out on One list but not on another
# or if two user with same email address, one opted in and the other one opted out, send the mail anyway
# TODO DBE Fixme : Optimise the following to get real opt_out and opt_in
target_list_contacts = self.env['mailing.contact.subscription'].search(
[('list_id', 'in', self.contact_list_ids.ids)])
opt_out_contacts = target_list_contacts.filtered(lambda rel: rel.opt_out).mapped('contact_id.email_normalized')
opt_in_contacts = target_list_contacts.filtered(lambda rel: not rel.opt_out).mapped('contact_id.email_normalized')
opt_out = set(c for c in opt_out_contacts if c not in opt_in_contacts)
if hasattr(self.env[self.mailing_model_name], '_mailing_get_opt_out_list'):
opt_out = self.env[self.mailing_model_name]._mailing_get_opt_out_list(self)
_logger.info(
"Mass-mailing %s targets %s, blacklist: %s emails",
self, target._name, len(opt_out))
@@ -848,8 +832,8 @@ class MassMailing(models.Model):
def _get_default_mailing_domain(self):
mailing_domain = []
if self.mailing_model_name == 'mailing.list' and self.contact_list_ids:
mailing_domain = [('list_ids', 'in', self.contact_list_ids.ids)]
if hasattr(self.env[self.mailing_model_name], '_mailing_get_default_domain'):
mailing_domain = self.env[self.mailing_model_name]._mailing_get_default_domain(self)
if self.mailing_type == 'mail' and 'is_blacklisted' in self.env[self.mailing_model_name]._fields:
mailing_domain = expression.AND([[('is_blacklisted', '=', False)], mailing_domain])
@@ -54,6 +54,7 @@ class MassMailingContact(models.Model):
_inherit = ['mail.thread.blacklist']
_description = 'Mailing Contact'
_order = 'email'
_mailing_enabled = True
def default_get(self, fields):
""" When coming from a mailing list we may have a default_list_ids context
@@ -10,6 +10,7 @@ class MassMailingList(models.Model):
_name = 'mailing.list'
_order = 'name'
_description = 'Mailing List'
_mailing_enabled = True
name = fields.Char(string='Mailing List', required=True)
active = fields.Boolean(default=True)
@@ -224,6 +225,24 @@ class MassMailingList(models.Model):
def close_dialog(self):
return {'type': 'ir.actions.act_window_close'}
# ------------------------------------------------------
# MAILING
# ------------------------------------------------------
def _mailing_get_default_domain(self, mailing):
return [('list_ids', 'in', mailing.contact_list_ids.ids)]
def _mailing_get_opt_out_list(self, mailing):
""" Check subscription on all involved mailing lists. If user is opt_out
on one list but not on another if two users with same email address, one
opted in and the other one opted out, send the mail anyway. """
# TODO DBE Fixme : Optimize the following to get real opt_out and opt_in
subscriptions = self.subscription_ids if self else mailing.contact_list_ids.subscription_ids
opt_out_contacts = subscriptions.filtered(lambda rel: rel.opt_out).mapped('contact_id.email_normalized')
opt_in_contacts = subscriptions.filtered(lambda rel: not rel.opt_out).mapped('contact_id.email_normalized')
opt_out = set(c for c in opt_out_contacts if c not in opt_in_contacts)
return opt_out
# ------------------------------------------------------
# UTILITY
# ------------------------------------------------------
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class Partner(models.Model):
_inherit = 'res.partner'
_mailing_enabled = True
@@ -92,7 +92,7 @@ class TestMassMailValues(MassMailCommon):
self.assertEqual(mailing.reply_to_mode, 'new')
self.assertEqual(mailing.reply_to, self.email_reply_to)
# default for mailing list: depends upon contact_list_ids
self.assertEqual(literal_eval(mailing.mailing_domain), [])
self.assertEqual(literal_eval(mailing.mailing_domain), [('list_ids', 'in', [])])
mailing.write({
'contact_list_ids': [(4, self.mailing_list_1.id), (4, self.mailing_list_2.id)]
})
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import crm_lead
from . import mailing_mailing
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class CrmLead(models.Model):
_inherit = 'crm.lead'
_mailing_enabled = True
+2 -1
View File
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import event
from . import event_event
from . import event_registration
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, _
from odoo import models
class Event(models.Model):
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class EventRegistration(models.Model):
_inherit = 'event.registration'
_mailing_enabled = True
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import event
from . import event_event
from . import event_track
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class EventTrack(models.Model):
_inherit = 'event.track'
_mailing_enabled = True
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mailing_mailing
from . import sale_order
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class SaleOrder(models.Model):
_inherit = 'sale.order'
_mailing_enabled = True
@@ -51,3 +51,15 @@ class MailingList(models.Model):
return super(MailingList, self)._get_contact_statistics_joins() + '''
LEFT JOIN phone_blacklist bl_sms ON c.phone_sanitized = bl_sms.number and bl_sms.active
'''
def _mailing_get_opt_out_list_sms(self, mailing):
""" Check subscription on all involved mailing lists. If user is opt_out
on one list but not on another, one opted in and the other one opted out,
send mailing anyway.
:return list: opt-outed record IDs
"""
subscriptions = self.subscription_ids if self else mailing.contact_list_ids.subscription_ids
opt_out_contacts = subscriptions.filtered(lambda sub: sub.opt_out).mapped('contact_id')
opt_in_contacts = subscriptions.filtered(lambda sub: not sub.opt_out).mapped('contact_id')
return list(set(c.id for c in opt_out_contacts if c not in opt_in_contacts))
@@ -145,20 +145,16 @@ class Mailing(models.Model):
# --------------------------------------------------
def _get_opt_out_list_sms(self):
"""Returns a set of emails opted-out in target model"""
""" Give list of opt-outed records, depending on specific model-based
computation if available.
:return list: opt-outed record IDs
"""
self.ensure_one()
opt_out = []
target = self.env[self.mailing_model_real]
if self.mailing_model_real == 'mailing.contact':
# if user is opt_out on One list but not on another
# or if two user with same email address, one opted in and the other one opted out, send the mail anyway
# TODO DBE Fixme : Optimise the following to get real opt_out and opt_in
subscriptions = self.env['mailing.contact.subscription'].sudo().search(
[('list_id', 'in', self.contact_list_ids.ids)])
opt_out_contacts = subscriptions.filtered(lambda sub: sub.opt_out).mapped('contact_id')
opt_in_contacts = subscriptions.filtered(lambda sub: not sub.opt_out).mapped('contact_id')
opt_out = list(set(c.id for c in opt_out_contacts if c not in opt_in_contacts))
if hasattr(self.env[self.mailing_model_name], '_mailing_get_opt_out_list_sms'):
opt_out = self.env[self.mailing_model_name]._mailing_get_opt_out_list_sms(self)
_logger.info("Mass SMS %s targets %s: optout: %s contacts", self, target._name, len(opt_out))
else:
_logger.info("Mass SMS %s targets %s: no opt out list available", self, target._name)
@@ -54,7 +54,7 @@ class TestMassMailValues(MassSMSCommon):
self.assertEqual(mailing.mailing_model_name, 'mailing.list')
self.assertEqual(mailing.mailing_model_real, 'mailing.contact')
# default for mailing list: depends upon contact_list_ids
self.assertEqual(literal_eval(mailing.mailing_domain), [])
self.assertEqual(literal_eval(mailing.mailing_domain), [('list_ids', 'in', [])])
mailing.write({
'contact_list_ids': [(4, self.mailing_list_1.id), (4, self.mailing_list_2.id)]
})
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_mail_models
from . import mailing_mailing
@@ -10,6 +10,7 @@ class MailTestSMS(models.Model):
_description = 'Chatter Model for SMS Gateway'
_name = 'mail.test.sms'
_inherit = ['mail.thread']
_mailing_enabled = True
_order = 'name asc, id asc'
name = fields.Char()
@@ -32,6 +33,7 @@ class MailTestSMSBL(models.Model):
_description = 'SMS Mailing Blacklist Enabled'
_name = 'mail.test.sms.bl'
_inherit = ['mail.thread.phone']
_mailing_enabled = True
_order = 'name asc, id asc'
name = fields.Char()
@@ -54,6 +56,7 @@ class MailTestSMSOptout(models.Model):
_description = 'SMS Mailing Blacklist / Optout Enabled'
_name = 'mail.test.sms.bl.optout'
_inherit = ['mail.thread.phone']
_mailing_enabled = True
_order = 'name asc, id asc'
name = fields.Char()
@@ -64,6 +67,13 @@ class MailTestSMSOptout(models.Model):
customer_id = fields.Many2one('res.partner', 'Customer')
opt_out = fields.Boolean()
def _mailing_get_opt_out_list_sms(self, mailing):
res_ids = mailing._get_recipients()
return self.search([
('id', 'in', res_ids),
('opt_out', '=', True)
]).ids
def _sms_get_partner_fields(self):
return ['customer_id']
@@ -78,11 +88,19 @@ class MailTestSMSPartner(models.Model):
_description = 'Chatter Model for SMS Gateway (Partner only)'
_name = 'mail.test.sms.partner'
_inherit = ['mail.thread']
_mailing_enabled = True
name = fields.Char()
customer_id = fields.Many2one('res.partner', 'Customer')
opt_out = fields.Boolean()
def _mailing_get_opt_out_list_sms(self, mailing):
res_ids = mailing._get_recipients()
return self.search([
('id', 'in', res_ids),
('opt_out', '=', True)
]).ids
def _sms_get_partner_fields(self):
return ['customer_id']
@@ -96,11 +114,19 @@ class MailTestSMSPartner2Many(models.Model):
_description = 'Chatter Model for SMS Gateway (M2M Partners only)'
_name = 'mail.test.sms.partner.2many'
_inherit = ['mail.thread']
_mailing_enabled = True
name = fields.Char()
customer_ids = fields.Many2many('res.partner', string='Customers')
opt_out = fields.Boolean()
def _mailing_get_opt_out_list_sms(self, mailing):
res_ids = mailing._get_recipients()
return self.search([
('id', 'in', res_ids),
('opt_out', '=', True)
]).ids
def _sms_get_partner_fields(self):
return ['customer_ids']
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mailing_mailing
from . import mailing_models
@@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class Mailing(models.Model):
_inherit = 'mailing.mailing'
def _get_opt_out_list(self):
"""Returns a set of emails opted-out in target model"""
self.ensure_one()
if self.mailing_model_real == 'mailing.test.optout':
res_ids = self._get_recipients()
opt_out_contacts = set(self.env['mailing.test.optout'].search([
('id', 'in', res_ids),
('opt_out', '=', True)
]).mapped('email_normalized'))
_logger.info(
"Mass-mailing %s targets %s, optout: %s emails",
self, self.mailing_model_real, len(opt_out_contacts))
return opt_out_contacts
return super(Mailing, self)._get_opt_out_list()
@@ -51,6 +51,14 @@ class MailingOptOut(models.Model):
customer_id = fields.Many2one('res.partner', 'Customer', tracking=True)
user_id = fields.Many2one('res.users', 'Responsible', tracking=True)
def _mailing_get_opt_out_list(self, mailing):
res_ids = mailing._get_recipients()
opt_out_contacts = set(self.search([
('id', 'in', res_ids),
('opt_out', '=', True)
]).mapped('email_normalized'))
return opt_out_contacts
class MailingPerformance(models.Model):
""" A very simple model only inheriting from mail.thread to test pure mass
@@ -303,10 +303,13 @@ class TestMassMailing(TestMassMailCommon):
# contact_1 is optout but same email is not optout from the same list
# contact 3 is optout in list 1 but not in list 2
# contact 5 is optout
Sub = self.env['mailing.contact.subscription']
Sub.search([('contact_id', '=', mailing_contact_1.id), ('list_id', '=', mailing_list_1.id)]).write({'opt_out': True})
Sub.search([('contact_id', '=', mailing_contact_3.id), ('list_id', '=', mailing_list_1.id)]).write({'opt_out': True})
Sub.search([('contact_id', '=', mailing_contact_5.id), ('list_id', '=', mailing_list_1.id)]).write({'opt_out': True})
subs = self.env['mailing.contact.subscription'].search([
'|', '|',
'&', ('contact_id', '=', mailing_contact_1.id), ('list_id', '=', mailing_list_1.id),
'&', ('contact_id', '=', mailing_contact_3.id), ('list_id', '=', mailing_list_1.id),
'&', ('contact_id', '=', mailing_contact_5.id), ('list_id', '=', mailing_list_1.id)
])
subs.write({'opt_out': True})
# create mass mailing record
mailing = self.env['mailing.mailing'].create({