summaryrefslogtreecommitdiff
path: root/addons/mass_mailing/models
diff options
context:
space:
mode:
Diffstat (limited to 'addons/mass_mailing/models')
-rw-r--r--addons/mass_mailing/models/__init__.py7
-rw-r--r--addons/mass_mailing/models/mail_mail.py98
-rw-r--r--addons/mass_mailing/models/mail_thread.py87
-rw-r--r--addons/mass_mailing/models/mass_mailing.py571
-rw-r--r--addons/mass_mailing/models/mass_mailing_stats.py109
-rw-r--r--addons/mass_mailing/models/res_config.py15
6 files changed, 887 insertions, 0 deletions
diff --git a/addons/mass_mailing/models/__init__.py b/addons/mass_mailing/models/__init__.py
new file mode 100644
index 00000000000..2cffe48642b
--- /dev/null
+++ b/addons/mass_mailing/models/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+
+import mass_mailing
+import mass_mailing_stats
+import mail_mail
+import mail_thread
+import res_config
diff --git a/addons/mass_mailing/models/mail_mail.py b/addons/mass_mailing/models/mail_mail.py
new file mode 100644
index 00000000000..a67f88a2a28
--- /dev/null
+++ b/addons/mass_mailing/models/mail_mail.py
@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2013-Today OpenERP SA (<http://www.openerp.com>)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+##############################################################################
+
+import urllib
+import urlparse
+
+from openerp import tools
+from openerp import SUPERUSER_ID
+from openerp.osv import osv, fields
+
+
+class MailMail(osv.Model):
+ """Add the mass mailing campaign data to mail"""
+ _name = 'mail.mail'
+ _inherit = ['mail.mail']
+
+ _columns = {
+ 'mailing_id': fields.many2one('mail.mass_mailing', 'Mass Mailing'),
+ 'statistics_ids': fields.one2many(
+ 'mail.mail.statistics', 'mail_mail_id',
+ string='Statistics',
+ ),
+ }
+
+ def create(self, cr, uid, values, context=None):
+ """ Override mail_mail creation to create an entry in mail.mail.statistics """
+ # TDE note: should be after 'all values computed', to have values (FIXME after merging other branch holding create refactoring)
+ mail_id = super(MailMail, self).create(cr, uid, values, context=context)
+ if values.get('statistics_ids'):
+ mail = self.browse(cr, SUPERUSER_ID, mail_id)
+ for stat in mail.statistics_ids:
+ self.pool['mail.mail.statistics'].write(cr, uid, [stat.id], {'message_id': mail.message_id}, context=context)
+ return mail_id
+
+ def _get_tracking_url(self, cr, uid, mail, partner=None, context=None):
+ base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url')
+ track_url = urlparse.urljoin(
+ base_url, 'mail/track/%(mail_id)s/blank.gif?%(params)s' % {
+ 'mail_id': mail.id,
+ 'params': urllib.urlencode({'db': cr.dbname})
+ }
+ )
+ return '<img src="%s" alt=""/>' % track_url
+
+ def _get_unsubscribe_url(self, cr, uid, mail, email_to, msg=None, context=None):
+ base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url')
+ url = urlparse.urljoin(
+ base_url, 'mail/mailing/%(mailing_id)s/unsubscribe?%(params)s' % {
+ 'mailing_id': mail.mailing_id.id,
+ 'params': urllib.urlencode({'db': cr.dbname, 'res_id': mail.res_id, 'email': email_to})
+ }
+ )
+ return '<small><a href="%s">%s</a></small>' % (url, msg or 'Click to unsubscribe')
+
+ def send_get_mail_body(self, cr, uid, mail, partner=None, context=None):
+ """ Override to add the tracking URL to the body. """
+ body = super(MailMail, self).send_get_mail_body(cr, uid, mail, partner=partner, context=context)
+
+ # generate tracking URL
+ if mail.statistics_ids:
+ tracking_url = self._get_tracking_url(cr, uid, mail, partner, context=context)
+ if tracking_url:
+ body = tools.append_content_to_html(body, tracking_url, plaintext=False, container_tag='div')
+ return body
+
+ def send_get_email_dict(self, cr, uid, mail, partner=None, context=None):
+ res = super(MailMail, self).send_get_email_dict(cr, uid, mail, partner, context=context)
+ if mail.mailing_id and res.get('body') and res.get('email_to'):
+ email_to = tools.email_split(res.get('email_to')[0])
+ unsubscribe_url = self._get_unsubscribe_url(cr, uid, mail, email_to, context=context)
+ if unsubscribe_url:
+ res['body'] = tools.append_content_to_html(res['body'], unsubscribe_url, plaintext=False, container_tag='p')
+ return res
+
+ def _postprocess_sent_message(self, cr, uid, mail, context=None, mail_sent=True):
+ if mail_sent is True and mail.statistics_ids:
+ self.pool['mail.mail.statistics'].write(cr, uid, [s.id for s in mail.statistics_ids], {'sent': fields.datetime.now()}, context=context)
+ elif mail_sent is False and mail.statistics_ids:
+ self.pool['mail.mail.statistics'].write(cr, uid, [s.id for s in mail.statistics_ids], {'exception': fields.datetime.now()}, context=context)
+ return super(MailMail, self)._postprocess_sent_message(cr, uid, mail, context=context, mail_sent=mail_sent)
diff --git a/addons/mass_mailing/models/mail_thread.py b/addons/mass_mailing/models/mail_thread.py
new file mode 100644
index 00000000000..524341f88d7
--- /dev/null
+++ b/addons/mass_mailing/models/mail_thread.py
@@ -0,0 +1,87 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2013-Today OpenERP SA (<http://www.openerp.com>)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+##############################################################################
+
+import logging
+
+from openerp import tools
+from openerp.addons.mail.mail_message import decode
+from openerp.addons.mail.mail_thread import decode_header
+from openerp.osv import osv
+
+_logger = logging.getLogger(__name__)
+
+
+class MailThread(osv.AbstractModel):
+ """ Update MailThread to add the feature of bounced emails and replied emails
+ in message_process. """
+ _name = 'mail.thread'
+ _inherit = ['mail.thread']
+
+ def message_route_check_bounce(self, cr, uid, message, context=None):
+ """ Override to verify that the email_to is the bounce alias. If it is the
+ case, log the bounce, set the parent and related document as bounced and
+ return False to end the routing process. """
+ bounce_alias = self.pool['ir.config_parameter'].get_param(cr, uid, "mail.bounce.alias", context=context)
+ message_id = message.get('Message-Id')
+ email_from = decode_header(message, 'From')
+ email_to = decode_header(message, 'To')
+
+ # 0. Verify whether this is a bounced email (wrong destination,...) -> use it to collect data, such as dead leads
+ if bounce_alias in email_to:
+ bounce_match = tools.bounce_re.search(email_to)
+ if bounce_match:
+ bounced_model, bounced_thread_id = None, False
+ bounced_mail_id = bounce_match.group(1)
+ stat_ids = self.pool['mail.mail.statistics'].set_bounced(cr, uid, mail_mail_ids=[bounced_mail_id], context=context)
+ for stat in self.pool['mail.mail.statistics'].browse(cr, uid, stat_ids, context=context):
+ bounced_model = stat.model
+ bounced_thread_id = stat.res_id
+ _logger.info('Routing mail from %s to %s with Message-Id %s: bounced mail from mail %s, model: %s, thread_id: %s',
+ email_from, email_to, message_id, bounced_mail_id, bounced_model, bounced_thread_id)
+ if bounced_model and bounced_model in self.pool and hasattr(self.pool[bounced_model], 'message_receive_bounce') and bounced_thread_id:
+ self.pool[bounced_model].message_receive_bounce(cr, uid, [bounced_thread_id], mail_id=bounced_mail_id, context=context)
+ return False
+
+ return True
+
+ def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
+ custom_values=None, context=None):
+ if not self.message_route_check_bounce(cr, uid, message, context=context):
+ return []
+ return super(MailThread, self).message_route(cr, uid, message, message_dict, model, thread_id, custom_values, context)
+
+ def message_receive_bounce(self, cr, uid, ids, mail_id=None, context=None):
+ """Called by ``message_process`` when a bounce email (such as Undelivered
+ Mail Returned to Sender) is received for an existing thread. The default
+ behavior is to check is an integer ``message_bounce`` column exists.
+ If it is the case, its content is incremented. """
+ if self._all_columns.get('message_bounce'):
+ for obj in self.browse(cr, uid, ids, context=context):
+ self.write(cr, uid, [obj.id], {'message_bounce': obj.message_bounce + 1}, context=context)
+
+ def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
+ """ Override to update the parent mail statistics. The parent is found
+ by using the References header of the incoming message and looking for
+ matching message_id in mail.mail.statistics. """
+ if message.get('References'):
+ message_ids = [x.strip() for x in decode(message['References']).split()]
+ self.pool['mail.mail.statistics'].set_replied(cr, uid, mail_message_ids=message_ids, context=context)
+ return super(MailThread, self).message_route_process(cr, uid, message, message_dict, routes, context=context)
diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py
new file mode 100644
index 00000000000..bd314f03a3d
--- /dev/null
+++ b/addons/mass_mailing/models/mass_mailing.py
@@ -0,0 +1,571 @@
+# -*- coding: utf-8 -*-
+
+from datetime import datetime
+from dateutil import relativedelta
+import json
+import random
+import urllib
+import urlparse
+
+from openerp import tools
+from openerp.tools.safe_eval import safe_eval as eval
+from openerp.tools.translate import _
+from openerp.osv import osv, fields
+
+
+class MassMailingCategory(osv.Model):
+ """Model of categories of mass mailing, i.e. marketing, newsletter, ... """
+ _name = 'mail.mass_mailing.category'
+ _description = 'Mass Mailing Category'
+ _order = 'name'
+
+ _columns = {
+ 'name': fields.char('Name', required=True),
+ }
+
+
+class MassMailingContact(osv.Model):
+ """Model of a contact. This model is different from the partner model
+ because it holds only some basic information: name, email. The purpose is to
+ be able to deal with large contact list to email without bloating the partner
+ base."""
+ _name = 'mail.mass_mailing.contact'
+ _description = 'Mass Mailing Contact'
+ _order = 'email'
+ _rec_name = 'email'
+
+ _columns = {
+ 'name': fields.char('Name'),
+ 'email': fields.char('Email', required=True),
+ 'create_date': fields.datetime('Create Date'),
+ 'list_id': fields.many2one(
+ 'mail.mass_mailing.list', string='Mailing List',
+ ondelete='cascade', required=True,
+ ),
+ 'opt_out': fields.boolean('Opt Out', help='The contact has chosen not to receive mails anymore from this list'),
+ }
+
+ def _get_latest_list(self, cr, uid, context={}):
+ lid = self.pool.get('mail.mass_mailing.list').search(cr, uid, [], limit=1, order='id desc', context=context)
+ return lid and lid[0] or False
+
+ _defaults = {
+ 'list_id': _get_latest_list
+ }
+
+ def name_create(self, cr, uid, name, context=None):
+ name, email = self.pool['res.partner']._parse_partner_name(name, context=context)
+ if name and not email:
+ email = name
+ if email and not name:
+ name = email
+ rec_id = self.create(cr, uid, {'name': name, 'email': email}, context=context)
+ return self.name_get(cr, uid, [rec_id], context)[0]
+
+
+class MassMailingList(osv.Model):
+ """Model of a contact list. """
+ _name = 'mail.mass_mailing.list'
+ _order = 'name'
+ _description = 'Mailing List'
+
+ def _get_contact_nbr(self, cr, uid, ids, name, arg, context=None):
+ result = dict.fromkeys(ids, 0)
+ Contacts = self.pool.get('mail.mass_mailing.contact')
+ for group in Contacts.read_group(cr, uid, [('list_id', 'in', ids), ('opt_out', '!=', True)], ['list_id'], ['list_id'], context=context):
+ result[group['list_id'][0]] = group['list_id_count']
+ return result
+
+ _columns = {
+ 'name': fields.char('Mailing List', required=True),
+ 'contact_nbr': fields.function(
+ _get_contact_nbr, type='integer',
+ string='Number of Contacts',
+ ),
+ }
+
+
+class MassMailingStage(osv.Model):
+ """Stage for mass mailing campaigns. """
+ _name = 'mail.mass_mailing.stage'
+ _description = 'Mass Mailing Campaign Stage'
+ _order = 'sequence'
+
+ _columns = {
+ 'name': fields.char('Name', required=True, translate=True),
+ 'sequence': fields.integer('Sequence'),
+ }
+
+ _defaults = {
+ 'sequence': 0,
+ }
+
+
+class MassMailingCampaign(osv.Model):
+ """Model of mass mailing campaigns. """
+ _name = "mail.mass_mailing.campaign"
+ _description = 'Mass Mailing Campaign'
+
+ def _get_statistics(self, cr, uid, ids, name, arg, context=None):
+ """ Compute statistics of the mass mailing campaign """
+ Statistics = self.pool['mail.mail.statistics']
+ results = dict.fromkeys(ids, False)
+ for cid in ids:
+ stat_ids = Statistics.search(cr, uid, [('mass_mailing_campaign_id', '=', cid)], context=context)
+ stats = Statistics.browse(cr, uid, stat_ids, context=context)
+ results[cid] = {
+ 'total': len(stats),
+ 'failed': len([s for s in stats if not s.scheduled is False and s.sent is False and not s.exception is False]),
+ 'scheduled': len([s for s in stats if not s.scheduled is False and s.sent is False and s.exception is False]),
+ 'sent': len([s for s in stats if not s.sent is False]),
+ 'opened': len([s for s in stats if not s.opened is False]),
+ 'replied': len([s for s in stats if not s.replied is False]),
+ 'bounced': len([s for s in stats if not s.bounced is False]),
+ }
+ results[cid]['delivered'] = results[cid]['sent'] - results[cid]['bounced']
+ results[cid]['received_ratio'] = 100.0 * results[cid]['delivered'] / (results[cid]['total'] or 1)
+ results[cid]['opened_ratio'] = 100.0 * results[cid]['opened'] / (results[cid]['total'] or 1)
+ results[cid]['replied_ratio'] = 100.0 * results[cid]['replied'] / (results[cid]['total'] or 1)
+ return results
+
+ _columns = {
+ 'name': fields.char('Name', required=True),
+ 'stage_id': fields.many2one('mail.mass_mailing.stage', 'Stage', required=True),
+ 'user_id': fields.many2one(
+ 'res.users', 'Responsible',
+ required=True,
+ ),
+ 'category_ids': fields.many2many(
+ 'mail.mass_mailing.category', 'mail_mass_mailing_category_rel',
+ 'category_id', 'campaign_id', string='Categories'),
+ 'mass_mailing_ids': fields.one2many(
+ 'mail.mass_mailing', 'mass_mailing_campaign_id',
+ 'Mass Mailings',
+ ),
+ 'unique_ab_testing': fields.boolean(
+ 'AB Testing',
+ help='If checked, recipients will be mailed only once, allowing to send'
+ 'various mailings in a single campaign to test the effectiveness'
+ 'of the mailings.'),
+ 'color': fields.integer('Color Index'),
+ # stat fields
+ 'total': fields.function(
+ _get_statistics, string='Total',
+ type='integer', multi='_get_statistics'
+ ),
+ 'scheduled': fields.function(
+ _get_statistics, string='Scheduled',
+ type='integer', multi='_get_statistics'
+ ),
+ 'failed': fields.function(
+ _get_statistics, string='Failed',
+ type='integer', multi='_get_statistics'
+ ),
+ 'sent': fields.function(
+ _get_statistics, string='Sent Emails',
+ type='integer', multi='_get_statistics'
+ ),
+ 'delivered': fields.function(
+ _get_statistics, string='Delivered',
+ type='integer', multi='_get_statistics',
+ ),
+ 'opened': fields.function(
+ _get_statistics, string='Opened',
+ type='integer', multi='_get_statistics',
+ ),
+ 'replied': fields.function(
+ _get_statistics, string='Replied',
+ type='integer', multi='_get_statistics'
+ ),
+ 'bounced': fields.function(
+ _get_statistics, string='Bounced',
+ type='integer', multi='_get_statistics'
+ ),
+ 'received_ratio': fields.function(
+ _get_statistics, string='Received Ratio',
+ type='integer', multi='_get_statistics',
+ ),
+ 'opened_ratio': fields.function(
+ _get_statistics, string='Opened Ratio',
+ type='integer', multi='_get_statistics',
+ ),
+ 'replied_ratio': fields.function(
+ _get_statistics, string='Replied Ratio',
+ type='integer', multi='_get_statistics',
+ ),
+ }
+
+ def _get_default_stage_id(self, cr, uid, context=None):
+ stage_ids = self.pool['mail.mass_mailing.stage'].search(cr, uid, [], limit=1, context=context)
+ return stage_ids and stage_ids[0] or False
+
+ _defaults = {
+ 'user_id': lambda self, cr, uid, ctx=None: uid,
+ 'stage_id': lambda self, *args: self._get_default_stage_id(*args),
+ }
+
+ def get_recipients(self, cr, uid, ids, model=None, context=None):
+ """Return the recipients of a mailing campaign. This is based on the statistics
+ build for each mailing. """
+ Statistics = self.pool['mail.mail.statistics']
+ res = dict.fromkeys(ids, False)
+ for cid in ids:
+ domain = [('mass_mailing_campaign_id', '=', cid)]
+ if model:
+ domain += [('model', '=', model)]
+ stat_ids = Statistics.search(cr, uid, domain, context=context)
+ res[cid] = set(stat.res_id for stat in Statistics.browse(cr, uid, stat_ids, context=context))
+ return res
+
+
+class MassMailing(osv.Model):
+ """ MassMailing models a wave of emails for a mass mailign campaign.
+ A mass mailing is an occurence of sending emails. """
+
+ _name = 'mail.mass_mailing'
+ _description = 'Mass Mailing'
+ # number of periods for tracking mail_mail statistics
+ _period_number = 6
+ _order = 'sent_date DESC'
+
+ def __get_bar_values(self, cr, uid, obj, domain, read_fields, value_field, groupby_field, date_begin, context=None):
+ """ Generic method to generate data for bar chart values using SparklineBarWidget.
+ This method performs obj.read_group(cr, uid, domain, read_fields, groupby_field).
+
+ :param obj: the target model (i.e. crm_lead)
+ :param domain: the domain applied to the read_group
+ :param list read_fields: the list of fields to read in the read_group
+ :param str value_field: the field used to compute the value of the bar slice
+ :param str groupby_field: the fields used to group
+
+ :return list section_result: a list of dicts: [
+ { 'value': (int) bar_column_value,
+ 'tootip': (str) bar_column_tooltip,
+ }
+ ]
+ """
+ date_begin = date_begin.date()
+ section_result = [{'value': 0,
+ 'tooltip': (date_begin + relativedelta.relativedelta(days=i)).strftime('%d %B %Y'),
+ } for i in range(0, self._period_number)]
+ group_obj = obj.read_group(cr, uid, domain, read_fields, groupby_field, context=context)
+ field_col_info = obj._all_columns.get(groupby_field.split(':')[0])
+ pattern = tools.DEFAULT_SERVER_DATE_FORMAT if field_col_info.column._type == 'date' else tools.DEFAULT_SERVER_DATETIME_FORMAT
+ for group in group_obj:
+ group_begin_date = datetime.strptime(group['__domain'][0][2], pattern).date()
+ timedelta = relativedelta.relativedelta(group_begin_date, date_begin)
+ section_result[timedelta.days] = {'value': group.get(value_field, 0), 'tooltip': group.get(groupby_field)}
+ return section_result
+
+ def _get_daily_statistics(self, cr, uid, ids, field_name, arg, context=None):
+ """ Get the daily statistics of the mass mailing. This is done by a grouping
+ on opened and replied fields. Using custom format in context, we obtain
+ results for the next 6 days following the mass mailing date. """
+ obj = self.pool['mail.mail.statistics']
+ res = {}
+ for mailing in self.browse(cr, uid, ids, context=context):
+ res[mailing.id] = {}
+ date = mailing.sent_date if mailing.sent_date else mailing.create_date
+ date_begin = datetime.strptime(date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
+ date_end = date_begin + relativedelta.relativedelta(days=self._period_number - 1)
+ date_begin_str = date_begin.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
+ date_end_str = date_end.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
+ domain = [('mass_mailing_id', '=', mailing.id), ('opened', '>=', date_begin_str), ('opened', '<=', date_end_str)]
+ res[mailing.id]['opened_dayly'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['opened'], 'opened_count', 'opened:day', date_begin, context=context))
+ domain = [('mass_mailing_id', '=', mailing.id), ('replied', '>=', date_begin_str), ('replied', '<=', date_end_str)]
+ res[mailing.id]['replied_dayly'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['replied'], 'replied_count', 'replied:day', date_begin, context=context))
+ return res
+
+ def _get_statistics(self, cr, uid, ids, name, arg, context=None):
+ """ Compute statistics of the mass mailing campaign """
+ Statistics = self.pool['mail.mail.statistics']
+ results = dict.fromkeys(ids, False)
+ for mid in ids:
+ stat_ids = Statistics.search(cr, uid, [('mass_mailing_id', '=', mid)], context=context)
+ stats = Statistics.browse(cr, uid, stat_ids, context=context)
+ results[mid] = {
+ 'total': len(stats),
+ 'failed': len([s for s in stats if not s.scheduled is False and s.sent is False and not s.exception is False]),
+ 'scheduled': len([s for s in stats if not s.scheduled is False and s.sent is False and s.exception is False]),
+ 'sent': len([s for s in stats if not s.sent is False]),
+ 'opened': len([s for s in stats if not s.opened is False]),
+ 'replied': len([s for s in stats if not s.replied is False]),
+ 'bounced': len([s for s in stats if not s.bounced is False]),
+ }
+ results[mid]['delivered'] = results[mid]['sent'] - results[mid]['bounced']
+ results[mid]['received_ratio'] = 100.0 * results[mid]['delivered'] / (results[mid]['total'] or 1)
+ results[mid]['opened_ratio'] = 100.0 * results[mid]['opened'] / (results[mid]['total'] or 1)
+ results[mid]['replied_ratio'] = 100.0 * results[mid]['replied'] / (results[mid]['total'] or 1)
+ return results
+
+ def _get_mailing_model(self, cr, uid, context=None):
+ res = []
+ for model_name in self.pool:
+ model = self.pool[model_name]
+ if hasattr(model, '_mail_mass_mailing') and getattr(model, '_mail_mass_mailing'):
+ res.append((model._name, getattr(model, '_mail_mass_mailing')))
+ res.append(('mail.mass_mailing.contact', _('Mailing List')))
+ return res
+
+ # indirections for inheritance
+ _mailing_model = lambda self, *args, **kwargs: self._get_mailing_model(*args, **kwargs)
+
+ _columns = {
+ 'name': fields.char('Subject', required=True),
+ 'email_from': fields.char('From', required=True),
+ 'create_date': fields.datetime('Creation Date'),
+ 'sent_date': fields.datetime('Sent Date'),
+ 'body_html': fields.html('Body'),
+ 'attachment_ids': fields.many2many(
+ 'ir.attachment', 'mass_mailing_ir_attachments_rel',
+ 'mass_mailing_id', 'attachment_id', 'Attachments'
+ ),
+ 'mass_mailing_campaign_id': fields.many2one(
+ 'mail.mass_mailing.campaign', 'Mass Mailing Campaign',
+ ondelete='set null',
+ ),
+ 'state': fields.selection(
+ [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')],
+ string='Status', required=True,
+ ),
+ 'color': fields.related(
+ 'mass_mailing_campaign_id', 'color',
+ type='integer', string='Color Index',
+ ),
+ # mailing options
+ 'reply_to_mode': fields.selection(
+ [('thread', 'In Document'), ('email', 'Specified Email Address')],
+ string='Reply-To Mode', required=True,
+ ),
+ 'reply_to': fields.char('Reply To', help='Preferred Reply-To Address'),
+ # recipients
+ 'mailing_model': fields.selection(_mailing_model, string='Recipients Model', required=True),
+ 'mailing_domain': fields.char('Domain'),
+ 'contact_list_ids': fields.many2many(
+ 'mail.mass_mailing.list', 'mail_mass_mailing_list_rel',
+ string='Mailing Lists',
+ ),
+ 'contact_ab_pc': fields.integer(
+ 'AB Testing percentage',
+ help='Percentage of the contacts that will be mailed. Recipients will be taken randomly.'
+ ),
+ # statistics data
+ 'statistics_ids': fields.one2many(
+ 'mail.mail.statistics', 'mass_mailing_id',
+ 'Emails Statistics',
+ ),
+ 'total': fields.function(
+ _get_statistics, string='Total',
+ type='integer', multi='_get_statistics',
+ ),
+ 'scheduled': fields.function(
+ _get_statistics, string='Scheduled',
+ type='integer', multi='_get_statistics',
+ ),
+ 'failed': fields.function(
+ _get_statistics, string='Failed',
+ type='integer', multi='_get_statistics',
+ ),
+ 'sent': fields.function(
+ _get_statistics, string='Sent',
+ type='integer', multi='_get_statistics',
+ ),
+ 'delivered': fields.function(
+ _get_statistics, string='Delivered',
+ type='integer', multi='_get_statistics',
+ ),
+ 'opened': fields.function(
+ _get_statistics, string='Opened',
+ type='integer', multi='_get_statistics',
+ ),
+ 'replied': fields.function(
+ _get_statistics, string='Replied',
+ type='integer', multi='_get_statistics',
+ ),
+ 'bounced': fields.function(
+ _get_statistics, string='Bounced',
+ type='integer', multi='_get_statistics',
+ ),
+ 'received_ratio': fields.function(
+ _get_statistics, string='Received Ratio',
+ type='integer', multi='_get_statistics',
+ ),
+ 'opened_ratio': fields.function(
+ _get_statistics, string='Opened Ratio',
+ type='integer', multi='_get_statistics',
+ ),
+ 'replied_ratio': fields.function(
+ _get_statistics, string='Replied Ratio',
+ type='integer', multi='_get_statistics',
+ ),
+ # dayly ratio
+ 'opened_dayly': fields.function(
+ _get_daily_statistics, string='Opened',
+ type='char', multi='_get_daily_statistics',
+ oldname='opened_monthly',
+ ),
+ 'replied_dayly': fields.function(
+ _get_daily_statistics, string='Replied',
+ type='char', multi='_get_daily_statistics',
+ oldname='replied_monthly',
+ )
+ }
+
+ def default_get(self, cr, uid, fields, context=None):
+ res = super(MassMailing, self).default_get(cr, uid, fields, context=context)
+ if 'reply_to_mode' in fields and not 'reply_to_mode' in res and res.get('mailing_model'):
+ if res['mailing_model'] in ['res.partner', 'mail.mass_mailing.contact']:
+ res['reply_to_mode'] = 'email'
+ else:
+ res['reply_to_mode'] = 'thread'
+ return res
+
+ _defaults = {
+ 'state': 'draft',
+ 'email_from': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx),
+ 'reply_to': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx),
+ 'mailing_model': 'mail.mass_mailing.contact',
+ 'contact_ab_pc': 100,
+ }
+
+ #------------------------------------------------------
+ # Technical stuff
+ #------------------------------------------------------
+
+ def copy_data(self, cr, uid, id, default=None, context=None):
+ if default is None:
+ default = {}
+ mailing = self.browse(cr, uid, id, context=context)
+ default.update({
+ 'state': 'draft',
+ 'statistics_ids': [],
+ 'name': _('%s (duplicate)') % mailing.name,
+ 'sent_date': False,
+ })
+ return super(MassMailing, self).copy_data(cr, uid, id, default, context=context)
+
+ def read_group(self, cr, uid, domain, fields, groupby, offset=0, limit=None, context=None, orderby=False, lazy=True):
+ """ Override read_group to always display all states. """
+ if groupby and groupby[0] == "state":
+ # Default result structure
+ # states = self._get_state_list(cr, uid, context=context)
+ states = [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')]
+ read_group_all_states = [{
+ '__context': {'group_by': groupby[1:]},
+ '__domain': domain + [('state', '=', state_value)],
+ 'state': state_value,
+ 'state_count': 0,
+ } for state_value, state_name in states]
+ # Get standard results
+ read_group_res = super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby)
+ # Update standard results with default results
+ result = []
+ for state_value, state_name in states:
+ res = filter(lambda x: x['state'] == state_value, read_group_res)
+ if not res:
+ res = filter(lambda x: x['state'] == state_value, read_group_all_states)
+ res[0]['state'] = [state_value, state_name]
+ result.append(res[0])
+ return result
+ else:
+ return super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby)
+
+ #------------------------------------------------------
+ # Views & Actions
+ #------------------------------------------------------
+
+ def on_change_model_and_list(self, cr, uid, ids, mailing_model, list_ids, context=None):
+ value = {}
+ if mailing_model == 'mail.mass_mailing.contact':
+ list_ids = map(lambda item: item if isinstance(item, (int, long)) else [lid for lid in item[2]], list_ids)
+ if list_ids:
+ value['mailing_domain'] = "[('list_id', 'in', %s)]" % list_ids
+ else:
+ value['mailing_domain'] = "[('list_id', '=', False)]"
+ else:
+ value['mailing_domain'] = False
+ return {'value': value}
+
+ def action_duplicate(self, cr, uid, ids, context=None):
+ copy_id = None
+ for mid in ids:
+ copy_id = self.copy(cr, uid, mid, context=context)
+ if copy_id:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'mail.mass_mailing',
+ 'res_id': copy_id,
+ 'context': context,
+ }
+ return False
+
+ def action_test_mailing(self, cr, uid, ids, context=None):
+ ctx = dict(context, default_mass_mailing_id=ids[0])
+ return {
+ 'name': _('Test Mailing'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'mail.mass_mailing.test',
+ 'target': 'new',
+ 'context': ctx,
+ }
+
+ def action_edit_html(self, cr, uid, ids, context=None):
+ if not len(ids) == 1:
+ raise ValueError('One and only one ID allowed for this action')
+ mail = self.browse(cr, uid, ids[0], context=context)
+ url = '/website_mail/email_designer?model=mail.mass_mailing&res_id=%d&template_model=%s&enable_editor=1' % (ids[0], mail.mailing_model)
+ return {
+ 'name': _('Open with Visual Editor'),
+ 'type': 'ir.actions.act_url',
+ 'url': url,
+ 'target': 'self',
+ }
+
+ #------------------------------------------------------
+ # Email Sending
+ #------------------------------------------------------
+
+ def get_recipients(self, cr, uid, mailing, context=None):
+ domain = eval(mailing.mailing_domain)
+ res_ids = self.pool[mailing.mailing_model].search(cr, uid, domain, context=context)
+
+ # randomly choose a fragment
+ if mailing.contact_ab_pc < 100:
+ contact_nbr = self.pool[mailing.mailing_model].search(cr, uid, domain, count=True, context=context)
+ topick = int(contact_nbr / 100.0 * mailing.contact_ab_pc)
+ if mailing.mass_mailing_campaign_id and mailing.mass_mailing_campaign_id.unique_ab_testing:
+ 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]
+ else:
+ already_mailed = set([])
+ remaining = set(res_ids).difference(already_mailed)
+ if topick > len(remaining):
+ topick = len(remaining)
+ res_ids = random.sample(remaining, topick)
+ return res_ids
+
+ def send_mail(self, cr, uid, ids, context=None):
+ author_id = self.pool['res.users'].browse(cr, uid, uid, context=context).partner_id.id
+ for mailing in self.browse(cr, uid, ids, context=context):
+ # instantiate an email composer + send emails
+ res_ids = self.get_recipients(cr, uid, mailing, context=context)
+ comp_ctx = dict(context, active_ids=res_ids)
+ composer_values = {
+ 'author_id': author_id,
+ 'body': mailing.body_html,
+ 'subject': mailing.name,
+ 'model': mailing.mailing_model,
+ 'email_from': mailing.email_from,
+ 'record_name': False,
+ 'composition_mode': 'mass_mail',
+ 'mass_mailing_id': mailing.id,
+ 'mailing_list_ids': [(4, l.id) for l in mailing.contact_list_ids],
+ }
+ if mailing.reply_to_mode == 'email':
+ composer_values['reply_to'] = mailing.reply_to
+ composer_id = self.pool['mail.compose.message'].create(cr, uid, composer_values, context=comp_ctx)
+ self.pool['mail.compose.message'].send_mail(cr, uid, [composer_id], context=comp_ctx)
+ self.write(cr, uid, [mailing.id], {'sent_date': fields.datetime.now(), 'state': 'done'}, context=context)
+ return True
diff --git a/addons/mass_mailing/models/mass_mailing_stats.py b/addons/mass_mailing/models/mass_mailing_stats.py
new file mode 100644
index 00000000000..319a6fde14d
--- /dev/null
+++ b/addons/mass_mailing/models/mass_mailing_stats.py
@@ -0,0 +1,109 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2013-today OpenERP SA (<http://www.openerp.com>)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+##############################################################################
+
+from datetime import datetime
+from dateutil import relativedelta
+import random
+try:
+ import simplejson as json
+except ImportError:
+ import json
+import urllib
+import urlparse
+
+from openerp import tools
+from openerp.exceptions import Warning
+from openerp.tools.safe_eval import safe_eval as eval
+from openerp.tools.translate import _
+from openerp.osv import osv, fields
+
+
+class MailMailStats(osv.Model):
+ """ MailMailStats models the statistics collected about emails. Those statistics
+ are stored in a separated model and table to avoid bloating the mail_mail table
+ with statistics values. This also allows to delete emails send with mass mailing
+ without loosing the statistics about them. """
+
+ _name = 'mail.mail.statistics'
+ _description = 'Email Statistics'
+ _rec_name = 'message_id'
+ _order = 'message_id'
+
+ _columns = {
+ 'mail_mail_id': fields.integer(
+ 'Mail ID',
+ help='ID of the related mail_mail. This field is an integer field because'
+ 'the related mail_mail can be deleted separately from its statistics.'
+ ),
+ 'message_id': fields.char('Message-ID'),
+ 'model': fields.char('Document model'),
+ 'res_id': fields.integer('Document ID'),
+ # campaign / wave data
+ 'mass_mailing_id': fields.many2one(
+ 'mail.mass_mailing', 'Mass Mailing',
+ ondelete='set null',
+ ),
+ 'mass_mailing_campaign_id': fields.related(
+ 'mass_mailing_id', 'mass_mailing_campaign_id',
+ type='many2one', ondelete='set null',
+ relation='mail.mass_mailing.campaign',
+ string='Mass Mailing Campaign',
+ store=True, readonly=True,
+ ),
+ # Bounce and tracking
+ 'scheduled': fields.datetime('Scheduled', help='Date when the email has been created'),
+ 'sent': fields.datetime('Sent', help='Date when the email has been sent'),
+ 'exception': fields.datetime('Exception', help='Date of technical error leading to the email not being sent'),
+ 'opened': fields.datetime('Opened', help='Date when the email has been opened the first time'),
+ 'replied': fields.datetime('Replied', help='Date when this email has been replied for the first time.'),
+ 'bounced': fields.datetime('Bounced', help='Date when this email has bounced.'),
+ }
+
+ _defaults = {
+ 'scheduled': fields.datetime.now,
+ }
+
+ def _get_ids(self, cr, uid, ids=None, mail_mail_ids=None, mail_message_ids=None, domain=None, context=None):
+ if not ids and mail_mail_ids:
+ base_domain = [('mail_mail_id', 'in', mail_mail_ids)]
+ elif not ids and mail_message_ids:
+ base_domain = [('message_id', 'in', mail_message_ids)]
+ else:
+ base_domain = [('id', 'in', ids or [])]
+ if domain:
+ base_domain = ['&'] + domain + base_domain
+ return self.search(cr, uid, base_domain, context=context)
+
+ def set_opened(self, cr, uid, ids=None, mail_mail_ids=None, mail_message_ids=None, context=None):
+ stat_ids = self._get_ids(cr, uid, ids, mail_mail_ids, mail_message_ids, [('opened', '=', False)], context)
+ self.write(cr, uid, stat_ids, {'opened': fields.datetime.now()}, context=context)
+ return stat_ids
+
+ def set_replied(self, cr, uid, ids=None, mail_mail_ids=None, mail_message_ids=None, context=None):
+ stat_ids = self._get_ids(cr, uid, ids, mail_mail_ids, mail_message_ids, [('replied', '=', False)], context)
+ self.write(cr, uid, stat_ids, {'replied': fields.datetime.now()}, context=context)
+ return stat_ids
+
+ def set_bounced(self, cr, uid, ids=None, mail_mail_ids=None, mail_message_ids=None, context=None):
+ stat_ids = self._get_ids(cr, uid, ids, mail_mail_ids, mail_message_ids, [('bounced', '=', False)], context)
+ self.write(cr, uid, stat_ids, {'bounced': fields.datetime.now()}, context=context)
+ return stat_ids
+
diff --git a/addons/mass_mailing/models/res_config.py b/addons/mass_mailing/models/res_config.py
new file mode 100644
index 00000000000..4b3757b7dca
--- /dev/null
+++ b/addons/mass_mailing/models/res_config.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+
+from openerp.osv import fields, osv
+
+
+class MassMailingConfiguration(osv.TransientModel):
+ _name = 'marketing.config.settings'
+ _inherit = 'marketing.config.settings'
+
+ _columns = {
+ 'group_mass_mailing_campaign': fields.boolean(
+ 'Manage Mass Mailing using Campaign',
+ implied_group='mass_mailing.group_mass_mailing_campaign',
+ help="""Manage mass mailign using Campaigns"""),
+ }