1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449
|
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from markupsafe import Markup
from odoo import _, api, Command, fields, models, tools
from odoo.exceptions import UserError
class MassMailingList(models.Model):
"""Model of a contact list. """
_name = 'mailing.list'
_order = 'name'
_description = 'Mailing List'
_mailing_enabled = True
_order = 'create_date DESC'
# As this model has their own data merge, avoid to enable the generic data_merge on that model.
_disable_data_merge = True
name = fields.Char(string='Mailing List', required=True)
active = fields.Boolean(default=True)
contact_count = fields.Integer(compute="_compute_mailing_list_statistics", string='Number of Contacts')
contact_count_email = fields.Integer(compute="_compute_mailing_list_statistics", string="Number of Emails")
contact_count_opt_out = fields.Integer(compute="_compute_mailing_list_statistics", string="Number of Opted-out")
contact_pct_opt_out = fields.Float(compute="_compute_mailing_list_statistics", string="Percentage of Opted-out")
contact_count_blacklisted = fields.Integer(compute="_compute_mailing_list_statistics", string="Number of Blacklisted")
contact_pct_blacklisted = fields.Float(compute="_compute_mailing_list_statistics", string="Percentage of Blacklisted")
contact_pct_bounce = fields.Float(compute="_compute_mailing_list_statistics", string="Percentage of Bouncing")
contact_ids = fields.Many2many(
'mailing.contact', 'mailing_subscription', 'list_id', 'contact_id',
string='Mailing Lists', copy=False)
mailing_count = fields.Integer(compute="_compute_mailing_count", string="Number of Mailing")
mailing_ids = fields.Many2many(
'mailing.mailing', 'mail_mass_mailing_list_rel',
string='Mass Mailings', copy=False)
subscription_ids = fields.One2many(
'mailing.subscription', 'list_id',
string='Subscription Information',
copy=True, depends=['contact_ids'])
is_public = fields.Boolean(
string='Show In Preferences', default=False,
help='The mailing list can be accessible by recipients in the subscription '
'management page to allow them to update their preferences.')
# ------------------------------------------------------
# COMPUTE / ONCHANGE
# ------------------------------------------------------
@api.depends('mailing_ids')
def _compute_mailing_count(self):
data = {}
if self.ids:
self.env.cr.execute('''
SELECT mailing_list_id, count(*)
FROM mail_mass_mailing_list_rel
WHERE mailing_list_id IN %s
GROUP BY mailing_list_id''', (tuple(self.ids),))
data = dict(self.env.cr.fetchall())
for mailing_list in self:
mailing_list.mailing_count = data.get(mailing_list._origin.id, 0)
@api.depends('contact_ids')
def _compute_mailing_list_statistics(self):
""" Computes various statistics for this mailing.list that allow users
to have a global idea of its quality (based on blacklist, opt-outs, ...).
As some fields depend on the value of each other (mainly percentages),
we compute everything in a single method. """
# flush, notably to have email_normalized computed on contact model
self.env.flush_all()
# 1. Fetch contact data and associated counts (total / blacklist / opt-out)
contact_statistics_per_mailing = self._fetch_contact_statistics()
# 2. Fetch bounce data
# Optimized SQL way of fetching the count of contacts that have
# at least 1 message bouncing for passed mailing.lists """
bounce_per_mailing = {}
if self.ids:
sql = '''
SELECT list_sub.list_id, COUNT(DISTINCT mc.id)
FROM mailing_contact mc
LEFT OUTER JOIN mailing_subscription list_sub
ON mc.id = list_sub.contact_id
WHERE mc.message_bounce > 0
AND list_sub.list_id in %s
GROUP BY list_sub.list_id
'''
self.env.cr.execute(sql, (tuple(self.ids),))
bounce_per_mailing = dict(self.env.cr.fetchall())
# 3. Compute and assign all counts / pct fields
for mailing_list in self:
contact_counts = contact_statistics_per_mailing.get(mailing_list.id, {})
for field, value in contact_counts.items():
if field in self._fields:
mailing_list[field] = value
if mailing_list.contact_count != 0:
mailing_list.contact_pct_opt_out = 100 * (mailing_list.contact_count_opt_out / mailing_list.contact_count)
mailing_list.contact_pct_blacklisted = 100 * (mailing_list.contact_count_blacklisted / mailing_list.contact_count)
mailing_list.contact_pct_bounce = 100 * (bounce_per_mailing.get(mailing_list.id, 0) / mailing_list.contact_count)
else:
mailing_list.contact_pct_opt_out = 0
mailing_list.contact_pct_blacklisted = 0
mailing_list.contact_pct_bounce = 0
# ------------------------------------------------------
# ORM overrides
# ------------------------------------------------------
def write(self, vals):
# Prevent archiving used mailing list
if 'active' in vals and not vals.get('active'):
mass_mailings = self.env['mailing.mailing'].search_count([
('state', '!=', 'done'),
('contact_list_ids', 'in', self.ids),
])
if mass_mailings > 0:
raise UserError(_("At least one of the mailing list you are trying to archive is used in an ongoing mailing campaign."))
return super(MassMailingList, self).write(vals)
@api.depends('contact_count')
def _compute_display_name(self):
for mailing_list in self:
mailing_list.display_name = f"{mailing_list.name} ({mailing_list.contact_count})"
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", mailing_list.name)) for mailing_list, vals in zip(self, vals_list)]
# ------------------------------------------------------
# ACTIONS
# ------------------------------------------------------
def action_open_import(self):
"""Open the mailing list contact import wizard."""
action = self.env['ir.actions.actions']._for_xml_id('mass_mailing.mailing_contact_import_action')
action['context'] = {
**self.env.context,
'default_mailing_list_ids': self.ids,
'default_subscription_ids': [
Command.create({'list_id': mailing_list.id})
for mailing_list in self
],
}
return action
def action_send_mailing(self):
"""Open the mailing form view, with the current lists set as recipients."""
action = self.env["ir.actions.actions"]._for_xml_id('mass_mailing.mailing_mailing_action_mail')
action.update({
'context': {
**self.env.context,
'default_contact_list_ids': self.ids,
'default_mailing_type': 'mail',
'default_model_id': self.env['ir.model']._get_id('mailing.list'),
},
'target': 'current',
'view_type': 'form',
})
return action
def action_view_contacts(self):
action = self.env["ir.actions.actions"]._for_xml_id("mass_mailing.action_view_mass_mailing_contacts")
action['domain'] = [('list_ids', 'in', self.ids)]
action['context'] = {'default_list_ids': self.ids}
return action
def action_view_contacts_email(self):
action = self.action_view_contacts()
action['context'] = dict(action.get('context', {}), search_default_filter_valid_email_recipient=1)
return action
def action_view_mailings(self):
action = self.env["ir.actions.actions"]._for_xml_id('mass_mailing.mailing_mailing_action_mail')
action['domain'] = [('contact_list_ids', 'in', self.ids)]
action['context'] = {'default_mailing_type': 'mail', 'default_contact_list_ids': self.ids}
return action
def action_view_contacts_opt_out(self):
action = self.env["ir.actions.actions"]._for_xml_id('mass_mailing.action_view_mass_mailing_contacts')
action['domain'] = [('list_ids', 'in', self.id)]
action['context'] = {'default_list_ids': self.ids, 'create': False, 'search_default_filter_opt_out': 1}
return action
def action_view_contacts_blacklisted(self):
action = self.env["ir.actions.actions"]._for_xml_id('mass_mailing.action_view_mass_mailing_contacts')
action['domain'] = [('list_ids', 'in', self.id)]
action['context'] = {'default_list_ids': self.ids, 'create': False, 'search_default_filter_blacklisted': 1}
return action
def action_view_contacts_bouncing(self):
action = self.env["ir.actions.actions"]._for_xml_id('mass_mailing.action_view_mass_mailing_contacts')
action['domain'] = [('list_ids', 'in', self.id)]
action['context'] = {'default_list_ids': self.ids, 'create': False, 'search_default_filter_bounce': 1}
return action
def action_merge(self, src_lists, archive):
"""
Insert all the contact from the mailing lists 'src_lists' to the
mailing list in 'self'. Possibility to archive the mailing lists
'src_lists' after the merge except the destination mailing list 'self'.
"""
# Explanation of the SQL query with an example. There are the following lists
# A (id=4): yti@odoo.com; yti@example.com
# B (id=5): yti@odoo.com; yti@openerp.com
# C (id=6): nothing
# To merge the mailing lists A and B into C, we build the view st that looks
# like this with our example:
#
# contact_id | email | row_number | list_id |
# ------------+---------------------------+------------------------
# 4 | yti@odoo.com | 1 | 4 |
# 6 | yti@odoo.com | 2 | 5 |
# 5 | yti@example.com | 1 | 4 |
# 7 | yti@openerp.com | 1 | 5 |
#
# The row_column is kind of an occurrence counter for the email address.
# Then we create the Many2many relation between the destination list and the contacts
# while avoiding to insert an existing email address (if the destination is in the source
# for example)
self.ensure_one()
# Put destination is sources lists if not already the case
src_lists |= self
self.env.flush_all()
self.env.cr.execute("""
INSERT INTO mailing_subscription (contact_id, list_id)
SELECT st.contact_id AS contact_id, %s AS list_id
FROM
(
SELECT
contact.id AS contact_id,
contact.email AS email,
list.id AS list_id,
row_number() OVER (PARTITION BY email ORDER BY email) AS rn
FROM
mailing_contact contact,
mailing_subscription contact_list_rel,
mailing_list list
WHERE contact.id=contact_list_rel.contact_id
AND COALESCE(contact_list_rel.opt_out,FALSE) = FALSE
AND contact.email_normalized NOT IN (select email from mail_blacklist where active = TRUE)
AND list.id=contact_list_rel.list_id
AND list.id IN %s
AND NOT EXISTS
(
SELECT 1
FROM
mailing_contact contact2,
mailing_subscription contact_list_rel2
WHERE contact2.email = contact.email
AND contact_list_rel2.contact_id = contact2.id
AND contact_list_rel2.list_id = %s
)
) st
WHERE st.rn = 1;""", (self.id, tuple(src_lists.ids), self.id))
self.env.invalidate_all()
if archive:
(src_lists - self).action_archive()
def close_dialog(self):
return {'type': 'ir.actions.act_window_close'}
# ------------------------------------------------------
# SUBSCRIPTION MANAGEMENT
# ------------------------------------------------------
def _update_subscription_from_email(self, email, opt_out=True, force_message=None):
""" When opting-out: we have to switch opted-in subscriptions. We don't
need to create subscription for other lists as opt-out = not being a
member.
When opting-in: we have to switch opted-out subscriptions and create
subscription for other mailing lists id they are public. Indeed a
contact is opted-in when being subscribed in a mailing list.
:param str email: email address that should opt-in or opt-out from
mailing lists;
:param boolean opt_out: if True, opt-out from lists given by self if
'email' is member of it. If False, opt-in in lists givben by self
and create membership if not already member;
:param str force_message: if given, post a note using that body on
contact instead of generated update message. Give False to entirely
skip the note step;
"""
email_normalized = tools.email_normalize(email)
if not self or not email_normalized:
return
contacts = self.env['mailing.contact'].with_context(active_test=False).search(
[('email_normalized', '=', email_normalized)]
)
if not contacts:
return
# switch opted-in subscriptions
if opt_out:
current_opt_in = contacts.subscription_ids.filtered(
lambda sub: not sub.opt_out and sub.list_id in self
)
if current_opt_in:
current_opt_in.write({'opt_out': True})
# switch opted-out subscription and create missing subscriptions
else:
subscriptions = contacts.subscription_ids.filtered(lambda sub: sub.list_id in self)
current_opt_out = subscriptions.filtered('opt_out')
if current_opt_out:
current_opt_out.write({'opt_out': False})
# create a subscription (for a single contact) for missing lists
missing_lists = self - subscriptions.list_id
if missing_lists:
self.env['mailing.subscription'].create([
{'contact_id': contacts[0].id,
'list_id': mailing_list.id}
for mailing_list in missing_lists
])
for contact in contacts:
# do not log if no opt-out / opt-in was actually done
if opt_out:
updated = current_opt_in.filtered(lambda sub: sub.contact_id == contact).list_id
else:
updated = current_opt_out.filtered(lambda sub: sub.contact_id == contact).list_id + missing_lists
if not updated:
continue
if force_message is False:
continue
if force_message:
body = force_message
elif opt_out:
body = Markup('<p>%s</p><ul>%s</ul>') % (
_('%(contact_name)s unsubscribed from the following mailing list(s)', contact_name=contact.display_name),
Markup().join(Markup('<li>%s</li>') % name for name in updated.mapped('name')),
)
else:
body = Markup('<p>%s</p><ul>%s</ul>') % (
_('%(contact_name)s subscribed to the following mailing list(s)', contact_name=contact.display_name),
Markup().join(Markup('<li>%s</li>') % name for name in updated.mapped('name')),
)
contact.with_context(mail_create_nosubscribe=True).message_post(
body=body,
subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'),
)
# ------------------------------------------------------
# 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
# ------------------------------------------------------
def _fetch_contact_statistics(self):
""" Compute number of contacts matching various conditions.
(see '_get_contact_count_select_fields' for details)
Will return a dict under the form:
{
42: { # 42 being the mailing list ID
'contact_count': 52,
'contact_count_email': 35,
'contact_count_opt_out': 5,
'contact_count_blacklisted': 2
},
...
} """
res = []
if self.ids:
self.env.cr.execute(f'''
SELECT
{','.join(self._get_contact_statistics_fields().values())}
FROM
mailing_subscription r
{self._get_contact_statistics_joins()}
WHERE list_id IN %s
GROUP BY
list_id;
''', (tuple(self.ids), ))
res = self.env.cr.dictfetchall()
contact_counts = {}
for res_item in res:
mailing_list_id = res_item.pop('mailing_list_id')
contact_counts[mailing_list_id] = res_item
for mass_mailing in self:
# adds default 0 values for ids that don't have statistics
if mass_mailing.id not in contact_counts:
contact_counts[mass_mailing.id] = {
field: 0
for field in mass_mailing._get_contact_statistics_fields()
}
return contact_counts
def _get_contact_statistics_fields(self):
""" Returns fields and SQL query select path in a dictionnary.
This is done to be easily overridable in subsequent modules.
- mailing_list_id id of the associated mailing.list
- contact_count: all contacts
- contact_count_email: all valid emails
- contact_count_opt_out: all opted-out contacts
- contact_count_blacklisted: all blacklisted contacts """
return {
'mailing_list_id': 'list_id AS mailing_list_id',
'contact_count': 'COUNT(*) AS contact_count',
'contact_count_email': '''
SUM(CASE WHEN
(c.email_normalized IS NOT NULL
AND COALESCE(r.opt_out,FALSE) = FALSE
AND bl.id IS NULL)
THEN 1 ELSE 0 END) AS contact_count_email''',
'contact_count_opt_out': '''
SUM(CASE WHEN COALESCE(r.opt_out,FALSE) = TRUE
THEN 1 ELSE 0 END) AS contact_count_opt_out''',
'contact_count_blacklisted': '''
SUM(CASE WHEN bl.id IS NOT NULL
THEN 1 ELSE 0 END) AS contact_count_blacklisted'''
}
def _get_contact_statistics_joins(self):
""" Extracted to be easily overridable by sub-modules (such as mass_mailing_sms). """
return """
LEFT JOIN mailing_contact c ON (r.contact_id=c.id)
LEFT JOIN mail_blacklist bl on c.email_normalized = bl.email and bl.active"""
|