Merge lp:~openerp-dev/openobject-addons/trunk-campaign-tracking-jri into lp:openobject-addons

Proposed by Juan Rial (OpenERP)
Status: Needs review
Proposed branch: lp:~openerp-dev/openobject-addons/trunk-campaign-tracking-jri
Merge into: lp:openobject-addons
Diff against target: 1867 lines (+1520/-36) (has conflicts)
13 files modified
marketing_campaign/__init__.py (+3/-0)
marketing_campaign/__openerp__.py (+14/-0)
marketing_campaign/email_template.py (+212/-0)
marketing_campaign/email_template_view.xml (+20/-0)
marketing_campaign/ir_mail_server.py (+260/-0)
marketing_campaign/mail_message.py (+365/-0)
marketing_campaign/marketing_campaign.py (+174/-11)
marketing_campaign/marketing_campaign_view.xml (+2/-0)
marketing_campaign/report/__init__.py (+1/-0)
marketing_campaign/report/campaign_analysis.py (+93/-14)
marketing_campaign/report/campaign_analysis_view.xml (+135/-11)
marketing_campaign/report/campaign_tracking.py (+188/-0)
marketing_campaign/report/campaign_tracking_view.xml (+53/-0)
Text conflict in marketing_campaign/__openerp__.py
Text conflict in marketing_campaign/report/campaign_analysis_view.xml
To merge this branch: bzr merge lp:~openerp-dev/openobject-addons/trunk-campaign-tracking-jri
Reviewer Review Type Date Requested Status
OpenERP Core Team Pending
Review via email: mp+116298@code.launchpad.net

Description of the change

Tracking & improved reporting for marketing campaigns.

To post a comment you must log in.
7086. By Juan Rial (OpenERP)

Removed some print statements

7087. By Juan Rial (OpenERP)

Change one small customer-specific piece of code

7088. By Juan Rial (OpenERP)

Marketing Campaign: separate return path from sender and fix small bug when running in normal mode

Unmerged revisions

7088. By Juan Rial (OpenERP)

Marketing Campaign: separate return path from sender and fix small bug when running in normal mode

7087. By Juan Rial (OpenERP)

Change one small customer-specific piece of code

7086. By Juan Rial (OpenERP)

Removed some print statements

7085. By Juan Rial (OpenERP)

Marketing Campaign: tracking

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'marketing_campaign/__init__.py'
2--- marketing_campaign/__init__.py 2011-01-14 00:11:01 +0000
3+++ marketing_campaign/__init__.py 2012-08-27 10:03:27 +0000
4@@ -20,6 +20,9 @@
5 ##############################################################################
6
7 import marketing_campaign
8+import email_template
9+import ir_mail_server
10+import mail_message
11 import res_partner
12 import report
13
14
15=== modified file 'marketing_campaign/__openerp__.py'
16--- marketing_campaign/__openerp__.py 2012-08-22 13:02:32 +0000
17+++ marketing_campaign/__openerp__.py 2012-08-27 10:03:27 +0000
18@@ -57,15 +57,29 @@
19 'website': 'http://www.openerp.com',
20 'data': [
21 'marketing_campaign_view.xml',
22+ 'email_template_view.xml',
23 'marketing_campaign_data.xml',
24 'marketing_campaign_workflow.xml',
25 'res_partner_view.xml',
26 'report/campaign_analysis_view.xml',
27+<<<<<<< TREE
28 'security/marketing_campaign_security.xml',
29 'security/ir.model.access.csv'
30 ],
31 'demo': ['marketing_campaign_demo.xml'],
32 'test': ['test/marketing_campaign.yml'],
33+=======
34+ 'report/campaign_tracking_view.xml',
35+ "security/marketing_campaign_security.xml",
36+ "security/ir.model.access.csv"
37+ ],
38+ 'demo_xml': [
39+ 'marketing_campaign_demo.xml',
40+ ],
41+ 'test': [
42+ 'test/marketing_campaign.yml',
43+ ],
44+>>>>>>> MERGE-SOURCE
45 'installable': True,
46 'auto_install': False,
47 'certificate': '00421723279617928365',
48
49=== added file 'marketing_campaign/email_template.py'
50--- marketing_campaign/email_template.py 1970-01-01 00:00:00 +0000
51+++ marketing_campaign/email_template.py 2012-08-27 10:03:27 +0000
52@@ -0,0 +1,212 @@
53+import netsvc
54+import base64
55+from osv import osv
56+from osv import fields
57+import re
58+import urllib
59+
60+
61+# Helper functions to transform template fields for campaign tracking
62+def insert_html_tracking(template, track_str, get_char):
63+ retval = template['body_html']
64+ if 'tracker_url' in template:
65+ re_str = ['href="([^"]*)"', '(?:img[^<>]*)src="([^"]*)"']
66+ for i in range(2):
67+ m = re.compile(re_str[i]).finditer(retval)
68+ startpos = 0
69+ body_html = ''
70+ for x in m:
71+ body_html += retval[startpos:x.start(1)] \
72+ + template['tracker_url'] + get_char + 'url=' \
73+ + urllib.quote_plus(x.group(1)) \
74+ + '&' + track_str
75+ startpos = x.end(1)
76+ body_html += retval[startpos:]
77+ retval = body_html
78+ else:
79+ re_str = [
80+ 'href="([^"]*\?[^"]*)"',
81+ 'href="([^"\?]*)"',
82+ '(img[^<>]*)src="([^"]*\?[^"]*)"',
83+ '(img[^<>]*)src="([^"\?]*)"',
84+ ]
85+ get_chars = ['&', '?'] * 2
86+ for i in range(0, 2):
87+ expr = re.compile(re_str[i])
88+ retval = expr.sub('href="\\1' + get_chars[i] + track_str + '"', retval)
89+ for i in range(2, 4):
90+ expr = re.compile(re_str[i])
91+ retval = expr.sub('\\1 src="\\2' + get_chars[i] + track_str + '"', retval)
92+ return retval
93+
94+
95+def insert_text_tracking(template, track_str, get_char):
96+ retval = template['body_text']
97+ if 'tracker_url' in template:
98+ expr1 = re.compile('http(s?://([^\s]*?))([,\.]?\s)')
99+ m = expr1.finditer(retval)
100+ startpos = 0
101+ body_text = ''
102+ for x in m:
103+ body_text += retval[startpos:x.start()] \
104+ + template['tracker_url'] + get_char + 'url=' \
105+ + urllib.quote_plus('http' + x.group(1)) \
106+ + '&' + track_str + x.group(3)
107+ startpos = x.end()
108+ body_text += retval[startpos:]
109+ retval = body_text
110+ else:
111+ re_str = ['http(s?://([^\s]*?\?[^\s]*?))([,\.]?\s)', 'http(s?://([^\s\?]*?))([,\.]?\s)']
112+ get_chars = ['&', '?']
113+ for i in range(2):
114+ expr = re.compile(re_str[i])
115+ retval = expr.sub('http\\1' + get_chars[i] + track_str + '\\3', retval)
116+ return retval
117+# End helper functions for campaign tracking
118+
119+
120+class email_template(osv.osv):
121+ _name = 'email.template'
122+ _inherit = 'email.template'
123+
124+ def generate_email(self, cr, uid, template_id, res_id, context=None):
125+ """Generates an email from the template for given (model, res_id) pair.
126+
127+ :param template_id: id of the template to render.
128+ :param res_id: id of the record to use for rendering the template (model
129+ is taken from template definition)
130+ :returns: a dict containing all relevant fields for creating a new
131+ mail.message entry, with the addition one additional
132+ special key ``attachments`` containing a list of
133+ """
134+ if context is None:
135+ context = {}
136+ values = {
137+ 'subject': False,
138+ 'body_text': False,
139+ 'body_html': False,
140+ 'email_from': False,
141+ 'return_path': False, # MODIF
142+ 'email_to': False,
143+ 'email_cc': False,
144+ 'email_bcc': False,
145+ 'reply_to': False,
146+ 'auto_delete': False,
147+ 'model': False,
148+ 'res_id': False,
149+ 'mail_server_id': False,
150+ 'attachments': False,
151+ 'attachment_ids': False,
152+ 'message_id': False,
153+ 'state': 'outgoing',
154+ 'subtype': 'plain',
155+ }
156+ if not template_id:
157+ return values
158+
159+ report_xml_pool = self.pool.get('ir.actions.report.xml')
160+ template = self.get_email_template(cr, uid, template_id, res_id, context)
161+
162+ # BEGIN MODIF
163+ fieldnames = ['subject', 'body_text', 'body_html', 'email_from',
164+ 'email_to', 'email_cc', 'email_bcc', 'reply_to',
165+ 'return_path', 'message_id']
166+ render_src = {}
167+ for field in fieldnames:
168+ render_src[field] = getattr(template, field)
169+ if 'campaign_track_str' in context \
170+ and 'activity_track_str' in context \
171+ and 'res_track_str' in context:
172+ track_str = 'c=' + context['campaign_track_str'] \
173+ + '&a=' + context['activity_track_str'] \
174+ + '&r=' + context['res_track_str']
175+ get_char = None
176+ if 'tracker_url' in template:
177+ if re.search('\?', template['tracker_url']):
178+ get_char = '&'
179+ else:
180+ get_char = '?'
181+
182+ # Body text
183+ if 'body_text' in template and template['body_text']:
184+ render_src['body_text'] = insert_text_tracking(template, track_str, get_char)
185+
186+ # Body HTML
187+ if 'body_html' in template and template['body_html']:
188+ render_src['body_html'] = insert_html_tracking(template, track_str, get_char)
189+
190+ for field in fieldnames:
191+ values[field] = self.render_template(cr, uid, render_src.get(field),
192+ template.model, res_id, context=context) \
193+ or False
194+
195+ # Variable Sender
196+ if values['return_path']:
197+ bounce_split = values['return_path'].split('@')
198+ else:
199+ bounce_split = values['email_from'].split('@')
200+ # If we have campaign tracking info
201+ if 'campaign_track_str' in context \
202+ and 'activity_track_str' in context \
203+ and 'res_track_str' in context:
204+ values['return_path'] = bounce_split[0] + '+' \
205+ + context['campaign_track_str'] + '-' \
206+ + context['activity_track_str'] + '-' \
207+ + context['res_track_str'] \
208+ + '@' + bounce_split[1]
209+ # If we don't have campaign tracking info
210+ else:
211+ values['return_path'] = bounce_split[0] + '+' \
212+ + re.sub('@', '=', values['email_to']) \
213+ + '@' + bounce_split[1]
214+ # END MODIF
215+
216+ if values['body_html']:
217+ values.update(subtype='html')
218+
219+ if template.user_signature:
220+ signature = self.pool.get('res.users').browse(cr, uid, uid, context).signature
221+ values['body_text'] += '\n\n' + signature
222+
223+ values.update(mail_server_id=template.mail_server_id.id or False,
224+ auto_delete=template.auto_delete,
225+ model=template.model,
226+ res_id=res_id or False)
227+
228+ attachments = {}
229+ # Add report as a Document
230+ if template.report_template:
231+ report_name = self.render_template(cr, uid, template.report_name, template.model, res_id, context=context)
232+ report_service = 'report.' + report_xml_pool.browse(cr, uid, template.report_template.id, context).report_name
233+ # Ensure report is rendered using template's language
234+ ctx = context.copy()
235+ if template.lang:
236+ ctx['lang'] = self.render_template(cr, uid, template.lang, template.model, res_id, context)
237+ service = netsvc.LocalService(report_service)
238+ (result, format) = service.create(cr, uid, [res_id], {'model': template.model}, ctx)
239+ result = base64.b64encode(result)
240+ if not report_name:
241+ report_name = report_service
242+ ext = "." + format
243+ if not report_name.endswith(ext):
244+ report_name += ext
245+ attachments[report_name] = result
246+
247+ # Add document attachments
248+ for attach in template.attachment_ids:
249+ # keep the bytes as fetched from the db, base64 encoded
250+ attachments[attach.datas_fname] = attach.datas
251+
252+ values['attachments'] = attachments
253+ return values
254+
255+ _columns = {
256+ 'tracker_url': fields.char('Tracker URL', None, help='The URL of the campaign tracker'),
257+ 'return_path': fields.char('Return path', None, help='Email address to send mail delivery reports to'),
258+ }
259+
260+ _defaults = {
261+ 'model_id': lambda obj, cr, uid, context: context.get('object_id',False),
262+ }
263+
264+email_template()
265
266=== added file 'marketing_campaign/email_template_view.xml'
267--- marketing_campaign/email_template_view.xml 1970-01-01 00:00:00 +0000
268+++ marketing_campaign/email_template_view.xml 2012-08-27 10:03:27 +0000
269@@ -0,0 +1,20 @@
270+<?xml version="1.0" encoding="UTF-8"?>
271+<openerp>
272+ <data>
273+
274+ <record model="ir.ui.view" id="email_template_form_inherit">
275+ <field name="name">email.template.form</field>
276+ <field name="inherit_id" ref='email_template.email_template_form' />
277+ <field name="model">email.template</field>
278+ <field name="type">form</field>
279+ <field name="arch" type="xml">
280+ <field name="reply_to" position="after">
281+ <field name="return_path"/>
282+ </field>
283+ <field name="track_campaign_item" position="after">
284+ <field name="tracker_url" />
285+ </field>
286+ </field>
287+ </record>
288+ </data>
289+</openerp>
290\ No newline at end of file
291
292=== added file 'marketing_campaign/ir_mail_server.py'
293--- marketing_campaign/ir_mail_server.py 1970-01-01 00:00:00 +0000
294+++ marketing_campaign/ir_mail_server.py 2012-08-27 10:03:27 +0000
295@@ -0,0 +1,260 @@
296+from email.MIMEText import MIMEText
297+from email.MIMEBase import MIMEBase
298+from email.MIMEMultipart import MIMEMultipart
299+from email.Charset import Charset
300+from email.Header import Header
301+from email.Utils import formatdate, make_msgid, COMMASPACE
302+from email import Encoders
303+import logging
304+import re
305+# import smtplib
306+# import threading
307+
308+# Just here to satisfy code requirements, but unmodified from original
309+from osv import osv
310+# from osv import fields
311+# from openerp.tools.translate import _
312+from openerp.tools import html2text
313+import openerp.tools as tools
314+
315+# ustr was originally from tools.misc.
316+# it is moved to loglevels until we refactor tools.
317+from openerp.loglevels import ustr
318+
319+_logger = logging.getLogger(__name__)
320+name_with_email_pattern = re.compile(r'("[^<@>]+")\s*<([^ ,<@]+@[^> ,]+)>')
321+address_pattern = re.compile(r'([^ ,<@]+@[^> ,]+)')
322+
323+
324+def try_coerce_ascii(string_utf8):
325+ """Attempts to decode the given utf8-encoded string
326+ as ASCII after coercing it to UTF-8, then return
327+ the confirmed 7-bit ASCII string.
328+
329+ If the process fails (because the string
330+ contains non-ASCII characters) returns ``None``.
331+ """
332+ try:
333+ string_utf8.decode('ascii')
334+ except UnicodeDecodeError:
335+ return
336+ return string_utf8
337+
338+
339+def extract_rfc2822_addresses(text):
340+ """Returns a list of valid RFC2822 addresses
341+ that can be found in ``source``, ignoring
342+ malformed ones and non-ASCII ones.
343+ """
344+ if not text:
345+ return []
346+ candidates = address_pattern.findall(tools.ustr(text).encode('utf-8'))
347+ return filter(try_coerce_ascii, candidates)
348+
349+
350+def encode_header_param(param_text):
351+ """Returns an appropriate RFC2047 encoded representation of the given
352+ header parameter value, suitable for direct assignation as the
353+ param value (e.g. via Message.set_param() or Message.add_header())
354+ RFC2822 assumes that headers contain only 7-bit characters,
355+ so we ensure it is the case, using RFC2047 encoding when needed.
356+
357+ :param param_text: unicode or utf-8 encoded string with header value
358+ :rtype: string
359+ :return: if ``param_text`` represents a plain ASCII string,
360+ return the same 7-bit string, otherwise returns an
361+ ASCII string containing the RFC2047 encoded text.
362+ """
363+ # For details see the encode_header() method that uses the same logic
364+ if not param_text:
365+ return ""
366+ param_text_utf8 = tools.ustr(param_text).encode('utf-8')
367+ param_text_ascii = try_coerce_ascii(param_text_utf8)
368+ return param_text_ascii if param_text_ascii\
369+ else Charset('utf8').header_encode(param_text_utf8)
370+
371+
372+def encode_rfc2822_address_header(header_text):
373+ """If ``header_text`` contains non-ASCII characters,
374+ attempts to locate patterns of the form
375+ ``"Name" <address@domain>`` and replace the
376+ ``"Name"`` portion by the RFC2047-encoded
377+ version, preserving the address part untouched.
378+ """
379+ header_text_utf8 = tools.ustr(header_text).encode('utf-8')
380+ header_text_ascii = try_coerce_ascii(header_text_utf8)
381+ if header_text_ascii:
382+ return header_text_ascii
383+ # non-ASCII characters are present, attempt to
384+ # replace all "Name" patterns with the RFC2047-
385+ # encoded version
386+
387+ def replace(match_obj):
388+ name, email = match_obj.group(1), match_obj.group(2)
389+ name_encoded = str(Header(name, 'utf-8'))
390+ return "%s <%s>" % (name_encoded, email)
391+ header_text_utf8 = name_with_email_pattern.sub(replace,
392+ header_text_utf8)
393+ # try again after encoding
394+ header_text_ascii = try_coerce_ascii(header_text_utf8)
395+ if header_text_ascii:
396+ return header_text_ascii
397+ # fallback to extracting pure addresses only, which could
398+ # still cause a failure downstream if the actual addresses
399+ # contain non-ASCII characters
400+ return COMMASPACE.join(extract_rfc2822_addresses(header_text_utf8))
401+
402+
403+def encode_header(header_text):
404+ """Returns an appropriate representation of the given header value,
405+ suitable for direct assignment as a header value in an
406+ email.message.Message. RFC2822 assumes that headers contain
407+ only 7-bit characters, so we ensure it is the case, using
408+ RFC2047 encoding when needed.
409+
410+ :param header_text: unicode or utf-8 encoded string with header value
411+ :rtype: string | email.header.Header
412+ :return: if ``header_text`` represents a plain ASCII string,
413+ return the same 7-bit string, otherwise returns an email.header.Header
414+ that will perform the appropriate RFC2047 encoding of
415+ non-ASCII values.
416+ """
417+ if not header_text:
418+ return ""
419+ # convert anything to utf-8, suitable for testing ASCIIness, as 7-bit chars are
420+ # encoded as ASCII in utf-8
421+ header_text_utf8 = tools.ustr(header_text).encode('utf-8')
422+ header_text_ascii = try_coerce_ascii(header_text_utf8)
423+ # if this header contains non-ASCII characters,
424+ # we'll need to wrap it up in a message.header.Header
425+ # that will take care of RFC2047-encoding it as
426+ # 7-bit string.
427+ return header_text_ascii if header_text_ascii\
428+ else Header(header_text_utf8, 'utf-8')
429+# End "Just here ..."
430+
431+
432+class ir_mail_server(osv.osv):
433+ _name = "ir.mail_server"
434+ _inherit = "ir.mail_server"
435+
436+ # _columns = {
437+ # 'variable_sender': fields.boolean('Variable Sender'),
438+ # }
439+
440+ # MODIF signature
441+ def build_email(self, email_from, email_to, subject, body, email_cc=None, email_bcc=None, reply_to=False,
442+ attachments=None, message_id=None, references=None, object_id=False, subtype='plain', headers=None,
443+ body_alternative=None, subtype_alternative='plain', return_path=False):
444+ """Constructs an RFC2822 email.message.Message object based on the keyword arguments passed, and returns it.
445+
446+ :param string email_from: sender email address
447+ :param list email_to: list of recipient addresses (to be joined with commas)
448+ :param string subject: email subject (no pre-encoding/quoting necessary)
449+ :param string body: email body, of the type ``subtype`` (by default, plaintext).
450+ If html subtype is used, the message will be automatically converted
451+ to plaintext and wrapped in multipart/alternative, unless an explicit
452+ ``body_alternative`` version is passed.
453+ :param string body_alternative: optional alternative body, of the type specified in ``subtype_alternative``
454+ :param string reply_to: optional value of Reply-To header
455+ :param string object_id: optional tracking identifier, to be included in the message-id for
456+ recognizing replies. Suggested format for object-id is "res_id-model",
457+ e.g. "12345-crm.lead".
458+ :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
459+ must match the format of the ``body`` parameter. Default is 'plain',
460+ making the content part of the mail "text/plain".
461+ :param string subtype_alternative: optional mime subtype of ``body_alternative`` (usually 'plain'
462+ or 'html'). Default is 'plain'.
463+ :param list attachments: list of (filename, filecontents) pairs, where filecontents is a string
464+ containing the bytes of the attachment
465+ :param list email_cc: optional list of string values for CC header (to be joined with commas)
466+ :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
467+ :param dict headers: optional map of headers to set on the outgoing mail (may override the
468+ other headers, including Subject, Reply-To, Message-Id, etc.)
469+ :rtype: email.message.Message (usually MIMEMultipart)
470+ :return: the new RFC2822 email message
471+ """
472+ email_from = email_from or tools.config.get('email_from')
473+ assert email_from, "You must either provide a sender address explicitly or configure "\
474+ "a global sender address in the server configuration or with the "\
475+ "--email-from startup parameter."
476+
477+ # Note: we must force all strings to to 8-bit utf-8 when crafting message,
478+ # or use encode_header() for headers, which does it automatically.
479+
480+ headers = headers or {} # need valid dict later
481+
482+ if not email_cc:
483+ email_cc = []
484+ if not email_bcc:
485+ email_bcc = []
486+ if not body:
487+ body = u''
488+
489+ email_body_utf8 = ustr(body).encode('utf-8')
490+ email_text_part = MIMEText(email_body_utf8, _subtype=subtype, _charset='utf-8')
491+ msg = MIMEMultipart()
492+
493+ if not message_id:
494+ if object_id:
495+ message_id = tools.generate_tracking_message_id(object_id)
496+ else:
497+ message_id = make_msgid()
498+ msg['Message-Id'] = encode_header(message_id)
499+ if references:
500+ msg['references'] = encode_header(references)
501+ msg['Subject'] = encode_header(subject)
502+ msg['From'] = encode_rfc2822_address_header(email_from)
503+ # del msg['Reply-To'] # MODIF: unnecessary., given what preceeds and what follows.
504+ if reply_to:
505+ msg['Reply-To'] = encode_rfc2822_address_header(reply_to)
506+ else:
507+ msg['Reply-To'] = msg['From']
508+ # BEGIN MODIF
509+ if return_path:
510+ msg['Return-Path'] = encode_rfc2822_address_header(return_path)
511+ else:
512+ msg['Return-Path'] = msg['From']
513+ # END MODIF
514+ msg['To'] = encode_rfc2822_address_header(COMMASPACE.join(email_to))
515+ if email_cc:
516+ msg['Cc'] = encode_rfc2822_address_header(COMMASPACE.join(email_cc))
517+ if email_bcc:
518+ msg['Bcc'] = encode_rfc2822_address_header(COMMASPACE.join(email_bcc))
519+ msg['Date'] = formatdate()
520+ # Custom headers may override normal headers or provide additional ones
521+ for key, value in headers.iteritems():
522+ msg[ustr(key).encode('utf-8')] = encode_header(value)
523+
524+ if subtype == 'html' and not body_alternative and html2text:
525+ # Always provide alternative text body ourselves if possible.
526+ text_utf8 = tools.html2text(email_body_utf8.decode('utf-8')).encode('utf-8')
527+ alternative_part = MIMEMultipart(_subtype="alternative")
528+ alternative_part.attach(MIMEText(text_utf8, _charset='utf-8', _subtype='plain'))
529+ alternative_part.attach(email_text_part)
530+ msg.attach(alternative_part)
531+ elif body_alternative:
532+ # Include both alternatives, as specified, within a multipart/alternative part
533+ alternative_part = MIMEMultipart(_subtype="alternative")
534+ body_alternative_utf8 = ustr(body_alternative).encode('utf-8')
535+ alternative_body_part = MIMEText(body_alternative_utf8, _subtype=subtype_alternative, _charset='utf-8')
536+ alternative_part.attach(alternative_body_part)
537+ alternative_part.attach(email_text_part)
538+ msg.attach(alternative_part)
539+ else:
540+ msg.attach(email_text_part)
541+
542+ if attachments:
543+ for (fname, fcontent) in attachments:
544+ filename_rfc2047 = encode_header_param(fname)
545+ part = MIMEBase('application', "octet-stream")
546+
547+ # The default RFC2231 encoding of Message.add_header() works in Thunderbird but not GMail
548+ # so we fix it by using RFC2047 encoding for the filename instead.
549+ part.set_param('name', filename_rfc2047)
550+ part.add_header('Content-Disposition', 'attachment', filename=filename_rfc2047)
551+
552+ part.set_payload(fcontent)
553+ Encoders.encode_base64(part)
554+ msg.attach(part)
555+ return msg
556
557=== added file 'marketing_campaign/mail_message.py'
558--- marketing_campaign/mail_message.py 1970-01-01 00:00:00 +0000
559+++ marketing_campaign/mail_message.py 2012-08-27 10:03:27 +0000
560@@ -0,0 +1,365 @@
561+import ast
562+import base64
563+import dateutil.parser
564+import email
565+import logging
566+import re
567+import time
568+from email.header import decode_header
569+from email.message import Message
570+
571+import tools
572+from osv import osv
573+from osv import fields
574+# from tools.translate import _
575+# from openerp import SUPERUSER_ID
576+
577+# Just here to satisfy code requirements, but unmodified from original
578+_logger = logging.getLogger('mail')
579+
580+
581+def decode(text):
582+ """Returns unicode() string conversion of the the given encoded smtp header text"""
583+ if text:
584+ text = decode_header(text.replace('\r', ''))
585+ return ''.join([tools.ustr(x[0], x[1]) for x in text])
586+
587+
588+def to_email(text):
589+ """Return a list of the email addresses found in ``text``"""
590+ if not text:
591+ return []
592+ return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
593+# End "Just here ..."
594+
595+
596+class mail_message(osv.osv):
597+ _name = 'mail.message'
598+ _inherit = 'mail.message'
599+
600+ _columns = {
601+ 'return_path': fields.char('Return-Path', size=None, readonly=True),
602+ 'workitem_id': fields.many2one('marketing.campaign.workitem', 'Workitem'),
603+ }
604+
605+ # MODIF signature
606+ def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
607+ email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
608+ res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
609+ context=None, return_path=False):
610+ """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
611+ the next time :meth:`process_email_queue` is called explicitly.
612+
613+ :param string email_from: sender email address
614+ :param string return_path: bounce return path
615+ :param list email_to: list of recipient addresses (to be joined with commas)
616+ :param string subject: email subject (no pre-encoding/quoting necessary)
617+ :param string body: email body, according to the ``subtype`` (by default, plaintext).
618+ If html subtype is used, the message will be automatically converted
619+ to plaintext and wrapped in multipart/alternative.
620+ :param list email_cc: optional list of string values for CC header (to be joined with commas)
621+ :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
622+ :param string model: optional model name of the document this mail is related to (this will also
623+ be used to generate a tracking id, used to match any response related to the
624+ same document)
625+ :param int res_id: optional resource identifier this mail is related to (this will also
626+ be used to generate a tracking id, used to match any response related to the
627+ same document)
628+ :param string reply_to: optional value of Reply-To header
629+ :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
630+ must match the format of the ``body`` parameter. Default is 'plain',
631+ making the content part of the mail "text/plain".
632+ :param dict attachments: map of filename to filecontents, where filecontents is a string
633+ containing the bytes of the attachment
634+ :param dict headers: optional map of headers to set on the outgoing mail (may override the
635+ other headers, including Subject, Reply-To, Message-Id, etc.)
636+ :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
637+ :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
638+ successfully sent (default to False)
639+
640+ """
641+ if context is None:
642+ context = {}
643+ if attachments is None:
644+ attachments = {}
645+ attachment_obj = self.pool.get('ir.attachment')
646+ for param in (email_to, email_cc, email_bcc):
647+ if param and not isinstance(param, list):
648+ param = [param]
649+ msg_vals = {
650+ 'subject': subject,
651+ 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
652+ 'user_id': uid,
653+ 'model': model,
654+ 'res_id': res_id,
655+ 'body_text': body if subtype != 'html' else False,
656+ 'body_html': body if subtype == 'html' else False,
657+ 'email_from': email_from,
658+ 'return_path': return_path or email_from, # MODIF
659+ 'email_to': email_to and ','.join(email_to) or '',
660+ 'email_cc': email_cc and ','.join(email_cc) or '',
661+ 'email_bcc': email_bcc and ','.join(email_bcc) or '',
662+ 'reply_to': reply_to,
663+ 'message_id': message_id,
664+ 'references': references,
665+ 'subtype': subtype,
666+ 'headers': headers, # serialize the dict on the fly
667+ 'mail_server_id': mail_server_id,
668+ 'state': 'outgoing',
669+ 'auto_delete': auto_delete
670+ }
671+ if 'workitem_id' in context and context['workitem_id']:
672+ msg_vals.update({'workitem_id': context['workitem_id']})
673+ email_msg_id = self.create(cr, uid, msg_vals, context)
674+ attachment_ids = []
675+ for fname, fcontent in attachments.iteritems():
676+ attachment_data = {
677+ 'name': fname,
678+ 'datas_fname': fname,
679+ 'datas': fcontent and fcontent.encode('base64'),
680+ 'res_model': self._name,
681+ 'res_id': email_msg_id,
682+ }
683+ if 'default_type' in context:
684+ del context['default_type']
685+ attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
686+ if attachment_ids:
687+ self.write(cr, uid, email_msg_id, {'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
688+ return email_msg_id
689+
690+ def parse_message(self, message, save_original=False):
691+ """Parses a string or email.message.Message representing an
692+ RFC-2822 email, and returns a generic dict holding the
693+ message details.
694+
695+ :param message: the message to parse
696+ :type message: email.message.Message | string | unicode
697+ :param bool save_original: whether the returned dict
698+ should include an ``original`` entry with the base64
699+ encoded source of the message.
700+ :rtype: dict
701+ :return: A dict with the following structure, where each
702+ field may not be present if missing in original
703+ message::
704+
705+ { 'message-id': msg_id,
706+ 'subject': subject,
707+ 'from': from,
708+ 'to': to,
709+ 'cc': cc,
710+ 'headers' : { 'X-Mailer': mailer,
711+ #.. all X- headers...
712+ },
713+ 'subtype': msg_mime_subtype,
714+ 'body_text': plaintext_body
715+ 'body_html': html_body,
716+ 'attachments': [('file1', 'bytes'),
717+ ('file2', 'bytes') }
718+ # ...
719+ 'original': source_of_email,
720+ }
721+ """
722+ msg_txt = message
723+ if isinstance(message, str):
724+ msg_txt = email.message_from_string(message)
725+
726+ # Warning: message_from_string doesn't always work correctly on unicode,
727+ # we must use utf-8 strings here :-(
728+ if isinstance(message, unicode):
729+ message = message.encode('utf-8')
730+ msg_txt = email.message_from_string(message)
731+
732+ message_id = msg_txt.get('message-id', False)
733+ msg = {}
734+
735+ if save_original:
736+ # save original, we need to be able to read the original email sometimes
737+ msg['original'] = message.as_string() if isinstance(message, Message) \
738+ else message
739+ msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
740+
741+ if not message_id:
742+ # Very unusual situation, be we should be fault-tolerant here
743+ message_id = time.time()
744+ msg_txt['message-id'] = message_id
745+ _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
746+
747+ fields = msg_txt.keys()
748+ msg['id'] = message_id
749+ msg['message-id'] = message_id
750+
751+ if 'Subject' in fields:
752+ msg['subject'] = decode(msg_txt.get('Subject'))
753+
754+ if 'Content-Type' in fields:
755+ msg['content-type'] = msg_txt.get('Content-Type')
756+
757+ if 'From' in fields:
758+ msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
759+
760+ # MODIF
761+ if 'Return-Path' in fields:
762+ msg['return_path'] = decode(msg_txt.get('Return-Path')) or msg['from']
763+ # END MODIF
764+
765+ if 'To' in fields:
766+ msg['to'] = decode(msg_txt.get('To'))
767+
768+ if 'Delivered-To' in fields:
769+ msg['to'] = decode(msg_txt.get('Delivered-To'))
770+
771+ if 'CC' in fields:
772+ msg['cc'] = decode(msg_txt.get('CC'))
773+
774+ if 'Cc' in fields:
775+ msg['cc'] = decode(msg_txt.get('Cc'))
776+
777+ if 'Reply-To' in fields:
778+ msg['reply'] = decode(msg_txt.get('Reply-To'))
779+
780+ if 'Date' in fields:
781+ date_hdr = decode(msg_txt.get('Date'))
782+ msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
783+
784+ if 'Content-Transfer-Encoding' in fields:
785+ msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
786+
787+ if 'References' in fields:
788+ msg['references'] = msg_txt.get('References')
789+
790+ if 'In-Reply-To' in fields:
791+ msg['in-reply-to'] = msg_txt.get('In-Reply-To')
792+
793+ msg['headers'] = {}
794+ msg['subtype'] = 'plain'
795+ for item in msg_txt.items():
796+ if item[0].startswith('X-'):
797+ msg['headers'].update({item[0]: item[1]})
798+ if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
799+ encoding = msg_txt.get_content_charset()
800+ body = msg_txt.get_payload(decode=True)
801+ if 'text/html' in msg.get('content-type', ''):
802+ msg['body_html'] = body
803+ msg['subtype'] = 'html'
804+ body = tools.html2plaintext(body)
805+ msg['body_text'] = tools.ustr(body, encoding)
806+
807+ attachments = []
808+ if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
809+ body = ""
810+ if 'multipart/alternative' in msg.get('content-type', ''):
811+ msg['subtype'] = 'alternative'
812+ else:
813+ msg['subtype'] = 'mixed'
814+ for part in msg_txt.walk():
815+ if part.get_content_maintype() == 'multipart':
816+ continue
817+
818+ encoding = part.get_content_charset()
819+ filename = part.get_filename()
820+ if part.get_content_maintype()=='text':
821+ content = part.get_payload(decode=True)
822+ if filename:
823+ attachments.append((filename, content))
824+ content = tools.ustr(content, encoding)
825+ if part.get_content_subtype() == 'html':
826+ msg['body_html'] = content
827+ msg['subtype'] = 'html' # html version prevails
828+ body = tools.ustr(tools.html2plaintext(content))
829+ body = body.replace('&#13;', '')
830+ elif part.get_content_subtype() == 'plain':
831+ body = content
832+ elif part.get_content_maintype() in ('application', 'image'):
833+ if filename :
834+ attachments.append((filename,part.get_payload(decode=True)))
835+ else:
836+ res = part.get_payload(decode=True)
837+ body += tools.ustr(res, encoding)
838+
839+ msg['body_text'] = body
840+ msg['attachments'] = attachments
841+
842+ # for backwards compatibility:
843+ msg['body'] = msg['body_text']
844+ msg['sub_type'] = msg['subtype'] or 'plain'
845+ return msg
846+
847+ def send(self, cr, uid, ids, auto_commit=False, context=None):
848+ """Sends the selected emails immediately, ignoring their current
849+ state (mails that have already been sent should not be passed
850+ unless they should actually be re-sent).
851+ Emails successfully delivered are marked as 'sent', and those
852+ that fail to be deliver are marked as 'exception', and the
853+ corresponding error message is output in the server logs.
854+
855+ :param bool auto_commit: whether to force a commit of the message
856+ status after sending each message (meant
857+ only for processing by the scheduler),
858+ should never be True during normal
859+ transactions (default: False)
860+ :return: True
861+ """
862+ if context is None:
863+ context = {}
864+ ir_mail_server = self.pool.get('ir.mail_server')
865+ self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
866+ for message in self.browse(cr, uid, ids, context=context):
867+ try:
868+ attachments = []
869+ for attach in message.attachment_ids:
870+ attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
871+
872+ body = message.body_html if message.subtype == 'html' else message.body_text
873+ body_alternative = None
874+ subtype_alternative = None
875+ if message.subtype == 'html' and message.body_text:
876+ # we have a plain text alternative prepared, pass it to
877+ # build_message instead of letting it build one
878+ body_alternative = message.body_text
879+ subtype_alternative = 'plain'
880+
881+ msg = ir_mail_server.build_email(
882+ email_from=message.email_from,
883+ return_path=message.return_path, # MODIF
884+ email_to=to_email(message.email_to),
885+ subject=message.subject,
886+ body=body,
887+ body_alternative=body_alternative,
888+ email_cc=to_email(message.email_cc),
889+ email_bcc=to_email(message.email_bcc),
890+ reply_to=message.reply_to,
891+ attachments=attachments, message_id=message.message_id,
892+ references=message.references,
893+ object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
894+ subtype=message.subtype,
895+ subtype_alternative=subtype_alternative,
896+ headers=message.headers and ast.literal_eval(message.headers))
897+ res = ir_mail_server.send_email(cr, uid, msg,
898+ mail_server_id=message.mail_server_id.id,
899+ context=context)
900+ if res:
901+ message.write({'state':'sent', 'message_id': res})
902+ else:
903+ message.write({'state':'exception'})
904+
905+ # if auto_delete=True then delete that sent messages as well as attachments
906+ message.refresh()
907+ if message.state == 'sent' and message.auto_delete:
908+ self.pool.get('ir.attachment').unlink(cr, uid,
909+ [x.id for x in message.attachment_ids \
910+ if x.res_model == self._name and \
911+ x.res_id == message.id],
912+ context=context)
913+ message.unlink()
914+ except Exception:
915+ _logger.exception('failed sending mail.message %s', message.id)
916+ message.write({'state':'exception'})
917+
918+ if auto_commit == True:
919+ cr.commit()
920+ return True
921+
922+ def create(self, cr, uid, values, context=None):
923+ if 'workitem_id' in context:
924+ values.update({'workitem_id': context['workitem_id']})
925+ return super(mail_message, self).create(cr, uid, values, context)
926
927=== modified file 'marketing_campaign/marketing_campaign.py'
928--- marketing_campaign/marketing_campaign.py 2012-08-14 14:10:40 +0000
929+++ marketing_campaign/marketing_campaign.py 2012-08-27 10:03:27 +0000
930@@ -21,7 +21,7 @@
931
932 import time
933 import base64
934-import itertools
935+import hashlib
936 from datetime import datetime
937 from dateutil.relativedelta import relativedelta
938 from operator import itemgetter
939@@ -44,9 +44,11 @@
940
941 DT_FMT = '%Y-%m-%d %H:%M:%S'
942
943+
944 def dict_map(f, d):
945 return dict((k, f(v)) for k,v in d.items())
946
947+
948 def _find_fieldname(model, field):
949 inherit_columns = dict_map(itemgetter(2), model._inherit_fields)
950 all_columns = dict(inherit_columns, **model._columns)
951@@ -55,6 +57,14 @@
952 return fn
953 raise ValueError('Field not found: %r' % (field,))
954
955+
956+def compute_track_str(cr, uid, string, context=None):
957+ db_uuid = osv.pooler.get_pool(cr.dbname).get('ir.config_parameter').get_param(cr, uid, 'database.uuid')
958+ m = hashlib.md5()
959+ m.update(db_uuid + '_' + string)
960+ return m.hexdigest()
961+
962+
963 class selection_converter(object):
964 """Format the selection in the browse record objects"""
965 def __init__(self, value):
966@@ -88,6 +98,7 @@
967
968 _columns = {
969 'name': fields.char('Name', size=64, required=True),
970+ 'track_str': fields.char('Tracking String', 32),
971 'object_id': fields.many2one('ir.model', 'Resource', required=True,
972 help="Choose the resource on which you want \
973 this campaign to be run"),
974@@ -128,6 +139,13 @@
975 'mode': lambda *a: 'test',
976 }
977
978+ def create(self, cr, uid, vals, context=None):
979+ res_id = super(marketing_campaign, self).create(cr, uid, vals, context)
980+ track_str = compute_track_str(cr, uid, self._name + '_' + str(res_id))
981+ vals = {'track_str': track_str}
982+ self.pool.get(self._name).write(cr, uid, res_id, vals)
983+ return res_id
984+
985 def state_running_set(self, cr, uid, ids, *args):
986 # TODO check that all subcampaigns are running
987 campaign = self.browse(cr, uid, ids[0])
988@@ -386,6 +404,7 @@
989
990 marketing_campaign_segment()
991
992+
993 class marketing_campaign_activity(osv.osv):
994 _name = "marketing.campaign.activity"
995 _order = "name"
996@@ -403,6 +422,7 @@
997
998 _columns = {
999 'name': fields.char('Name', size=128, required=True),
1000+ 'track_str': fields.char('Tracking String', 32),
1001 'campaign_id': fields.many2one('marketing.campaign', 'Campaign',
1002 required = True, ondelete='cascade', select=1),
1003 'object_id': fields.related('campaign_id','object_id',
1004@@ -417,6 +437,8 @@
1005 " - resource: the resource object this campaign item represents\n"
1006 " - transitions: list of campaign transitions outgoing from this activity\n"
1007 "...- re: Python regular expression module"),
1008+ 'parent_response_cond': fields.char('Response to previous action', None),
1009+ 'parent_no_response': fields.boolean('Run only when no response to previous action', help='This does not include "mail sent", "mail opened" or "url viewed" responses.'),
1010 'type': fields.selection(_action_types, 'Type', required=True,
1011 help="""The type of action to execute when an item enters this activity, such as:
1012 - Email: send an email using a predefined email template
1013@@ -446,6 +468,7 @@
1014 _defaults = {
1015 'type': lambda *a: 'email',
1016 'condition': lambda *a: 'True',
1017+ 'parent_no_response': False,
1018 }
1019
1020 def search(self, cr, uid, args, offset=0, limit=None, order=None,
1021@@ -479,10 +502,40 @@
1022 return True
1023
1024 def _process_wi_email(self, cr, uid, activity, workitem, context=None):
1025- return self.pool.get('email.template').send_mail(cr, uid,
1026+ retval = None
1027+ send_mail = True
1028+
1029+ target_model = self.pool.get(activity.object_id['model'])
1030+ target = target_model.browse(cr, uid, workitem.res_id, context)
1031+ # TODO: make sure opt_out is called opt_out in all possible mail-receiving models
1032+ send_mail &= 'opt_out' in target and not target.opt_out
1033+
1034+ if activity.campaign_id.partner_field_id:
1035+ partner_id_field = activity.campaign_id.partner_field_id.name
1036+ partner = target.__getattr__(partner_id_field)
1037+ send_mail &= not partner.opt_out
1038+
1039+ if send_mail:
1040+ retval = self.pool.get('email.template').send_mail(cr, uid,
1041 activity.email_template_id.id,
1042 workitem.res_id, context=context)
1043
1044+ if retval:
1045+ self.pool.get('marketing.campaign.workitem').write(cr, uid, workitem.id, {'mail_sent': True})
1046+ tracklog_vals = {
1047+ 'workitem_id': workitem.id,
1048+ 'activity_id': workitem.activity_id.id,
1049+ 'res_track_id': workitem.trackitem_id.id,
1050+ 'mail_message_id': retval,
1051+ 'log_date': datetime.now().strftime(DT_FMT),
1052+ 'log_ip': '0.0.0.0',
1053+ 'log_type': 'mail_sent',
1054+ 'revenue': 0.0,
1055+ 'log_url': 'N/A',
1056+ }
1057+ self.pool.get('marketing.campaign.tracklog').create(cr, uid, tracklog_vals)
1058+ return retval
1059+
1060 #dead code
1061 def _process_wi_action(self, cr, uid, activity, workitem, context=None):
1062 if context is None:
1063@@ -511,6 +564,26 @@
1064 workitem = workitem_obj.browse(cr, uid, wi_id, context=context)
1065 return action(cr, uid, activity, workitem, context=context)
1066
1067+ def onchange_parent_no_response(self, cr, uid, ids, value, context=None):
1068+ parent_response_cond = None
1069+ if not value:
1070+ activities = self.pool.get(self._name).browse(cr, uid, ids, context)
1071+ if len(activities):
1072+ parent_response_cond = activities[0].parent_response_cond
1073+ return {'value': {'parent_response_cond': parent_response_cond}}
1074+
1075+ def write(self, cr, uid, ids, vals, context=None):
1076+ if 'parent_no_response' in vals and vals['parent_no_response']:
1077+ vals['parent_response_cond'] = None
1078+ return super(marketing_campaign_activity, self).write(cr, uid, ids, vals, context=context)
1079+
1080+ def create(self, cr, uid, vals, context=None):
1081+ res_id = super(marketing_campaign_activity, self).create(cr, uid, vals, context)
1082+ track_str = compute_track_str(cr, uid, self._name + '_' + str(res_id))
1083+ vals = {'track_str': track_str}
1084+ self.pool.get(self._name).write(cr, uid, res_id, vals, context=context)
1085+ return res_id
1086+
1087 marketing_campaign_activity()
1088
1089 class marketing_campaign_transition(osv.osv):
1090@@ -637,6 +710,7 @@
1091 return [('id', 'in', list(set(matching_workitems)))]
1092
1093 _columns = {
1094+ 'parent_id': fields.many2one('marketing.campaign.workitem', 'Parent'),
1095 'segment_id': fields.many2one('marketing.campaign.segment', 'Segment', readonly=True),
1096 'activity_id': fields.many2one('marketing.campaign.activity','Activity',
1097 required=True, readonly=True),
1098@@ -646,6 +720,8 @@
1099 type='many2one', relation='ir.model', string='Resource', select=1, readonly=True, store=True),
1100 'res_id': fields.integer('Resource ID', select=1, readonly=True),
1101 'res_name': fields.function(_res_name_get, string='Resource Name', fnct_search=_resource_search, type="char", size=64),
1102+ 'trackitem_id': fields.many2one('marketing.campaign.trackitem', 'Tracking Tag'),
1103+ 'tracklog_ids': fields.one2many('marketing.campaign.tracklog', 'workitem_id', 'Tracking Logs'),
1104 'date': fields.datetime('Execution Date', help='If date is not set, this workitem has to be run manually', readonly=True),
1105 'partner_id': fields.many2one('res.partner', 'Partner', select=1, readonly=True),
1106 'state': fields.selection([ ('todo', 'To Do'),
1107@@ -653,7 +729,15 @@
1108 ('exception', 'Exception'),
1109 ('done', 'Done'),
1110 ], 'Status', readonly=True),
1111- 'error_msg' : fields.text('Error Message', readonly=True)
1112+ 'error_msg' : fields.text('Error Message', readonly=True),
1113+ # Come from the tracklogs, but here for ease of reporting..
1114+ 'mail_sent': fields.boolean('Mail Sent'),
1115+ 'mail_opened': fields.boolean('Mail Opened'),
1116+ 'mail_forwarded': fields.boolean('Mail Forwarded'),
1117+ 'mail_bounced': fields.boolean('Mail Bounced'),
1118+ 'subscribers': fields.integer('Subscribers'),
1119+ 'unsubscribed': fields.boolean('Unsubscribed'),
1120+ 'clicks': fields.integer('Page Views'),
1121 }
1122 _defaults = {
1123 'state': lambda *a: 'todo',
1124@@ -698,6 +782,35 @@
1125 else:
1126 workitem.unlink(context=context)
1127 return
1128+ # Make it easy for the user to express conditions
1129+ # First: absense of response
1130+ if activity.parent_no_response:
1131+ searchvals = [
1132+ ('workitem_id', '=', workitem.parent_id.id),
1133+ ('log_type', 'not in', ['mail_sent', 'mail_opened', 'url_viewed']),
1134+ ]
1135+ parent_responses = self.pool.get('marketing.campaign.tracklog').search(cr, uid, searchvals, context=context)
1136+ if len(parent_responses):
1137+ if activity.keep_if_condition_not_met:
1138+ workitem.write({'state': 'cancelled'}, context=context)
1139+ else:
1140+ workitem.unlink(context=context)
1141+ return
1142+ # Next, specific response
1143+ parent_response_cond = activity.parent_response_cond
1144+ if parent_response_cond and workitem.parent_id:
1145+ searchvals = [
1146+ ('workitem_id', '=', workitem.parent_id.id),
1147+ ('log_type', '=', parent_response_cond),
1148+ ]
1149+ parent_responses = self.pool.get('marketing.campaign.tracklog').search(cr, uid, searchvals, context=context)
1150+ if not len(parent_responses):
1151+ if activity.keep_if_condition_not_met:
1152+ workitem.write({'state': 'cancelled'}, context=context)
1153+ else:
1154+ workitem.unlink(context=context)
1155+ return
1156+ # End modif
1157 result = True
1158 if campaign_mode in ('manual', 'active'):
1159 Activities = self.pool.get('marketing.campaign.activity')
1160@@ -711,7 +824,7 @@
1161
1162 if result:
1163 # process _chain
1164- workitem = workitem.browse(context=context)[0] # reload
1165+ workitem = workitem.browse(context=context)[0] # reload
1166 date = datetime.strptime(workitem.date, DT_FMT)
1167
1168 for transition in activity.to_ids:
1169@@ -732,6 +845,7 @@
1170 'partner_id': workitem.partner_id.id,
1171 'res_id': workitem.res_id,
1172 'state': 'todo',
1173+ 'parent_id': workitem.id, # Keep track of parent workitem so we can use that for condition lookups
1174 }
1175 wi_id = self.create(cr, uid, values, context=context)
1176
1177@@ -760,7 +874,18 @@
1178 context=context)
1179
1180 def process(self, cr, uid, workitem_ids, context=None):
1181+ if context is None:
1182+ context = {}
1183 for wi in self.browse(cr, uid, workitem_ids, context=context):
1184+ campaign_track_str = wi.campaign_id.track_str
1185+ activity_track_str = wi.activity_id.track_str
1186+ res_track_str = wi.trackitem_id.track_str
1187+ context.update({
1188+ 'campaign_track_str': campaign_track_str,
1189+ 'activity_track_str': activity_track_str,
1190+ 'res_track_str': res_track_str,
1191+ 'workitem_id': wi.id,
1192+ })
1193 self._process_one(cr, uid, wi, context=context)
1194 return True
1195
1196@@ -819,17 +944,55 @@
1197 raise osv.except_osv(_('No preview'),_('The current step for this item has no email or report to preview.'))
1198 return res
1199
1200+ def create(self, cr, uid, vals, context=None):
1201+ # Get model we're working on via our associated activity
1202+ activity_obj = self.pool.get('marketing.campaign.activity').browse(cr, uid, vals['activity_id'])
1203+ search_domain = [
1204+ ('model_id', '=', activity_obj.object_id.id),
1205+ ('res_id', '=', vals['res_id']),
1206+ ]
1207+ trackitem_obj = self.pool.get('marketing.campaign.trackitem')
1208+ found_trackitems = trackitem_obj.search(cr, uid, search_domain)
1209+ # And if no trackitem is fond, corresponding the criteria, create one
1210+ if len(found_trackitems):
1211+ trackitem_id = found_trackitems[0]
1212+ else:
1213+ wvals = {
1214+ 'model_id': activity_obj.object_id.id,
1215+ 'res_id': vals['res_id'],
1216+ }
1217+ trackitem_id = trackitem_obj.create(cr, uid, wvals, context)
1218+ vals.update({'trackitem_id': trackitem_id})
1219+ return super(marketing_campaign_workitem, self).create(cr, uid, vals, context)
1220+
1221 marketing_campaign_workitem()
1222
1223-class email_template(osv.osv):
1224- _inherit = "email.template"
1225- _defaults = {
1226- 'model_id': lambda obj, cr, uid, context: context.get('object_id',False),
1227+
1228+class marketing_campaign_trackitem(osv.osv):
1229+ _name = 'marketing.campaign.trackitem'
1230+ _rec_name = 'track_str'
1231+ _description = 'Tracking Tags'
1232+
1233+ _columns = {
1234+ 'model_id': fields.many2one('ir.model', 'Resource', required=True),
1235+ 'res_id': fields.integer('Resource ID', required=True),
1236+ 'track_str': fields.char('Resource Code', 32),
1237 }
1238
1239- # TODO: add constraint to prevent disabling / disapproving an email account used in a running campaign
1240-
1241-email_template()
1242+ def create(self, cr, uid, vals, context=None):
1243+ res_id = super(marketing_campaign_trackitem, self).create(cr, uid, vals, context)
1244+ ir_model = self.pool.get('ir.model').browse(cr, uid, vals['model_id'], context=context)
1245+ model_name = ir_model.model
1246+ if 'track_str' not in vals:
1247+ res_track_str = compute_track_str(cr, uid, model_name + '_' + str(vals['res_id']))
1248+ wvals = {
1249+ 'track_str': res_track_str,
1250+ }
1251+ self.pool.get('marketing.campaign.trackitem').write(cr, uid, res_id, wvals, context)
1252+ return res_id
1253+
1254+marketing_campaign_trackitem()
1255+
1256
1257 class report_xml(osv.osv):
1258 _inherit = 'ir.actions.report.xml'
1259
1260=== modified file 'marketing_campaign/marketing_campaign_view.xml'
1261--- marketing_campaign/marketing_campaign_view.xml 2012-08-08 13:06:14 +0000
1262+++ marketing_campaign/marketing_campaign_view.xml 2012-08-27 10:03:27 +0000
1263@@ -261,6 +261,8 @@
1264 </group>
1265 <group >
1266 <group>
1267+ <field name="parent_response_cond" attrs="{'readonly': [('parent_no_response', '=', True)]}"/>
1268+ <field name="parent_no_response" on_change="onchange_parent_no_response(parent_no_response)"/>
1269 <field name="condition" widget="char"/>
1270 <field name="keep_if_condition_not_met"/>
1271 </group>
1272
1273=== modified file 'marketing_campaign/report/__init__.py'
1274--- marketing_campaign/report/__init__.py 2011-01-14 00:11:01 +0000
1275+++ marketing_campaign/report/__init__.py 2012-08-27 10:03:27 +0000
1276@@ -20,5 +20,6 @@
1277 ##############################################################################
1278
1279 import campaign_analysis
1280+import campaign_tracking
1281 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
1282
1283
1284=== modified file 'marketing_campaign/report/campaign_analysis.py'
1285--- marketing_campaign/report/campaign_analysis.py 2012-07-10 12:34:00 +0000
1286+++ marketing_campaign/report/campaign_analysis.py 2012-08-27 10:03:27 +0000
1287@@ -43,6 +43,7 @@
1288 ((ca_obj.campaign_id.fixed_cost or 1.00) / len(wi_ids))
1289 result[ca_obj.id] = total_cost
1290 return result
1291+
1292 _columns = {
1293 'res_id' : fields.integer('Resource', readonly=True),
1294 'year': fields.char('Year', size=4, readonly=True),
1295@@ -66,17 +67,32 @@
1296 type="float", digits_compute=dp.get_precision('Account')),
1297 'revenue': fields.float('Revenue', readonly=True, digits_compute=dp.get_precision('Account')),
1298 'count' : fields.integer('# of Actions', readonly=True),
1299- 'state': fields.selection([('todo', 'To Do'),
1300- ('exception', 'Exception'), ('done', 'Done'),
1301- ('cancelled', 'Cancelled')], 'Status', readonly=True),
1302+ # 'state': fields.selection([('todo', 'To Do'),
1303+ # ('exception', 'Exception'), ('done', 'Done'),
1304+ # ('cancelled', 'Cancelled')], 'Status', readonly=True),
1305+ # TODO: clean these up; don't think we still use them all
1306+ 'mail_sent': fields.integer('Mail Sent'),
1307+ 'mail_sent_bool': fields.boolean('Mail Sent B'),
1308+ 'mail_opened': fields.integer('Mail Opened'),
1309+ 'mail_opened_bool': fields.boolean('Mail Opened B'),
1310+ 'mail_forwarded': fields.integer('Mail Forwarded'),
1311+ 'mail_forwarded_bool': fields.boolean('Mail Forwarded B'),
1312+ 'mail_bounced': fields.integer('Mail Bounced'),
1313+ 'mail_bounced_bool': fields.boolean('Mail Bounced B'),
1314+ # 'subscribers': fields.integer('Subscribers'),
1315+ 'unsubscribed': fields.integer('Unsubscribed'),
1316+ 'unsubscribed_bool': fields.boolean('Unsubscribed B'),
1317+ 'clicks': fields.integer('Page Views'),
1318 }
1319+
1320 def init(self, cr):
1321- tools.drop_view_if_exists(cr, 'campaign_analysis')
1322- cr.execute("""
1323- create or replace view campaign_analysis as (
1324+ tools.drop_view_if_exists(cr, self._table)
1325+ args = (self._table)
1326+ query = """
1327+ create or replace view %s as (
1328 select
1329- min(wi.id) as id,
1330- min(wi.res_id) as res_id,
1331+ wi.id as id,
1332+ wi.res_id as res_id,
1333 to_char(wi.date::date, 'YYYY') as year,
1334 to_char(wi.date::date, 'MM') as month,
1335 to_char(wi.date::date, 'YYYY-MM-DD') as day,
1336@@ -85,19 +101,82 @@
1337 wi.activity_id as activity_id,
1338 wi.segment_id as segment_id,
1339 wi.partner_id as partner_id ,
1340- wi.state as state,
1341- sum(act.revenue) as revenue,
1342- count(*) as count
1343+ wi.mail_sent as mail_sent_bool,
1344+ wi.mail_sent::int as mail_sent,
1345+ wi.mail_opened as mail_opened_bool,
1346+ wi.mail_opened::int as mail_opened,
1347+ wi.mail_forwarded as mail_forwarded_bool,
1348+ wi.mail_forwarded::int as mail_forwarded,
1349+ wi.mail_bounced as mail_bounced_bool,
1350+ wi.mail_bounced::int as mail_bounced,
1351+ wi.unsubscribed as unsubscribed_bool,
1352+ wi.unsubscribed::int as unsubscribed,
1353+ (select count(*) from marketing_campaign_tracklog tl
1354+ where tl.log_type = 'url_viewed'
1355+ and tl.workitem_id = wi.id)
1356+ as clicks,
1357+ coalesce(act.revenue +
1358+ (select sum(revenue) from marketing_campaign_tracklog tl
1359+ where tl.workitem_id = wi.id),
1360+ 0.0) as revenue
1361 from
1362 marketing_campaign_workitem wi
1363 left join res_partner p on (p.id=wi.partner_id)
1364 left join marketing_campaign_segment s on (s.id=wi.segment_id)
1365 left join marketing_campaign_activity act on (act.id= wi.activity_id)
1366 group by
1367- s.campaign_id,wi.activity_id,wi.segment_id,wi.partner_id,wi.state,
1368- wi.date::date
1369+ wi.id,wi.res_id,wi.date,wi.activity_id,wi.segment_id,wi.partner_id,wi.mail_sent,wi.mail_opened,wi.mail_forwarded,wi.mail_bounced,wi.unsubscribed,act.revenue,s.campaign_id,act.id
1370 )
1371- """)
1372+ """ % args
1373+ cr.execute(query)
1374+
1375 campaign_analysis()
1376
1377+class campaign_click_analysis(osv.osv):
1378+ _name = "campaign.click_analysis"
1379+ _inherit = "campaign.analysis"
1380+ _description = 'Click Analysis'
1381+ _auto = False
1382+
1383+ _columns = {
1384+ # 'subscribers': fields.integer('Subscribers'),
1385+ 'log_url': fields.char('URL', None),
1386+ 'clicks': fields.integer('Page Views'),
1387+ }
1388+
1389+ def init(self, cr):
1390+ tools.drop_view_if_exists(cr, self._table)
1391+ args = (self._table)
1392+ query = """
1393+ create or replace view %s as (
1394+ select
1395+ tl.id as id,
1396+ wi.id as wi_id,
1397+ tl.res_id as res_id,
1398+ to_char(tl.log_date::date, 'YYYY') as year,
1399+ to_char(tl.log_date::date, 'MM') as month,
1400+ to_char(tl.log_date::date, 'YYYY-MM-DD') as day,
1401+ tl.log_date::date as date,
1402+ s.campaign_id as campaign_id,
1403+ wi.activity_id as activity_id,
1404+ wi.segment_id as segment_id,
1405+ wi.partner_id as partner_id ,
1406+ 1 as clicks,
1407+ coalesce(tl.log_url, 'N/A') as log_url,
1408+ coalesce(act.revenue + tl.revenue, 0.0) as revenue
1409+ from
1410+ marketing_campaign_tracklog tl
1411+ left join marketing_campaign_workitem wi on (tl.workitem_id = wi.id)
1412+ left join res_partner p on (p.id=wi.partner_id)
1413+ left join marketing_campaign_segment s on (s.id=wi.segment_id)
1414+ left join marketing_campaign_activity act on (act.id= wi.activity_id)
1415+ where tl.log_type = 'url_viewed'
1416+ group by
1417+ tl.id,wi.id,tl.res_id,tl.log_date,s.campaign_id,wi.activity_id,wi.segment_id,wi.partner_id,tl.log_url,act.revenue,tl.revenue,act.id
1418+ )
1419+ """ % args
1420+ cr.execute(query)
1421+
1422+campaign_click_analysis()
1423+
1424 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
1425
1426=== modified file 'marketing_campaign/report/campaign_analysis_view.xml'
1427--- marketing_campaign/report/campaign_analysis_view.xml 2012-08-08 13:06:14 +0000
1428+++ marketing_campaign/report/campaign_analysis_view.xml 2012-08-27 10:03:27 +0000
1429@@ -10,14 +10,24 @@
1430 <field name="month" invisible="1"/>
1431 <field name="day" invisible="1"/>
1432 <field name="date" invisible="1"/>
1433- <field name="state" invisible="1"/>
1434+ <!-- <field name="state" invisible="1"/> -->
1435 <field name="campaign_id" invisible="1"/>
1436 <field name="activity_id" invisible="1"/>
1437 <field name="segment_id" invisible="1"/>
1438 <field name="partner_id" invisible="1"/>
1439 <field name="country_id" invisible="1"/>
1440 <field name="res_id" invisible="1"/>
1441- <field name="count"/>
1442+ <field name="mail_sent"/>
1443+ <field name="mail_sent_bool" invisible="1"/>
1444+ <field name="mail_opened"/>
1445+ <field name="mail_opened_bool" invisible="1"/>
1446+ <field name="mail_forwarded"/>
1447+ <field name="mail_forwarded_bool" invisible="1"/>
1448+ <field name="mail_bounced"/>
1449+ <field name="mail_bounced_bool" invisible="1"/>
1450+ <field name="unsubscribed"/>
1451+ <field name="unsubscribed_bool" invisible="1"/>
1452+ <field name="clicks"/>
1453 <field name="total_cost" string="Cost"/><!-- sum="Cost"/-->
1454 <field name="revenue"/>
1455 </tree>
1456@@ -29,6 +39,7 @@
1457 <field name="model">campaign.analysis</field>
1458 <field name="arch" type="xml">
1459 <search string="Campaign Analysis">
1460+<<<<<<< TREE
1461 <field name="date"/>
1462 <filter icon="terp-gtk-go-back-rtl" string="To Do" domain="[('state','=','todo')]"/>
1463 <filter icon="terp-dialog-close" string="Done" domain="[('state','=','done')]"/>
1464@@ -38,12 +49,45 @@
1465 <field name="segment_id"/>
1466 <field name="partner_id"/>
1467 <field name="country_id"/>
1468+=======
1469+ <group>
1470+ <field name="date"/>
1471+<!-- <separator orientation="vertical"/>
1472+ <filter icon="terp-gtk-go-back-rtl"
1473+ string="To Do"
1474+ domain="[('state','=','todo')]"/>
1475+ <filter icon="terp-dialog-close"
1476+ string="Done"
1477+ domain="[('state','=','done')]"/>
1478+ <filter icon="terp-emblem-important"
1479+ string="Exceptions"
1480+ domain="[('state','=','exception')]"/>
1481+ --> <separator orientation="vertical"/>
1482+ <field name="campaign_id"/>
1483+ <field name="activity_id"/>
1484+ <field name="segment_id"/>
1485+ <field name="partner_id"/>
1486+ <field name="country_id"/>
1487+ </group>
1488+ <newline/>
1489+>>>>>>> MERGE-SOURCE
1490 <group expand="0" string="Group By...">
1491 <filter string="Campaign" name="Campaign" icon="terp-gtk-jump-to-rtl" context="{'group_by':'campaign_id'}" />
1492 <filter string="Segment" name="Segment" icon="terp-stock_symbol-selection" context="{'group_by':'segment_id'}"/>
1493 <filter string="Activity" name="activity" icon="terp-stock_align_left_24" context="{'group_by':'activity_id'}"/>
1494 <filter string="Resource" icon="terp-accessories-archiver" context="{'group_by':'res_id'}"/>
1495+<<<<<<< TREE
1496 <filter string="Status" icon="terp-stock_effects-object-colorize" context="{'group_by':'state'}"/>
1497+=======
1498+ <separator orientation="vertical"/>
1499+ <filter string="Mail Sent" icon="terp-mail-" context="{'group_by':'mail_sent_bool'}"/>
1500+ <filter string="Mail Opened" icon="terp-mail-message-new" context="{'group_by':'mail_opened_bool'}"/>
1501+ <filter string="Mail Forwarded" icon="terp-mail-forward" context="{'group_by':'mail_forwarded_bool'}"/>
1502+ <filter string="Mail Bounced" icon="terp-mail_delete" context="{'group_by':'mail_bounced_bool'}"/>
1503+ <filter string="Unsubscribed" icon="terp-mail_delete" context="{'group_by':'unsubscribed_bool'}"/>
1504+
1505+ <separator orientation="vertical"/>
1506+>>>>>>> MERGE-SOURCE
1507 <filter string="Partner" icon="terp-partner" context="{'group_by':'partner_id'}"/>
1508 <filter string="Day" icon="terp-go-today" context="{'group_by':'day'}"/>
1509 <filter string="Month" icon="terp-go-month" context="{'group_by':'month'}"/>
1510@@ -53,17 +97,97 @@
1511 </field>
1512 </record>
1513
1514- <record id="action_campaign_analysis_all" model="ir.actions.act_window">
1515- <field name="name">Campaign Analysis</field>
1516- <field name="res_model">campaign.analysis</field>
1517- <field name="view_type">form</field>
1518- <field name="view_mode">tree</field>
1519- <field name="context">{'search_default_year': 1,"search_default_This Month":1,'group_by': [], 'search_default_Campaign': 1, 'search_default_Segment': 1, 'group_by_no_leaf':1}</field>
1520- <field name="search_view_id" ref="view_campaign_analysis_search"/>
1521- </record>
1522+
1523+ <record id="view_campaign_click_analysis_tree" model="ir.ui.view">
1524+ <field name="name">campaign.click_analysis.tree</field>
1525+ <field name="model">campaign.click_analysis</field>
1526+ <field name="type">tree</field>
1527+ <field name="arch" type="xml">
1528+ <tree string="Marketing Reports">
1529+ <field name="year" invisible="1"/>
1530+ <field name="month" invisible="1"/>
1531+ <field name="day" invisible="1"/>
1532+ <field name="date" invisible="1"/>
1533+ <!-- <field name="state" invisible="1"/> -->
1534+ <field name="campaign_id" invisible="1"/>
1535+ <field name="activity_id" invisible="1"/>
1536+ <field name="segment_id" invisible="1"/>
1537+ <field name="partner_id" invisible="1"/>
1538+ <!-- <field name="country_id" invisible="1"/> -->
1539+ <field name="res_id" invisible="1"/>
1540+ <field name="log_url" invisible="1"/>
1541+ <field name="total_cost" string="Cost"/><!-- sum="Cost"/-->
1542+ <field name="revenue"/>
1543+ </tree>
1544+ </field>
1545+ </record>
1546+
1547+ <record id="view_campaign_click_analysis_search" model="ir.ui.view">
1548+ <field name="name">campaign.click_analysis.search</field>
1549+ <field name="model">campaign.click_analysis</field>
1550+ <field name="type">search</field>
1551+ <field name="arch" type="xml">
1552+ <search string="Click Analysis">
1553+ <group>
1554+ <filter icon="terp-go-year" name="year"
1555+ string="Year"
1556+ domain="[('year','=',time.strftime('%%Y'))]"/>
1557+ <separator orientation="vertical"/>
1558+
1559+ <filter icon="terp-go-month"
1560+ string="Month" name="This Month"
1561+ domain="[('month','=',time.strftime('%%m'))]"/>
1562+ <filter icon="terp-go-month" string=" Month-1 "
1563+ domain="[('create_date','&lt;=', (datetime.date.today() - relativedelta(day=31, months=1)).strftime('%%Y-%%m-%%d')),('create_date','&gt;=',(datetime.date.today() - relativedelta(day=1,months=1)).strftime('%%Y-%%m-%%d'))]"/>
1564+
1565+ <filter icon="terp-go-today"
1566+ string="Today"
1567+ domain="[('date','=',time.strftime('%%Y/%%m/%%d'))]"/>
1568+ <separator orientation="vertical"/>
1569+ <field name="campaign_id"/>
1570+ <field name="activity_id"/>
1571+ <field name="segment_id"/>
1572+ <field name="partner_id"/>
1573+ <field name="country_id"/>
1574+ </group>
1575+ <newline/>
1576+ <group expand="0" string="Group By...">
1577+ <filter string="Campaign" name="Campaign" icon="terp-gtk-jump-to-rtl" context="{'group_by':'campaign_id'}" />
1578+ <filter string="Segment" name="Segment" icon="terp-stock_symbol-selection" context="{'group_by':'segment_id'}" />
1579+ <filter string="Activity" name="activity" icon="terp-stock_align_left_24" context="{'group_by':'activity_id'}" />
1580+ <filter string="Resource" icon="terp-accessories-archiver" context="{'group_by':'res_id'}"/>
1581+ <filter string="url" name="URL" icon="STOCK_FILE" context="{'group_by':'log_url'}"/>
1582+ <separator orientation="vertical"/>
1583+ <filter string="Partner" icon="terp-partner" context="{'group_by':'partner_id'}"/>
1584+ <separator orientation="vertical"/>
1585+ <filter string="Day" icon="terp-go-today" context="{'group_by':'day'}"/>
1586+ <filter string="Month" icon="terp-go-month" context="{'group_by':'month'}"/>
1587+ <filter string="Year" icon="terp-go-year" context="{'group_by':'year'}"/>
1588+ </group>
1589+ </search>
1590+ </field>
1591+ </record>
1592+
1593+ <record id="action_campaign_analysis_all" model="ir.actions.act_window">
1594+ <field name="name">E-Mail Analysis</field>
1595+ <field name="res_model">campaign.analysis</field>
1596+ <field name="view_type">form</field>
1597+ <field name="view_mode">tree</field>
1598+ <field name="context">{'search_default_year': 1,"search_default_This Month":1,'group_by': [], 'search_default_Campaign': 1, 'search_default_Segment': 1, 'group_by_no_leaf':1}</field>
1599+ <field name="search_view_id" ref="view_campaign_analysis_search"/>
1600+ </record>
1601+
1602+ <record id="action_campaign_click_analysis" model="ir.actions.act_window">
1603+ <field name="name">Click Analysis</field>
1604+ <field name="res_model">campaign.click_analysis</field>
1605+ <field name="view_type">form</field>
1606+ <field name="view_mode">tree</field>
1607+ <field name="context">{'search_default_year': 1,"search_default_This Month":1,'group_by': [], 'search_default_Campaign': 1, 'search_default_Segment': 1, 'search_default_URL': 1, 'group_by_no_leaf':1}</field>
1608+ <field name="search_view_id" ref="view_campaign_click_analysis_search"/>
1609+ </record>
1610
1611 <menuitem name="Marketing" id="base.menu_report_marketing" parent="base.menu_reporting" sequence="45"/>
1612 <menuitem action="action_campaign_analysis_all" id="menu_action_campaign_analysis_all" parent="base.menu_report_marketing" sequence="2"/>
1613-
1614+ <menuitem action="action_campaign_click_analysis" id="menu_action_campaign_click_analysis" parent="base.menu_report_marketing" sequence="3"/>
1615 </data>
1616 </openerp>
1617
1618=== added file 'marketing_campaign/report/campaign_tracking.py'
1619--- marketing_campaign/report/campaign_tracking.py 1970-01-01 00:00:00 +0000
1620+++ marketing_campaign/report/campaign_tracking.py 2012-08-27 10:03:27 +0000
1621@@ -0,0 +1,188 @@
1622+from osv import osv, fields
1623+import re
1624+from datetime import datetime
1625+
1626+
1627+class campaign_tracklog(osv.osv):
1628+ _name = 'marketing.campaign.tracklog'
1629+ _description = 'Campaign Logs'
1630+
1631+ # These are copy/pasted from marketing.campaign.workitem
1632+ def _res_name_get(self, cr, uid, ids, field_name, arg, context=None):
1633+ res = dict.fromkeys(ids, 'N/A')
1634+ for tl in self.browse(cr, uid, ids, context=context):
1635+ if not tl.workitem_id:
1636+ continue
1637+ for wi in self.pool.get('marketing.campaign.workitem').browse(cr, uid, [tl.workitem_id.id], context=context):
1638+ if not wi.res_id:
1639+ continue
1640+
1641+ proxy = self.pool.get(wi.object_id.model)
1642+ if not proxy.exists(cr, uid, [wi.res_id]):
1643+ continue
1644+ ng = proxy.name_get(cr, uid, [wi.res_id], context=context)
1645+ if ng:
1646+ res[tl.id] = ng[0][1]
1647+ return res
1648+
1649+ def _resource_search(self, cr, uid, obj, name, args, domain=None, context=None):
1650+ """Returns id of tracklogs whose resource_name matches with the given name"""
1651+ if not len(args):
1652+ return []
1653+
1654+ condition_name = None
1655+ for domain_item in args:
1656+ # we only use the first domain criterion and ignore all the rest including operators
1657+ if isinstance(domain_item, (list,tuple)) and len(domain_item) == 3 and domain_item[0] == 'res_name':
1658+ condition_name = [None, domain_item[1], domain_item[2]]
1659+ break
1660+
1661+ assert condition_name, "Invalid search domain for marketing_campaign_workitem.res_name. It should use 'res_name'"
1662+
1663+ cr.execute("""select w.id, w.res_id, m.model \
1664+ from marketing_campaign_workitem w \
1665+ left join marketing_campaign_activity a on (a.id=w.activity_id)\
1666+ left join marketing_campaign c on (c.id=a.campaign_id)\
1667+ left join ir_model m on (m.id=c.object_id)
1668+ """)
1669+ res = cr.fetchall()
1670+ workitem_map = {}
1671+ matching_workitems = []
1672+ for id, res_id, model in res:
1673+ workitem_map.setdefault(model,{}).setdefault(res_id,set()).add(id)
1674+ for model, id_map in workitem_map.iteritems():
1675+ model_pool = self.pool.get(model)
1676+ condition_name[0] = model_pool._rec_name
1677+ condition = [('id', 'in', id_map.keys()), condition_name]
1678+ for res_id in model_pool.search(cr, uid, condition, context=context):
1679+ matching_workitems.extend(id_map[res_id])
1680+ return [('workitem_id', 'in', list(set(matching_workitems)))]
1681+ # end copy/paste
1682+
1683+ _columns = {
1684+ 'workitem_id': fields.many2one('marketing.campaign.workitem', 'Work Item', ondelete="cascade"),
1685+ 'activity_id': fields.many2one('marketing.campaign.activity', 'Campaign Activity', ondelete="cascade"),
1686+ 'campaign_id': fields.related('activity_id', 'campaign_id', type='many2one', relation='marketing.campaign', string='Campaign', store=True),
1687+ 'res_track_id': fields.many2one('marketing.campaign.trackitem', 'Tracking Tag'),
1688+ 'model_id': fields.related('res_track_id', 'model_id', type='many2one', relation='ir.model', string='Resource Type', store=True),
1689+ 'res_id': fields.related('res_track_id', 'res_id', type='integer', string='Resource ID', store=True),
1690+ 'res_name': fields.function(_res_name_get, string='Resource Name', fnct_search=_resource_search, type="char", size=None),
1691+ 'mail_message_id': fields.many2one('mail.message', 'Mail Message'),
1692+ 'log_date': fields.datetime('Logged Date'),
1693+ 'log_ip': fields.char('Logged IP', None),
1694+ 'log_type': fields.char('Access Type', None),
1695+ 'revenue': fields.float('Revenue', digits=(0,2)),
1696+ 'log_url': fields.char('Logged URL', None),
1697+ }
1698+
1699+ _defaults = {
1700+ 'log_ip': '0.0.0.0',
1701+ 'log_date': lambda *a: datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
1702+ 'revenue': 0,
1703+ 'log_url': 'N/A',
1704+ }
1705+
1706+ def onchange_activity(self, cr, uid, ids, activity_id, context=None):
1707+ value = {}
1708+ if activity_id:
1709+ activity = self.pool.get('marketing.campaign.activity').browse(cr, uid, activity_id, context)
1710+ value['campaign_id'] = activity.campaign_id.id
1711+ value['model_id'] = activity.object_id.id
1712+ return {'value': value}
1713+
1714+ def onchange_trackitem(self, cr, uid, ids, res_track_id, context=None):
1715+ value = {}
1716+ if res_track_id:
1717+ trackitem = self.pool.get('marketing.campaign.trackitem').browse(cr, uid, res_track_id, context)
1718+ value['model_id'] = trackitem.model_id.id
1719+ value['res_id'] = trackitem.res_id
1720+ if trackitem.res_id:
1721+ target_model = self.pool.get(trackitem.model_id['model'])
1722+ discard, value['res_name'] = target_model.name_get(cr, uid, [trackitem.res_id], context=context)[0]
1723+ return {'value': value}
1724+
1725+ def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1726+ my_id = campaign_id = activity_id = trackitem_id = None
1727+ to_user = msg_dict['to'].split('@')[0]
1728+ if len(to_user.split('+')) > 1:
1729+ # Track string encoded in return-path's address tag
1730+ track_str = re.sub('=', '@', to_user.split('+')[1])
1731+ if len(track_str.split('-')) == 3:
1732+ campaign_str, activity_str, trackitem_str = track_str.split('-')
1733+ # Campaign
1734+ campaign_ids = self.pool.get('marketing.campaign').search(cr, uid, [('track_str', '=', campaign_str)])
1735+ if len(campaign_ids):
1736+ campaign_id = campaign_ids[0]
1737+ # Activity
1738+ activity_ids = self.pool.get('marketing.campaign.activity').search(cr, uid, [('track_str', '=', activity_str)])
1739+ if len(activity_ids):
1740+ activity_id = activity_ids[0]
1741+ # Trackitem
1742+ trackitem_ids = self.pool.get('marketing.campaign.trackitem').search(cr, uid, [('track_str', '=', trackitem_str)])
1743+ if len(trackitem_ids):
1744+ trackitem_id = trackitem_ids[0]
1745+ if campaign_id is not None and activity_id is not None and trackitem_id is not None:
1746+ wi_search_vals = [('campaign_id', '=', campaign_id),
1747+ ('activity_id', '=', activity_id),
1748+ ('trackitem_id', '=', trackitem_id)]
1749+ workitem_ids = self.pool.get('marketing.campaign.workitem').search(cr, uid, wi_search_vals)
1750+ if len(workitem_ids):
1751+ workitem_id = workitem_ids[0]
1752+ my_create_vals = {
1753+ 'res_track_id': trackitem_id,
1754+ 'log_type': 'mail_bounced',
1755+ 'activity_id': activity_id,
1756+ 'workitem_id': workitem_id,
1757+ }
1758+ my_id = self.create(cr, uid, my_create_vals)
1759+ return my_id
1760+
1761+ def create(self, cr, uid, vals, context=None):
1762+ # Check which workitem we belong to
1763+ res_id = self.pool.get('marketing.campaign.trackitem').browse(cr, uid, vals['res_track_id']).res_id
1764+ args = [
1765+ ('activity_id', '=', vals['activity_id']),
1766+ ('res_id', '=', res_id),
1767+ ]
1768+ wi_obj = self.pool.get('marketing.campaign.workitem')
1769+ wi_id = wi_obj.search(cr, uid, args, context=context)[0]
1770+ vals.update({'workitem_id': wi_id})
1771+ # Check which mail message we belong to
1772+ mail_msg_obj = self.pool.get('mail.message')
1773+ mail_msg_ids = mail_msg_obj.search(cr, uid, [('workitem_id', '=', wi_id)])
1774+ if len(mail_msg_ids):
1775+ vals.update({'mail_message_id': mail_msg_ids[0]})
1776+ retval = super(campaign_tracklog, self).create(cr, uid, vals, context=context)
1777+
1778+ # Update related workitem if necessary
1779+ wvals = {}
1780+ if vals['log_type'] in ['mail_opened', 'mail_forwarded']:
1781+ wvals.update({'mail_opened': True})
1782+ # If opened from multiple IPs, consider it a forward.
1783+ # We're probably never gonna see "mail_forwarded" in the wild
1784+ fwd_search = [
1785+ ('workitem_id', '=', wi_id),
1786+ ('log_type', '=', 'mail_opened'),
1787+ ('log_ip', '!=', vals['log_ip']),
1788+ ]
1789+ if len(self.pool.get(self._name).search(cr, uid, fwd_search)) \
1790+ or vals['log_type'] == 'mail_forwarded':
1791+ wvals.update({'mail_forwarded': True})
1792+ # We're probably never gonna see "mail_bounced" in the wild.
1793+ # TODO: implement logic for bounces, unsubscribes
1794+ if vals['log_type'] == 'mail_bounced':
1795+ wvals.update({'mail_bounced': True})
1796+ if vals['log_type'] == 'unsubscribe':
1797+ wvals.update({'unsubscribed': True})
1798+ # Check if target has optin or optout stuffi
1799+ wi = wi_obj.browse(cr, uid, wi_id, context)
1800+ target_model = self.pool.get(wi.object_id['model'])
1801+ target = target_model.browse(cr, uid, res_id, context)
1802+ # TODO: check it's opt_out everywhere
1803+ if 'opt_out' in target:
1804+ target_wvals = {
1805+ 'opt_out': True,
1806+ }
1807+ target_model.write(cr, uid, res_id, target_wvals)
1808+ wi_obj.write(cr, uid, [wi_id], wvals, context)
1809+ return retval
1810
1811=== added file 'marketing_campaign/report/campaign_tracking_view.xml'
1812--- marketing_campaign/report/campaign_tracking_view.xml 1970-01-01 00:00:00 +0000
1813+++ marketing_campaign/report/campaign_tracking_view.xml 2012-08-27 10:03:27 +0000
1814@@ -0,0 +1,53 @@
1815+<?xml version="1.0" encoding="utf-8"?>
1816+<openerp>
1817+ <data>
1818+ <record model="ir.actions.act_window" id="action_marketing_campaign_log">
1819+ <field name="name">Campaign Logs</field>
1820+ <field name="res_model">marketing.campaign.tracklog</field>
1821+ <field name="view_type">form</field>
1822+ <field name="view_mode">tree,form</field>
1823+ <field name="help">Marketing Campaign Response Logs</field>
1824+ </record>
1825+
1826+ <record id="view_marketing_campaign_log_form" model="ir.ui.view">
1827+ <field name="name">marketing.campaign.log.form</field>
1828+ <field name="model">marketing.campaign.tracklog</field>
1829+ <field name="type">form</field>
1830+ <field name="arch" type="xml">
1831+ <form string="Logs">
1832+ <field name="campaign_id" readonly="True"/>
1833+ <field name="model_id" readonly="True"/>
1834+ <field name="activity_id" on_change="onchange_activity(activity_id)"/>
1835+ <field name="res_name" readonly="True"/>
1836+ <field name="res_track_id" on_change="onchange_trackitem(res_track_id)"/>
1837+ <field name="res_id" readonly="True"/>
1838+ <field name="log_date"/>
1839+ <field name="log_type"/>
1840+ <field name="log_ip"/>
1841+ <field name="revenue"/>
1842+ <field name="log_url"/>
1843+ </form>
1844+ </field>
1845+ </record>
1846+
1847+ <record id="view_marketing_campaign_log_list" model="ir.ui.view">
1848+ <field name="name">marketing.campaign.log.list</field>
1849+ <field name="model">marketing.campaign.tracklog</field>
1850+ <field name="type">tree</field>
1851+ <field name="arch" type="xml">
1852+ <form string="Logs">
1853+ <field name="log_date"/>
1854+ <field name="campaign_id"/>
1855+ <field name="activity_id"/>
1856+ <field name="model_id"/>
1857+ <field name="res_name"/>
1858+ <field name="log_type"/>
1859+ <field name="log_ip"/>
1860+ <field name="revenue"/>
1861+ </form>
1862+ </field>
1863+ </record>
1864+
1865+ <menuitem parent="base.menu_report_marketing" id="menu_marketing_campaign_log" action="action_marketing_campaign_log" />
1866+ </data>
1867+</openerp>

Subscribers

People subscribed via source and target branches

to all changes: