[FIX]mass mailing: performance issue on stats #469
[odoo/odoo.git] / addons / mass_mailing / mass_mailing.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2013-today OpenERP SA (<http://www.openerp.com>)
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>
19 #
20 ##############################################################################
21
22 from datetime import datetime
23 from dateutil import relativedelta
24
25 from openerp import tools
26 from openerp.tools.translate import _
27 from openerp.osv import osv, fields
28
29
30 class MassMailingCampaign(osv.Model):
31     """Model of mass mailing campaigns.
32     """
33     _name = "mail.mass_mailing.campaign"
34     _description = 'Mass Mailing Campaign'
35     # number of embedded mailings in kanban view
36     _kanban_mailing_nbr = 4
37
38     def _get_statistics(self, cr, uid, ids, name, arg, context=None):
39         """ Compute statistics of the mass mailing campaign """
40         results = dict.fromkeys(ids, {
41                 'sent': 0,
42                 'delivered': 0,
43                 'opened': 0,
44                 'replied': 0,
45                 'bounced': 0,
46             })
47         cr.execute("""
48             SELECT
49                 mass_mailing_id,
50                 COUNT(id) AS sent,
51                 COUNT(CASE WHEN bounced is null THEN 1 ELSE null END) AS delivered,
52                 COUNT(CASE WHEN opened is not null THEN 1 ELSE null END) AS opened,
53                 COUNT(CASE WHEN replied is not null THEN 1 ELSE null END) AS replied ,
54                 COUNT(CASE WHEN bounced is not null THEN 1 ELSE null END) AS bounced
55             FROM
56                 mail_mail_statistics
57             WHERE
58                 mass_mailing_id IN %s
59             GROUP BY
60                  mass_mailing_id
61         """, (tuple(ids), ))
62         for (campaign_id, sent, delivered, opened, replied, bounced) in cr.fetchall():
63             results[campaign_id] = {
64                 'sent': sent,
65                 # delivered: shouldn't be: all mails - (failed + bounced) ?
66                 'delivered': delivered,
67                 'opened': opened,
68                 'replied': replied,
69                 'bounced': bounced,
70             }
71         return results
72
73     def _get_mass_mailing_kanban_ids(self, cr, uid, ids, name, arg, context=None):
74         """ Gather data about mass mailings to display them in kanban view as
75         nested kanban views is not possible currently. """
76         results = dict.fromkeys(ids, '')
77         for campaign_id in ids:
78             mass_mailing_results = []
79             mass_mailing_results = self.pool['mail.mass_mailing'].search_read(cr, uid,
80                             domain=[('mass_mailing_campaign_id', '=', campaign_id)],
81                             fields=['name', 'sent', 'delivered', 'opened', 'replied', 'bounced'],
82                             limit=self._kanban_mailing_nbr,
83                             context=context)
84             results[campaign_id] = mass_mailing_results
85         return results
86
87     _columns = {
88         'name': fields.char(
89             'Campaign Name', required=True,
90         ),
91         'user_id': fields.many2one(
92             'res.users', 'Responsible',
93             required=True,
94         ),
95         'mass_mailing_ids': fields.one2many(
96             'mail.mass_mailing', 'mass_mailing_campaign_id',
97             'Mass Mailings',
98         ),
99         'mass_mailing_kanban_ids': fields.function(
100             _get_mass_mailing_kanban_ids,
101             type='text', string='Mass Mailings (kanban data)',
102             help='This field has for purpose to gather data about mass mailings '
103                  'to display them in kanban view as nested kanban views is not '
104                  'possible currently',
105         ),
106         'statistics_ids': fields.one2many(
107             'mail.mail.statistics', 'mass_mailing_campaign_id',
108             'Sent Emails',
109         ),
110         'color': fields.integer('Color Index'),
111         # stat fields
112         'sent': fields.function(
113             _get_statistics,
114             string='Sent Emails',
115             type='integer', multi='_get_statistics'
116         ),
117         'delivered': fields.function(
118             _get_statistics,
119             string='Delivered',
120             type='integer', multi='_get_statistics',
121         ),
122         'opened': fields.function(
123             _get_statistics,
124             string='Opened',
125             type='integer', multi='_get_statistics',
126         ),
127         'replied': fields.function(
128             _get_statistics,
129             string='Replied',
130             type='integer', multi='_get_statistics'
131         ),
132         'bounced': fields.function(
133             _get_statistics,
134             string='Bounced',
135             type='integer', multi='_get_statistics'
136         ),
137     }
138
139     _defaults = {
140         'user_id': lambda self, cr, uid, ctx=None: uid,
141     }
142
143     def launch_mass_mailing_create_wizard(self, cr, uid, ids, context=None):
144         ctx = dict(context)
145         ctx.update({
146             'default_mass_mailing_campaign_id': ids[0],
147         })
148         return {
149             'name': _('Create a Mass Mailing for the Campaign'),
150             'type': 'ir.actions.act_window',
151             'view_type': 'form',
152             'view_mode': 'form',
153             'res_model': 'mail.mass_mailing.create',
154             'views': [(False, 'form')],
155             'view_id': False,
156             'target': 'new',
157             'context': ctx,
158         }
159
160
161 class MassMailing(osv.Model):
162     """ MassMailing models a wave of emails for a mass mailign campaign.
163     A mass mailing is an occurence of sending emails. """
164
165     _name = 'mail.mass_mailing'
166     _description = 'Wave of sending emails'
167     # number of periods for tracking mail_mail statistics
168     _period_number = 6
169     _order = 'date DESC'
170
171     def __get_bar_values(self, cr, uid, id, obj, domain, read_fields, value_field, groupby_field, context=None):
172         """ Generic method to generate data for bar chart values using SparklineBarWidget.
173             This method performs obj.read_group(cr, uid, domain, read_fields, groupby_field).
174
175             :param obj: the target model (i.e. crm_lead)
176             :param domain: the domain applied to the read_group
177             :param list read_fields: the list of fields to read in the read_group
178             :param str value_field: the field used to compute the value of the bar slice
179             :param str groupby_field: the fields used to group
180
181             :return list section_result: a list of dicts: [
182                                                 {   'value': (int) bar_column_value,
183                                                     'tootip': (str) bar_column_tooltip,
184                                                 }
185                                             ]
186         """
187         date_begin = datetime.strptime(self.browse(cr, uid, id, context=context).date, tools.DEFAULT_SERVER_DATETIME_FORMAT).date()
188         section_result = [{'value': 0,
189                            'tooltip': (date_begin + relativedelta.relativedelta(days=i)).strftime('%d %B %Y'),
190                            } for i in range(0, self._period_number)]
191         group_obj = obj.read_group(cr, uid, domain, read_fields, groupby_field, context=context)
192         field_col_info = obj._all_columns.get(groupby_field.split(':')[0])
193         pattern = tools.DEFAULT_SERVER_DATE_FORMAT if field_col_info.column._type == 'date' else tools.DEFAULT_SERVER_DATETIME_FORMAT
194         for group in group_obj:
195             group_begin_date = datetime.strptime(group['__domain'][0][2], pattern).date()
196             timedelta = relativedelta.relativedelta(group_begin_date, date_begin)
197             section_result[timedelta.days] = {'value': group.get(value_field, 0), 'tooltip': group.get(groupby_field)}
198         return section_result
199
200     def _get_daily_statistics(self, cr, uid, ids, field_name, arg, context=None):
201         """ Get the daily statistics of the mass mailing. This is done by a grouping
202         on opened and replied fields. Using custom format in context, we obtain
203         results for the next 6 days following the mass mailing date. """
204         obj = self.pool['mail.mail.statistics']
205         res = {}
206         for id in ids:
207             res[id] = {}
208             date_begin = datetime.strptime(self.browse(cr, uid, id, context=context).date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
209             date_end = date_begin + relativedelta.relativedelta(days=self._period_number - 1)
210             date_begin_str = date_begin.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
211             date_end_str = date_end.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
212             domain = [('mass_mailing_id', '=', id), ('opened', '>=', date_begin_str), ('opened', '<=', date_end_str)]
213             res[id]['opened_monthly'] = self.__get_bar_values(cr, uid, id, obj, domain, ['opened'], 'opened_count', 'opened:day', context=context)
214             domain = [('mass_mailing_id', '=', id), ('replied', '>=', date_begin_str), ('replied', '<=', date_end_str)]
215             res[id]['replied_monthly'] = self.__get_bar_values(cr, uid, id, obj, domain, ['replied'], 'replied_count', 'replied:day', context=context)
216         return res
217
218     def _get_statistics(self, cr, uid, ids, name, arg, context=None):
219         """ Compute statistics of the mass mailing """
220         results = dict.fromkeys(ids, {
221                 'sent': 0,
222                 'delivered': 0,
223                 'opened': 0,
224                 'replied': 0,
225                 'bounced': 0,
226             })
227         cr.execute("""
228             SELECT
229                 mass_mailing_id,
230                 COUNT(id) AS sent,
231                 COUNT(CASE WHEN bounced is null THEN 1 ELSE null END) AS delivered,
232                 COUNT(CASE WHEN opened is not null THEN 1 ELSE null END) AS opened,
233                 COUNT(CASE WHEN replied is not null THEN 1 ELSE null END) AS replied ,
234                 COUNT(CASE WHEN bounced is not null THEN 1 ELSE null END) AS bounced
235             FROM
236                 mail_mail_statistics
237             WHERE
238                 mass_mailing_id IN %s
239             GROUP BY
240                  mass_mailing_id
241         """, (tuple(ids), ))
242         for (campaign_id, sent, delivered, opened, replied, bounced) in cr.fetchall():
243             results[campaign_id] = {
244                 'sent': sent,
245                 # delivered: shouldn't be: all mails - (failed + bounced) ?
246                 'delivered': delivered,
247                 'opened': opened,
248                 'replied': replied,
249                 'bounced': bounced,
250             }
251         return results
252
253     _columns = {
254         'name': fields.char('Name', required=True),
255         'mass_mailing_campaign_id': fields.many2one(
256             'mail.mass_mailing.campaign', 'Mass Mailing Campaign',
257             ondelete='cascade', required=True,
258         ),
259         'template_id': fields.many2one(
260             'email.template', 'Email Template',
261             ondelete='set null',
262         ),
263         'domain': fields.char('Domain'),
264         'date': fields.datetime('Date'),
265         'color': fields.related(
266             'mass_mailing_campaign_id', 'color',
267             type='integer', string='Color Index',
268         ),
269         # statistics data
270         'statistics_ids': fields.one2many(
271             'mail.mail.statistics', 'mass_mailing_id',
272             'Emails Statistics',
273         ),
274         'sent': fields.function(
275             _get_statistics,
276             string='Sent Emails',
277             type='integer', multi='_get_statistics'
278         ),
279         'delivered': fields.function(
280             _get_statistics,
281             string='Delivered',
282             type='integer', multi='_get_statistics',
283         ),
284         'opened': fields.function(
285             _get_statistics,
286             string='Opened',
287             type='integer', multi='_get_statistics',
288         ),
289         'replied': fields.function(
290             _get_statistics,
291             string='Replied',
292             type='integer', multi='_get_statistics'
293         ),
294         'bounced': fields.function(
295             _get_statistics,
296             string='Bounce',
297             type='integer', multi='_get_statistics'
298         ),
299         # monthly ratio
300         'opened_monthly': fields.function(
301             _get_daily_statistics,
302             string='Opened',
303             type='char', multi='_get_daily_statistics',
304         ),
305         'replied_monthly': fields.function(
306             _get_daily_statistics,
307             string='Replied',
308             type='char', multi='_get_daily_statistics',
309         ),
310     }
311
312     _defaults = {
313         'date': fields.datetime.now,
314     }
315
316
317 class MailMailStats(osv.Model):
318     """ MailMailStats models the statistics collected about emails. Those statistics
319     are stored in a separated model and table to avoid bloating the mail_mail table
320     with statistics values. This also allows to delete emails send with mass mailing
321     without loosing the statistics about them. """
322
323     _name = 'mail.mail.statistics'
324     _description = 'Email Statistics'
325     _rec_name = 'message_id'
326     _order = 'message_id'
327
328     _columns = {
329         'mail_mail_id': fields.integer(
330             'Mail ID',
331             help='ID of the related mail_mail. This field is an integer field because'
332                  'the related mail_mail can be deleted separately from its statistics.'
333         ),
334         'message_id': fields.char(
335             'Message-ID',
336         ),
337         'model': fields.char(
338             'Document model',
339         ),
340         'res_id': fields.integer(
341             'Document ID',
342         ),
343         # campaign / wave data
344         'mass_mailing_id': fields.many2one(
345             'mail.mass_mailing', 'Mass Mailing',
346             ondelete='set null',
347         ),
348         'mass_mailing_campaign_id': fields.related(
349             'mass_mailing_id', 'mass_mailing_campaign_id',
350             type='many2one', ondelete='set null',
351             relation='mail.mass_mailing.campaign',
352             string='Mass Mailing Campaign',
353             store=True, readonly=True,
354         ),
355         'template_id': fields.related(
356             'mass_mailing_id', 'template_id',
357             type='many2one', ondelete='set null',
358             relation='email.template',
359             string='Email Template',
360             store=True, readonly=True,
361         ),
362         # Bounce and tracking
363         'opened': fields.datetime(
364             'Opened',
365             help='Date when this email has been opened for the first time.'),
366         'replied': fields.datetime(
367             'Replied',
368             help='Date when this email has been replied for the first time.'),
369         'bounced': fields.datetime(
370             'Bounced',
371             help='Date when this email has bounced.'
372         ),
373     }
374
375     def set_opened(self, cr, uid, ids=None, mail_mail_ids=None, mail_message_ids=None, context=None):
376         """ Set as opened """
377         if not ids and mail_mail_ids:
378             ids = self.search(cr, uid, [('mail_mail_id', 'in', mail_mail_ids)], context=context)
379         elif not ids and mail_message_ids:
380             ids = self.search(cr, uid, [('message_id', 'in', mail_message_ids)], context=context)
381         else:
382             ids = []
383         for stat in self.browse(cr, uid, ids, context=context):
384             if not stat.opened:
385                 self.write(cr, uid, [stat.id], {'opened': fields.datetime.now()}, context=context)
386         return ids
387
388     def set_replied(self, cr, uid, ids=None, mail_mail_ids=None, mail_message_ids=None, context=None):
389         """ Set as replied """
390         if not ids and mail_mail_ids:
391             ids = self.search(cr, uid, [('mail_mail_id', 'in', mail_mail_ids)], context=context)
392         elif not ids and mail_message_ids:
393             ids = self.search(cr, uid, [('message_id', 'in', mail_message_ids)], context=context)
394         else:
395             ids = []
396         for stat in self.browse(cr, uid, ids, context=context):
397             if not stat.replied:
398                 self.write(cr, uid, [stat.id], {'replied': fields.datetime.now()}, context=context)
399         return ids
400
401     def set_bounced(self, cr, uid, ids=None, mail_mail_ids=None, mail_message_ids=None, context=None):
402         """ Set as bounced """
403         if not ids and mail_mail_ids:
404             ids = self.search(cr, uid, [('mail_mail_id', 'in', mail_mail_ids)], context=context)
405         elif not ids and mail_message_ids:
406             ids = self.search(cr, uid, [('message_id', 'in', mail_message_ids)], context=context)
407         else:
408             ids = []
409         for stat in self.browse(cr, uid, ids, context=context):
410             if not stat.bounced:
411                 self.write(cr, uid, [stat.id], {'bounced': fields.datetime.now()}, context=context)
412         return ids