1 # -*- coding: utf-8 -*-
3 from datetime import datetime
4 from dateutil import relativedelta
8 from openerp import tools
9 from openerp.exceptions import Warning
10 from openerp.tools.safe_eval import safe_eval as eval
11 from openerp.tools.translate import _
12 from openerp.tools import ustr
13 from openerp.osv import osv, fields
16 class MassMailingCategory(osv.Model):
17 """Model of categories of mass mailing, i.e. marketing, newsletter, ... """
18 _name = 'mail.mass_mailing.category'
19 _description = 'Mass Mailing Category'
23 'name': fields.char('Name', required=True),
27 class MassMailingContact(osv.Model):
28 """Model of a contact. This model is different from the partner model
29 because it holds only some basic information: name, email. The purpose is to
30 be able to deal with large contact list to email without bloating the partner
32 _name = 'mail.mass_mailing.contact'
33 _inherit = 'mail.thread'
34 _description = 'Mass Mailing Contact'
39 'name': fields.char('Name'),
40 'email': fields.char('Email', required=True),
41 'create_date': fields.datetime('Create Date'),
42 'list_id': fields.many2one(
43 'mail.mass_mailing.list', string='Mailing List',
44 ondelete='cascade', required=True,
46 'opt_out': fields.boolean('Opt Out', help='The contact has chosen not to receive mails anymore from this list'),
49 def _get_latest_list(self, cr, uid, context={}):
50 lid = self.pool.get('mail.mass_mailing.list').search(cr, uid, [], limit=1, order='id desc', context=context)
51 return lid and lid[0] or False
54 'list_id': _get_latest_list
57 def get_name_email(self, name, context):
58 name, email = self.pool['res.partner']._parse_partner_name(name, context=context)
59 if name and not email:
61 if email and not name:
65 def name_create(self, cr, uid, name, context=None):
66 name, email = self.get_name_email(name, context=context)
67 rec_id = self.create(cr, uid, {'name': name, 'email': email}, context=context)
68 return self.name_get(cr, uid, [rec_id], context)[0]
70 def add_to_list(self, cr, uid, name, list_id, context=None):
71 name, email = self.get_name_email(name, context=context)
72 rec_id = self.create(cr, uid, {'name': name, 'email': email, 'list_id': list_id}, context=context)
73 return self.name_get(cr, uid, [rec_id], context)[0]
75 def message_get_default_recipients(self, cr, uid, ids, context=None):
77 for record in self.browse(cr, uid, ids, context=context):
78 res[record.id] = {'partner_ids': [], 'email_to': record.email, 'email_cc': False}
82 class MassMailingList(osv.Model):
83 """Model of a contact list. """
84 _name = 'mail.mass_mailing.list'
86 _description = 'Mailing List'
88 def _get_contact_nbr(self, cr, uid, ids, name, arg, context=None):
89 result = dict.fromkeys(ids, 0)
90 Contacts = self.pool.get('mail.mass_mailing.contact')
91 for group in Contacts.read_group(cr, uid, [('list_id', 'in', ids), ('opt_out', '!=', True)], ['list_id'], ['list_id'], context=context):
92 result[group['list_id'][0]] = group['list_id_count']
96 'name': fields.char('Mailing List', required=True),
97 'contact_nbr': fields.function(
98 _get_contact_nbr, type='integer',
99 string='Number of Contacts',
104 class MassMailingStage(osv.Model):
105 """Stage for mass mailing campaigns. """
106 _name = 'mail.mass_mailing.stage'
107 _description = 'Mass Mailing Campaign Stage'
111 'name': fields.char('Name', required=True, translate=True),
112 'sequence': fields.integer('Sequence'),
120 class MassMailingCampaign(osv.Model):
121 """Model of mass mailing campaigns. """
122 _name = "mail.mass_mailing.campaign"
123 _description = 'Mass Mailing Campaign'
125 def _get_statistics(self, cr, uid, ids, name, arg, context=None):
126 """ Compute statistics of the mass mailing campaign """
131 COUNT(s.id) AS total,
132 COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent,
133 COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null THEN 1 ELSE null END) AS scheduled,
134 COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is not null THEN 1 ELSE null END) AS failed,
135 COUNT(CASE WHEN s.id is not null AND s.bounced is null THEN 1 ELSE null END) AS delivered,
136 COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened,
137 COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied ,
138 COUNT(CASE WHEN s.bounced is not null THEN 1 ELSE null END) AS bounced
140 mail_mail_statistics s
142 mail_mass_mailing_campaign c
143 ON (c.id = s.mass_mailing_campaign_id)
149 for row in cr.dictfetchall():
150 results[row.pop('campaign_id')] = row
151 total = row['total'] or 1
152 row['delivered'] = row['sent'] - row['bounced']
153 row['received_ratio'] = 100.0 * row['delivered'] / total
154 row['opened_ratio'] = 100.0 * row['opened'] / total
155 row['replied_ratio'] = 100.0 * row['replied'] / total
159 'name': fields.char('Name', required=True),
160 'stage_id': fields.many2one('mail.mass_mailing.stage', 'Stage', required=True),
161 'user_id': fields.many2one(
162 'res.users', 'Responsible',
165 'category_ids': fields.many2many(
166 'mail.mass_mailing.category', 'mail_mass_mailing_category_rel',
167 'category_id', 'campaign_id', string='Categories'),
168 'mass_mailing_ids': fields.one2many(
169 'mail.mass_mailing', 'mass_mailing_campaign_id',
172 'unique_ab_testing': fields.boolean(
174 help='If checked, recipients will be mailed only once, allowing to send'
175 'various mailings in a single campaign to test the effectiveness'
177 'color': fields.integer('Color Index'),
179 'total': fields.function(
180 _get_statistics, string='Total',
181 type='integer', multi='_get_statistics'
183 'scheduled': fields.function(
184 _get_statistics, string='Scheduled',
185 type='integer', multi='_get_statistics'
187 'failed': fields.function(
188 _get_statistics, string='Failed',
189 type='integer', multi='_get_statistics'
191 'sent': fields.function(
192 _get_statistics, string='Sent Emails',
193 type='integer', multi='_get_statistics'
195 'delivered': fields.function(
196 _get_statistics, string='Delivered',
197 type='integer', multi='_get_statistics',
199 'opened': fields.function(
200 _get_statistics, string='Opened',
201 type='integer', multi='_get_statistics',
203 'replied': fields.function(
204 _get_statistics, string='Replied',
205 type='integer', multi='_get_statistics'
207 'bounced': fields.function(
208 _get_statistics, string='Bounced',
209 type='integer', multi='_get_statistics'
211 'received_ratio': fields.function(
212 _get_statistics, string='Received Ratio',
213 type='integer', multi='_get_statistics',
215 'opened_ratio': fields.function(
216 _get_statistics, string='Opened Ratio',
217 type='integer', multi='_get_statistics',
219 'replied_ratio': fields.function(
220 _get_statistics, string='Replied Ratio',
221 type='integer', multi='_get_statistics',
225 def _get_default_stage_id(self, cr, uid, context=None):
226 stage_ids = self.pool['mail.mass_mailing.stage'].search(cr, uid, [], limit=1, context=context)
227 return stage_ids and stage_ids[0] or False
230 'user_id': lambda self, cr, uid, ctx=None: uid,
231 'stage_id': lambda self, *args: self._get_default_stage_id(*args),
234 def get_recipients(self, cr, uid, ids, model=None, context=None):
235 """Return the recipients of a mailing campaign. This is based on the statistics
236 build for each mailing. """
237 Statistics = self.pool['mail.mail.statistics']
238 res = dict.fromkeys(ids, False)
240 domain = [('mass_mailing_campaign_id', '=', cid)]
242 domain += [('model', '=', model)]
243 stat_ids = Statistics.search(cr, uid, domain, context=context)
244 res[cid] = set(stat.res_id for stat in Statistics.browse(cr, uid, stat_ids, context=context))
248 class MassMailing(osv.Model):
249 """ MassMailing models a wave of emails for a mass mailign campaign.
250 A mass mailing is an occurence of sending emails. """
252 _name = 'mail.mass_mailing'
253 _description = 'Mass Mailing'
254 # number of periods for tracking mail_mail statistics
256 _order = 'sent_date DESC'
258 def __get_bar_values(self, cr, uid, obj, domain, read_fields, value_field, groupby_field, date_begin, context=None):
259 """ Generic method to generate data for bar chart values using SparklineBarWidget.
260 This method performs obj.read_group(cr, uid, domain, read_fields, groupby_field).
262 :param obj: the target model (i.e. crm_lead)
263 :param domain: the domain applied to the read_group
264 :param list read_fields: the list of fields to read in the read_group
265 :param str value_field: the field used to compute the value of the bar slice
266 :param str groupby_field: the fields used to group
268 :return list section_result: a list of dicts: [
269 { 'value': (int) bar_column_value,
270 'tootip': (str) bar_column_tooltip,
274 date_begin = date_begin.date()
275 section_result = [{'value': 0,
276 'tooltip': ustr((date_begin + relativedelta.relativedelta(days=i)).strftime('%d %B %Y')),
277 } for i in range(0, self._period_number)]
278 group_obj = obj.read_group(cr, uid, domain, read_fields, groupby_field, context=context)
279 field = obj._fields.get(groupby_field.split(':')[0])
280 pattern = tools.DEFAULT_SERVER_DATE_FORMAT if field.type == 'date' else tools.DEFAULT_SERVER_DATETIME_FORMAT
281 for group in group_obj:
282 group_begin_date = datetime.strptime(group['__domain'][0][2], pattern).date()
283 timedelta = relativedelta.relativedelta(group_begin_date, date_begin)
284 section_result[timedelta.days] = {'value': group.get(value_field, 0), 'tooltip': group.get(groupby_field)}
285 return section_result
287 def _get_daily_statistics(self, cr, uid, ids, field_name, arg, context=None):
288 """ Get the daily statistics of the mass mailing. This is done by a grouping
289 on opened and replied fields. Using custom format in context, we obtain
290 results for the next 6 days following the mass mailing date. """
291 obj = self.pool['mail.mail.statistics']
293 for mailing in self.browse(cr, uid, ids, context=context):
295 date = mailing.sent_date if mailing.sent_date else mailing.create_date
296 date_begin = datetime.strptime(date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
297 date_end = date_begin + relativedelta.relativedelta(days=self._period_number - 1)
298 date_begin_str = date_begin.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
299 date_end_str = date_end.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
300 domain = [('mass_mailing_id', '=', mailing.id), ('opened', '>=', date_begin_str), ('opened', '<=', date_end_str)]
301 res[mailing.id]['opened_daily'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['opened'], 'opened_count', 'opened:day', date_begin, context=context))
302 domain = [('mass_mailing_id', '=', mailing.id), ('replied', '>=', date_begin_str), ('replied', '<=', date_end_str)]
303 res[mailing.id]['replied_daily'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['replied'], 'replied_count', 'replied:day', date_begin, context=context))
306 def _get_statistics(self, cr, uid, ids, name, arg, context=None):
307 """ Compute statistics of the mass mailing """
312 COUNT(s.id) AS total,
313 COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent,
314 COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null THEN 1 ELSE null END) AS scheduled,
315 COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is not null THEN 1 ELSE null END) AS failed,
316 COUNT(CASE WHEN s.sent is not null AND s.bounced is null THEN 1 ELSE null END) AS delivered,
317 COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened,
318 COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied,
319 COUNT(CASE WHEN s.bounced is not null THEN 1 ELSE null END) AS bounced
321 mail_mail_statistics s
324 ON (m.id = s.mass_mailing_id)
330 for row in cr.dictfetchall():
331 results[row.pop('mailing_id')] = row
332 total = row['total'] or 1
333 row['received_ratio'] = 100.0 * row['delivered'] / total
334 row['opened_ratio'] = 100.0 * row['opened'] / total
335 row['replied_ratio'] = 100.0 * row['replied'] / total
338 def _get_mailing_model(self, cr, uid, context=None):
340 for model_name in self.pool:
341 model = self.pool[model_name]
342 if hasattr(model, '_mail_mass_mailing') and getattr(model, '_mail_mass_mailing'):
343 res.append((model._name, getattr(model, '_mail_mass_mailing')))
344 res.append(('mail.mass_mailing.contact', _('Mailing List')))
347 # indirections for inheritance
348 _mailing_model = lambda self, *args, **kwargs: self._get_mailing_model(*args, **kwargs)
351 'name': fields.char('Subject', required=True),
352 'email_from': fields.char('From', required=True),
353 'create_date': fields.datetime('Creation Date'),
354 'sent_date': fields.datetime('Sent Date', oldname='date', copy=False),
355 'body_html': fields.html('Body'),
356 'attachment_ids': fields.many2many(
357 'ir.attachment', 'mass_mailing_ir_attachments_rel',
358 'mass_mailing_id', 'attachment_id', 'Attachments'
360 'mass_mailing_campaign_id': fields.many2one(
361 'mail.mass_mailing.campaign', 'Mass Mailing Campaign',
364 'state': fields.selection(
365 [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')],
366 string='Status', required=True, copy=False,
368 'color': fields.related(
369 'mass_mailing_campaign_id', 'color',
370 type='integer', string='Color Index',
373 'reply_to_mode': fields.selection(
374 [('thread', 'In Document'), ('email', 'Specified Email Address')],
375 string='Reply-To Mode', required=True,
377 'reply_to': fields.char('Reply To', help='Preferred Reply-To Address'),
379 'mailing_model': fields.selection(_mailing_model, string='Recipients Model', required=True),
380 'mailing_domain': fields.char('Domain', oldname='domain'),
381 'contact_list_ids': fields.many2many(
382 'mail.mass_mailing.list', 'mail_mass_mailing_list_rel',
383 string='Mailing Lists',
385 'contact_ab_pc': fields.integer(
386 'AB Testing percentage',
387 help='Percentage of the contacts that will be mailed. Recipients will be taken randomly.'
390 'statistics_ids': fields.one2many(
391 'mail.mail.statistics', 'mass_mailing_id',
394 'total': fields.function(
395 _get_statistics, string='Total',
396 type='integer', multi='_get_statistics',
398 'scheduled': fields.function(
399 _get_statistics, string='Scheduled',
400 type='integer', multi='_get_statistics',
402 'failed': fields.function(
403 _get_statistics, string='Failed',
404 type='integer', multi='_get_statistics',
406 'sent': fields.function(
407 _get_statistics, string='Sent',
408 type='integer', multi='_get_statistics',
410 'delivered': fields.function(
411 _get_statistics, string='Delivered',
412 type='integer', multi='_get_statistics',
414 'opened': fields.function(
415 _get_statistics, string='Opened',
416 type='integer', multi='_get_statistics',
418 'replied': fields.function(
419 _get_statistics, string='Replied',
420 type='integer', multi='_get_statistics',
422 'bounced': fields.function(
423 _get_statistics, string='Bounced',
424 type='integer', multi='_get_statistics',
426 'received_ratio': fields.function(
427 _get_statistics, string='Received Ratio',
428 type='integer', multi='_get_statistics',
430 'opened_ratio': fields.function(
431 _get_statistics, string='Opened Ratio',
432 type='integer', multi='_get_statistics',
434 'replied_ratio': fields.function(
435 _get_statistics, string='Replied Ratio',
436 type='integer', multi='_get_statistics',
439 'opened_daily': fields.function(
440 _get_daily_statistics, string='Opened',
441 type='char', multi='_get_daily_statistics',
443 'replied_daily': fields.function(
444 _get_daily_statistics, string='Replied',
445 type='char', multi='_get_daily_statistics',
449 def default_get(self, cr, uid, fields, context=None):
450 res = super(MassMailing, self).default_get(cr, uid, fields, context=context)
451 if 'reply_to_mode' in fields and not 'reply_to_mode' in res and res.get('mailing_model'):
452 if res['mailing_model'] in ['res.partner', 'mail.mass_mailing.contact']:
453 res['reply_to_mode'] = 'email'
455 res['reply_to_mode'] = 'thread'
460 'email_from': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx),
461 'reply_to': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx),
462 'mailing_model': 'mail.mass_mailing.contact',
463 'contact_ab_pc': 100,
466 #------------------------------------------------------
468 #------------------------------------------------------
470 def copy_data(self, cr, uid, id, default=None, context=None):
471 mailing = self.browse(cr, uid, id, context=context)
472 default = dict(default or {},
473 name=_('%s (copy)') % mailing.name)
474 return super(MassMailing, self).copy_data(cr, uid, id, default, context=context)
476 def read_group(self, cr, uid, domain, fields, groupby, offset=0, limit=None, context=None, orderby=False, lazy=True):
477 """ Override read_group to always display all states. """
478 if groupby and groupby[0] == "state":
479 # Default result structure
480 # states = self._get_state_list(cr, uid, context=context)
481 states = [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')]
482 read_group_all_states = [{
483 '__context': {'group_by': groupby[1:]},
484 '__domain': domain + [('state', '=', state_value)],
485 'state': state_value,
487 } for state_value, state_name in states]
488 # Get standard results
489 read_group_res = super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby)
490 # Update standard results with default results
492 for state_value, state_name in states:
493 res = filter(lambda x: x['state'] == state_value, read_group_res)
495 res = filter(lambda x: x['state'] == state_value, read_group_all_states)
496 res[0]['state'] = [state_value, state_name]
497 result.append(res[0])
500 return super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby)
502 #------------------------------------------------------
504 #------------------------------------------------------
506 def on_change_model_and_list(self, cr, uid, ids, mailing_model, list_ids, context=None):
508 if mailing_model == 'mail.mass_mailing.contact':
509 mailing_list_ids = set()
510 for item in list_ids:
511 if isinstance(item, (int, long)):
512 mailing_list_ids.add(item)
514 mailing_list_ids |= set(item[2])
516 value['mailing_domain'] = "[('list_id', 'in', %s)]" % list(mailing_list_ids)
518 value['mailing_domain'] = "[('list_id', '=', False)]"
520 value['mailing_domain'] = False
521 return {'value': value}
523 def action_duplicate(self, cr, uid, ids, context=None):
526 copy_id = self.copy(cr, uid, mid, context=context)
529 'type': 'ir.actions.act_window',
532 'res_model': 'mail.mass_mailing',
538 def action_test_mailing(self, cr, uid, ids, context=None):
539 ctx = dict(context, default_mass_mailing_id=ids[0])
541 'name': _('Test Mailing'),
542 'type': 'ir.actions.act_window',
544 'res_model': 'mail.mass_mailing.test',
549 def action_edit_html(self, cr, uid, ids, context=None):
550 if not len(ids) == 1:
551 raise ValueError('One and only one ID allowed for this action')
552 mail = self.browse(cr, uid, ids[0], context=context)
553 url = '/website_mail/email_designer?model=mail.mass_mailing&res_id=%d&template_model=%s&enable_editor=1' % (ids[0], mail.mailing_model)
555 'name': _('Open with Visual Editor'),
556 'type': 'ir.actions.act_url',
561 #------------------------------------------------------
563 #------------------------------------------------------
565 def get_recipients(self, cr, uid, mailing, context=None):
566 if mailing.mailing_domain:
567 domain = eval(mailing.mailing_domain)
568 res_ids = self.pool[mailing.mailing_model].search(cr, uid, domain, context=context)
571 domain = [('id', 'in', res_ids)]
573 # randomly choose a fragment
574 if mailing.contact_ab_pc < 100:
575 contact_nbr = self.pool[mailing.mailing_model].search(cr, uid, domain, count=True, context=context)
576 topick = int(contact_nbr / 100.0 * mailing.contact_ab_pc)
577 if mailing.mass_mailing_campaign_id and mailing.mass_mailing_campaign_id.unique_ab_testing:
578 already_mailed = self.pool['mail.mass_mailing.campaign'].get_recipients(cr, uid, [mailing.mass_mailing_campaign_id.id], context=context)[mailing.mass_mailing_campaign_id.id]
580 already_mailed = set([])
581 remaining = set(res_ids).difference(already_mailed)
582 if topick > len(remaining):
583 topick = len(remaining)
584 res_ids = random.sample(remaining, topick)
587 def send_mail(self, cr, uid, ids, context=None):
588 author_id = self.pool['res.users'].browse(cr, uid, uid, context=context).partner_id.id
589 for mailing in self.browse(cr, uid, ids, context=context):
590 # instantiate an email composer + send emails
591 res_ids = self.get_recipients(cr, uid, mailing, context=context)
593 raise Warning('Please select recipients.')
594 comp_ctx = dict(context, active_ids=res_ids)
596 'author_id': author_id,
597 'body': mailing.body_html,
598 'subject': mailing.name,
599 'model': mailing.mailing_model,
600 'email_from': mailing.email_from,
601 'record_name': False,
602 'composition_mode': 'mass_mail',
603 'mass_mailing_id': mailing.id,
604 'mailing_list_ids': [(4, l.id) for l in mailing.contact_list_ids],
605 'no_auto_thread': mailing.reply_to_mode != 'thread',
607 if mailing.reply_to_mode == 'email':
608 composer_values['reply_to'] = mailing.reply_to
609 composer_id = self.pool['mail.compose.message'].create(cr, uid, composer_values, context=comp_ctx)
610 self.pool['mail.compose.message'].send_mail(cr, uid, [composer_id], context=comp_ctx)
611 self.write(cr, uid, [mailing.id], {'sent_date': fields.datetime.now(), 'state': 'done'}, context=context)