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
=== modified file 'marketing_campaign/__init__.py'
--- marketing_campaign/__init__.py 2011-01-14 00:11:01 +0000
+++ marketing_campaign/__init__.py 2012-08-27 10:03:27 +0000
@@ -20,6 +20,9 @@
20##############################################################################20##############################################################################
2121
22import marketing_campaign22import marketing_campaign
23import email_template
24import ir_mail_server
25import mail_message
23import res_partner26import res_partner
24import report27import report
2528
2629
=== modified file 'marketing_campaign/__openerp__.py'
--- marketing_campaign/__openerp__.py 2012-08-22 13:02:32 +0000
+++ marketing_campaign/__openerp__.py 2012-08-27 10:03:27 +0000
@@ -57,15 +57,29 @@
57 'website': 'http://www.openerp.com',57 'website': 'http://www.openerp.com',
58 'data': [58 'data': [
59 'marketing_campaign_view.xml',59 'marketing_campaign_view.xml',
60 'email_template_view.xml',
60 'marketing_campaign_data.xml',61 'marketing_campaign_data.xml',
61 'marketing_campaign_workflow.xml',62 'marketing_campaign_workflow.xml',
62 'res_partner_view.xml',63 'res_partner_view.xml',
63 'report/campaign_analysis_view.xml',64 'report/campaign_analysis_view.xml',
65<<<<<<< TREE
64 'security/marketing_campaign_security.xml',66 'security/marketing_campaign_security.xml',
65 'security/ir.model.access.csv'67 'security/ir.model.access.csv'
66 ],68 ],
67 'demo': ['marketing_campaign_demo.xml'],69 'demo': ['marketing_campaign_demo.xml'],
68 'test': ['test/marketing_campaign.yml'],70 'test': ['test/marketing_campaign.yml'],
71=======
72 'report/campaign_tracking_view.xml',
73 "security/marketing_campaign_security.xml",
74 "security/ir.model.access.csv"
75 ],
76 'demo_xml': [
77 'marketing_campaign_demo.xml',
78 ],
79 'test': [
80 'test/marketing_campaign.yml',
81 ],
82>>>>>>> MERGE-SOURCE
69 'installable': True,83 'installable': True,
70 'auto_install': False,84 'auto_install': False,
71 'certificate': '00421723279617928365',85 'certificate': '00421723279617928365',
7286
=== added file 'marketing_campaign/email_template.py'
--- marketing_campaign/email_template.py 1970-01-01 00:00:00 +0000
+++ marketing_campaign/email_template.py 2012-08-27 10:03:27 +0000
@@ -0,0 +1,212 @@
1import netsvc
2import base64
3from osv import osv
4from osv import fields
5import re
6import urllib
7
8
9# Helper functions to transform template fields for campaign tracking
10def insert_html_tracking(template, track_str, get_char):
11 retval = template['body_html']
12 if 'tracker_url' in template:
13 re_str = ['href="([^"]*)"', '(?:img[^<>]*)src="([^"]*)"']
14 for i in range(2):
15 m = re.compile(re_str[i]).finditer(retval)
16 startpos = 0
17 body_html = ''
18 for x in m:
19 body_html += retval[startpos:x.start(1)] \
20 + template['tracker_url'] + get_char + 'url=' \
21 + urllib.quote_plus(x.group(1)) \
22 + '&' + track_str
23 startpos = x.end(1)
24 body_html += retval[startpos:]
25 retval = body_html
26 else:
27 re_str = [
28 'href="([^"]*\?[^"]*)"',
29 'href="([^"\?]*)"',
30 '(img[^<>]*)src="([^"]*\?[^"]*)"',
31 '(img[^<>]*)src="([^"\?]*)"',
32 ]
33 get_chars = ['&', '?'] * 2
34 for i in range(0, 2):
35 expr = re.compile(re_str[i])
36 retval = expr.sub('href="\\1' + get_chars[i] + track_str + '"', retval)
37 for i in range(2, 4):
38 expr = re.compile(re_str[i])
39 retval = expr.sub('\\1 src="\\2' + get_chars[i] + track_str + '"', retval)
40 return retval
41
42
43def insert_text_tracking(template, track_str, get_char):
44 retval = template['body_text']
45 if 'tracker_url' in template:
46 expr1 = re.compile('http(s?://([^\s]*?))([,\.]?\s)')
47 m = expr1.finditer(retval)
48 startpos = 0
49 body_text = ''
50 for x in m:
51 body_text += retval[startpos:x.start()] \
52 + template['tracker_url'] + get_char + 'url=' \
53 + urllib.quote_plus('http' + x.group(1)) \
54 + '&' + track_str + x.group(3)
55 startpos = x.end()
56 body_text += retval[startpos:]
57 retval = body_text
58 else:
59 re_str = ['http(s?://([^\s]*?\?[^\s]*?))([,\.]?\s)', 'http(s?://([^\s\?]*?))([,\.]?\s)']
60 get_chars = ['&', '?']
61 for i in range(2):
62 expr = re.compile(re_str[i])
63 retval = expr.sub('http\\1' + get_chars[i] + track_str + '\\3', retval)
64 return retval
65# End helper functions for campaign tracking
66
67
68class email_template(osv.osv):
69 _name = 'email.template'
70 _inherit = 'email.template'
71
72 def generate_email(self, cr, uid, template_id, res_id, context=None):
73 """Generates an email from the template for given (model, res_id) pair.
74
75 :param template_id: id of the template to render.
76 :param res_id: id of the record to use for rendering the template (model
77 is taken from template definition)
78 :returns: a dict containing all relevant fields for creating a new
79 mail.message entry, with the addition one additional
80 special key ``attachments`` containing a list of
81 """
82 if context is None:
83 context = {}
84 values = {
85 'subject': False,
86 'body_text': False,
87 'body_html': False,
88 'email_from': False,
89 'return_path': False, # MODIF
90 'email_to': False,
91 'email_cc': False,
92 'email_bcc': False,
93 'reply_to': False,
94 'auto_delete': False,
95 'model': False,
96 'res_id': False,
97 'mail_server_id': False,
98 'attachments': False,
99 'attachment_ids': False,
100 'message_id': False,
101 'state': 'outgoing',
102 'subtype': 'plain',
103 }
104 if not template_id:
105 return values
106
107 report_xml_pool = self.pool.get('ir.actions.report.xml')
108 template = self.get_email_template(cr, uid, template_id, res_id, context)
109
110 # BEGIN MODIF
111 fieldnames = ['subject', 'body_text', 'body_html', 'email_from',
112 'email_to', 'email_cc', 'email_bcc', 'reply_to',
113 'return_path', 'message_id']
114 render_src = {}
115 for field in fieldnames:
116 render_src[field] = getattr(template, field)
117 if 'campaign_track_str' in context \
118 and 'activity_track_str' in context \
119 and 'res_track_str' in context:
120 track_str = 'c=' + context['campaign_track_str'] \
121 + '&a=' + context['activity_track_str'] \
122 + '&r=' + context['res_track_str']
123 get_char = None
124 if 'tracker_url' in template:
125 if re.search('\?', template['tracker_url']):
126 get_char = '&'
127 else:
128 get_char = '?'
129
130 # Body text
131 if 'body_text' in template and template['body_text']:
132 render_src['body_text'] = insert_text_tracking(template, track_str, get_char)
133
134 # Body HTML
135 if 'body_html' in template and template['body_html']:
136 render_src['body_html'] = insert_html_tracking(template, track_str, get_char)
137
138 for field in fieldnames:
139 values[field] = self.render_template(cr, uid, render_src.get(field),
140 template.model, res_id, context=context) \
141 or False
142
143 # Variable Sender
144 if values['return_path']:
145 bounce_split = values['return_path'].split('@')
146 else:
147 bounce_split = values['email_from'].split('@')
148 # If we have campaign tracking info
149 if 'campaign_track_str' in context \
150 and 'activity_track_str' in context \
151 and 'res_track_str' in context:
152 values['return_path'] = bounce_split[0] + '+' \
153 + context['campaign_track_str'] + '-' \
154 + context['activity_track_str'] + '-' \
155 + context['res_track_str'] \
156 + '@' + bounce_split[1]
157 # If we don't have campaign tracking info
158 else:
159 values['return_path'] = bounce_split[0] + '+' \
160 + re.sub('@', '=', values['email_to']) \
161 + '@' + bounce_split[1]
162 # END MODIF
163
164 if values['body_html']:
165 values.update(subtype='html')
166
167 if template.user_signature:
168 signature = self.pool.get('res.users').browse(cr, uid, uid, context).signature
169 values['body_text'] += '\n\n' + signature
170
171 values.update(mail_server_id=template.mail_server_id.id or False,
172 auto_delete=template.auto_delete,
173 model=template.model,
174 res_id=res_id or False)
175
176 attachments = {}
177 # Add report as a Document
178 if template.report_template:
179 report_name = self.render_template(cr, uid, template.report_name, template.model, res_id, context=context)
180 report_service = 'report.' + report_xml_pool.browse(cr, uid, template.report_template.id, context).report_name
181 # Ensure report is rendered using template's language
182 ctx = context.copy()
183 if template.lang:
184 ctx['lang'] = self.render_template(cr, uid, template.lang, template.model, res_id, context)
185 service = netsvc.LocalService(report_service)
186 (result, format) = service.create(cr, uid, [res_id], {'model': template.model}, ctx)
187 result = base64.b64encode(result)
188 if not report_name:
189 report_name = report_service
190 ext = "." + format
191 if not report_name.endswith(ext):
192 report_name += ext
193 attachments[report_name] = result
194
195 # Add document attachments
196 for attach in template.attachment_ids:
197 # keep the bytes as fetched from the db, base64 encoded
198 attachments[attach.datas_fname] = attach.datas
199
200 values['attachments'] = attachments
201 return values
202
203 _columns = {
204 'tracker_url': fields.char('Tracker URL', None, help='The URL of the campaign tracker'),
205 'return_path': fields.char('Return path', None, help='Email address to send mail delivery reports to'),
206 }
207
208 _defaults = {
209 'model_id': lambda obj, cr, uid, context: context.get('object_id',False),
210 }
211
212email_template()
0213
=== added file 'marketing_campaign/email_template_view.xml'
--- marketing_campaign/email_template_view.xml 1970-01-01 00:00:00 +0000
+++ marketing_campaign/email_template_view.xml 2012-08-27 10:03:27 +0000
@@ -0,0 +1,20 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<openerp>
3 <data>
4
5 <record model="ir.ui.view" id="email_template_form_inherit">
6 <field name="name">email.template.form</field>
7 <field name="inherit_id" ref='email_template.email_template_form' />
8 <field name="model">email.template</field>
9 <field name="type">form</field>
10 <field name="arch" type="xml">
11 <field name="reply_to" position="after">
12 <field name="return_path"/>
13 </field>
14 <field name="track_campaign_item" position="after">
15 <field name="tracker_url" />
16 </field>
17 </field>
18 </record>
19 </data>
20</openerp>
0\ No newline at end of file21\ No newline at end of file
122
=== added file 'marketing_campaign/ir_mail_server.py'
--- marketing_campaign/ir_mail_server.py 1970-01-01 00:00:00 +0000
+++ marketing_campaign/ir_mail_server.py 2012-08-27 10:03:27 +0000
@@ -0,0 +1,260 @@
1from email.MIMEText import MIMEText
2from email.MIMEBase import MIMEBase
3from email.MIMEMultipart import MIMEMultipart
4from email.Charset import Charset
5from email.Header import Header
6from email.Utils import formatdate, make_msgid, COMMASPACE
7from email import Encoders
8import logging
9import re
10# import smtplib
11# import threading
12
13# Just here to satisfy code requirements, but unmodified from original
14from osv import osv
15# from osv import fields
16# from openerp.tools.translate import _
17from openerp.tools import html2text
18import openerp.tools as tools
19
20# ustr was originally from tools.misc.
21# it is moved to loglevels until we refactor tools.
22from openerp.loglevels import ustr
23
24_logger = logging.getLogger(__name__)
25name_with_email_pattern = re.compile(r'("[^<@>]+")\s*<([^ ,<@]+@[^> ,]+)>')
26address_pattern = re.compile(r'([^ ,<@]+@[^> ,]+)')
27
28
29def try_coerce_ascii(string_utf8):
30 """Attempts to decode the given utf8-encoded string
31 as ASCII after coercing it to UTF-8, then return
32 the confirmed 7-bit ASCII string.
33
34 If the process fails (because the string
35 contains non-ASCII characters) returns ``None``.
36 """
37 try:
38 string_utf8.decode('ascii')
39 except UnicodeDecodeError:
40 return
41 return string_utf8
42
43
44def extract_rfc2822_addresses(text):
45 """Returns a list of valid RFC2822 addresses
46 that can be found in ``source``, ignoring
47 malformed ones and non-ASCII ones.
48 """
49 if not text:
50 return []
51 candidates = address_pattern.findall(tools.ustr(text).encode('utf-8'))
52 return filter(try_coerce_ascii, candidates)
53
54
55def encode_header_param(param_text):
56 """Returns an appropriate RFC2047 encoded representation of the given
57 header parameter value, suitable for direct assignation as the
58 param value (e.g. via Message.set_param() or Message.add_header())
59 RFC2822 assumes that headers contain only 7-bit characters,
60 so we ensure it is the case, using RFC2047 encoding when needed.
61
62 :param param_text: unicode or utf-8 encoded string with header value
63 :rtype: string
64 :return: if ``param_text`` represents a plain ASCII string,
65 return the same 7-bit string, otherwise returns an
66 ASCII string containing the RFC2047 encoded text.
67 """
68 # For details see the encode_header() method that uses the same logic
69 if not param_text:
70 return ""
71 param_text_utf8 = tools.ustr(param_text).encode('utf-8')
72 param_text_ascii = try_coerce_ascii(param_text_utf8)
73 return param_text_ascii if param_text_ascii\
74 else Charset('utf8').header_encode(param_text_utf8)
75
76
77def encode_rfc2822_address_header(header_text):
78 """If ``header_text`` contains non-ASCII characters,
79 attempts to locate patterns of the form
80 ``"Name" <address@domain>`` and replace the
81 ``"Name"`` portion by the RFC2047-encoded
82 version, preserving the address part untouched.
83 """
84 header_text_utf8 = tools.ustr(header_text).encode('utf-8')
85 header_text_ascii = try_coerce_ascii(header_text_utf8)
86 if header_text_ascii:
87 return header_text_ascii
88 # non-ASCII characters are present, attempt to
89 # replace all "Name" patterns with the RFC2047-
90 # encoded version
91
92 def replace(match_obj):
93 name, email = match_obj.group(1), match_obj.group(2)
94 name_encoded = str(Header(name, 'utf-8'))
95 return "%s <%s>" % (name_encoded, email)
96 header_text_utf8 = name_with_email_pattern.sub(replace,
97 header_text_utf8)
98 # try again after encoding
99 header_text_ascii = try_coerce_ascii(header_text_utf8)
100 if header_text_ascii:
101 return header_text_ascii
102 # fallback to extracting pure addresses only, which could
103 # still cause a failure downstream if the actual addresses
104 # contain non-ASCII characters
105 return COMMASPACE.join(extract_rfc2822_addresses(header_text_utf8))
106
107
108def encode_header(header_text):
109 """Returns an appropriate representation of the given header value,
110 suitable for direct assignment as a header value in an
111 email.message.Message. RFC2822 assumes that headers contain
112 only 7-bit characters, so we ensure it is the case, using
113 RFC2047 encoding when needed.
114
115 :param header_text: unicode or utf-8 encoded string with header value
116 :rtype: string | email.header.Header
117 :return: if ``header_text`` represents a plain ASCII string,
118 return the same 7-bit string, otherwise returns an email.header.Header
119 that will perform the appropriate RFC2047 encoding of
120 non-ASCII values.
121 """
122 if not header_text:
123 return ""
124 # convert anything to utf-8, suitable for testing ASCIIness, as 7-bit chars are
125 # encoded as ASCII in utf-8
126 header_text_utf8 = tools.ustr(header_text).encode('utf-8')
127 header_text_ascii = try_coerce_ascii(header_text_utf8)
128 # if this header contains non-ASCII characters,
129 # we'll need to wrap it up in a message.header.Header
130 # that will take care of RFC2047-encoding it as
131 # 7-bit string.
132 return header_text_ascii if header_text_ascii\
133 else Header(header_text_utf8, 'utf-8')
134# End "Just here ..."
135
136
137class ir_mail_server(osv.osv):
138 _name = "ir.mail_server"
139 _inherit = "ir.mail_server"
140
141 # _columns = {
142 # 'variable_sender': fields.boolean('Variable Sender'),
143 # }
144
145 # MODIF signature
146 def build_email(self, email_from, email_to, subject, body, email_cc=None, email_bcc=None, reply_to=False,
147 attachments=None, message_id=None, references=None, object_id=False, subtype='plain', headers=None,
148 body_alternative=None, subtype_alternative='plain', return_path=False):
149 """Constructs an RFC2822 email.message.Message object based on the keyword arguments passed, and returns it.
150
151 :param string email_from: sender email address
152 :param list email_to: list of recipient addresses (to be joined with commas)
153 :param string subject: email subject (no pre-encoding/quoting necessary)
154 :param string body: email body, of the type ``subtype`` (by default, plaintext).
155 If html subtype is used, the message will be automatically converted
156 to plaintext and wrapped in multipart/alternative, unless an explicit
157 ``body_alternative`` version is passed.
158 :param string body_alternative: optional alternative body, of the type specified in ``subtype_alternative``
159 :param string reply_to: optional value of Reply-To header
160 :param string object_id: optional tracking identifier, to be included in the message-id for
161 recognizing replies. Suggested format for object-id is "res_id-model",
162 e.g. "12345-crm.lead".
163 :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
164 must match the format of the ``body`` parameter. Default is 'plain',
165 making the content part of the mail "text/plain".
166 :param string subtype_alternative: optional mime subtype of ``body_alternative`` (usually 'plain'
167 or 'html'). Default is 'plain'.
168 :param list attachments: list of (filename, filecontents) pairs, where filecontents is a string
169 containing the bytes of the attachment
170 :param list email_cc: optional list of string values for CC header (to be joined with commas)
171 :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
172 :param dict headers: optional map of headers to set on the outgoing mail (may override the
173 other headers, including Subject, Reply-To, Message-Id, etc.)
174 :rtype: email.message.Message (usually MIMEMultipart)
175 :return: the new RFC2822 email message
176 """
177 email_from = email_from or tools.config.get('email_from')
178 assert email_from, "You must either provide a sender address explicitly or configure "\
179 "a global sender address in the server configuration or with the "\
180 "--email-from startup parameter."
181
182 # Note: we must force all strings to to 8-bit utf-8 when crafting message,
183 # or use encode_header() for headers, which does it automatically.
184
185 headers = headers or {} # need valid dict later
186
187 if not email_cc:
188 email_cc = []
189 if not email_bcc:
190 email_bcc = []
191 if not body:
192 body = u''
193
194 email_body_utf8 = ustr(body).encode('utf-8')
195 email_text_part = MIMEText(email_body_utf8, _subtype=subtype, _charset='utf-8')
196 msg = MIMEMultipart()
197
198 if not message_id:
199 if object_id:
200 message_id = tools.generate_tracking_message_id(object_id)
201 else:
202 message_id = make_msgid()
203 msg['Message-Id'] = encode_header(message_id)
204 if references:
205 msg['references'] = encode_header(references)
206 msg['Subject'] = encode_header(subject)
207 msg['From'] = encode_rfc2822_address_header(email_from)
208 # del msg['Reply-To'] # MODIF: unnecessary., given what preceeds and what follows.
209 if reply_to:
210 msg['Reply-To'] = encode_rfc2822_address_header(reply_to)
211 else:
212 msg['Reply-To'] = msg['From']
213 # BEGIN MODIF
214 if return_path:
215 msg['Return-Path'] = encode_rfc2822_address_header(return_path)
216 else:
217 msg['Return-Path'] = msg['From']
218 # END MODIF
219 msg['To'] = encode_rfc2822_address_header(COMMASPACE.join(email_to))
220 if email_cc:
221 msg['Cc'] = encode_rfc2822_address_header(COMMASPACE.join(email_cc))
222 if email_bcc:
223 msg['Bcc'] = encode_rfc2822_address_header(COMMASPACE.join(email_bcc))
224 msg['Date'] = formatdate()
225 # Custom headers may override normal headers or provide additional ones
226 for key, value in headers.iteritems():
227 msg[ustr(key).encode('utf-8')] = encode_header(value)
228
229 if subtype == 'html' and not body_alternative and html2text:
230 # Always provide alternative text body ourselves if possible.
231 text_utf8 = tools.html2text(email_body_utf8.decode('utf-8')).encode('utf-8')
232 alternative_part = MIMEMultipart(_subtype="alternative")
233 alternative_part.attach(MIMEText(text_utf8, _charset='utf-8', _subtype='plain'))
234 alternative_part.attach(email_text_part)
235 msg.attach(alternative_part)
236 elif body_alternative:
237 # Include both alternatives, as specified, within a multipart/alternative part
238 alternative_part = MIMEMultipart(_subtype="alternative")
239 body_alternative_utf8 = ustr(body_alternative).encode('utf-8')
240 alternative_body_part = MIMEText(body_alternative_utf8, _subtype=subtype_alternative, _charset='utf-8')
241 alternative_part.attach(alternative_body_part)
242 alternative_part.attach(email_text_part)
243 msg.attach(alternative_part)
244 else:
245 msg.attach(email_text_part)
246
247 if attachments:
248 for (fname, fcontent) in attachments:
249 filename_rfc2047 = encode_header_param(fname)
250 part = MIMEBase('application', "octet-stream")
251
252 # The default RFC2231 encoding of Message.add_header() works in Thunderbird but not GMail
253 # so we fix it by using RFC2047 encoding for the filename instead.
254 part.set_param('name', filename_rfc2047)
255 part.add_header('Content-Disposition', 'attachment', filename=filename_rfc2047)
256
257 part.set_payload(fcontent)
258 Encoders.encode_base64(part)
259 msg.attach(part)
260 return msg
0261
=== added file 'marketing_campaign/mail_message.py'
--- marketing_campaign/mail_message.py 1970-01-01 00:00:00 +0000
+++ marketing_campaign/mail_message.py 2012-08-27 10:03:27 +0000
@@ -0,0 +1,365 @@
1import ast
2import base64
3import dateutil.parser
4import email
5import logging
6import re
7import time
8from email.header import decode_header
9from email.message import Message
10
11import tools
12from osv import osv
13from osv import fields
14# from tools.translate import _
15# from openerp import SUPERUSER_ID
16
17# Just here to satisfy code requirements, but unmodified from original
18_logger = logging.getLogger('mail')
19
20
21def decode(text):
22 """Returns unicode() string conversion of the the given encoded smtp header text"""
23 if text:
24 text = decode_header(text.replace('\r', ''))
25 return ''.join([tools.ustr(x[0], x[1]) for x in text])
26
27
28def to_email(text):
29 """Return a list of the email addresses found in ``text``"""
30 if not text:
31 return []
32 return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
33# End "Just here ..."
34
35
36class mail_message(osv.osv):
37 _name = 'mail.message'
38 _inherit = 'mail.message'
39
40 _columns = {
41 'return_path': fields.char('Return-Path', size=None, readonly=True),
42 'workitem_id': fields.many2one('marketing.campaign.workitem', 'Workitem'),
43 }
44
45 # MODIF signature
46 def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
47 email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
48 res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
49 context=None, return_path=False):
50 """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
51 the next time :meth:`process_email_queue` is called explicitly.
52
53 :param string email_from: sender email address
54 :param string return_path: bounce return path
55 :param list email_to: list of recipient addresses (to be joined with commas)
56 :param string subject: email subject (no pre-encoding/quoting necessary)
57 :param string body: email body, according to the ``subtype`` (by default, plaintext).
58 If html subtype is used, the message will be automatically converted
59 to plaintext and wrapped in multipart/alternative.
60 :param list email_cc: optional list of string values for CC header (to be joined with commas)
61 :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
62 :param string model: optional model name of the document this mail is related to (this will also
63 be used to generate a tracking id, used to match any response related to the
64 same document)
65 :param int res_id: optional resource identifier this mail is related to (this will also
66 be used to generate a tracking id, used to match any response related to the
67 same document)
68 :param string reply_to: optional value of Reply-To header
69 :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
70 must match the format of the ``body`` parameter. Default is 'plain',
71 making the content part of the mail "text/plain".
72 :param dict attachments: map of filename to filecontents, where filecontents is a string
73 containing the bytes of the attachment
74 :param dict headers: optional map of headers to set on the outgoing mail (may override the
75 other headers, including Subject, Reply-To, Message-Id, etc.)
76 :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
77 :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
78 successfully sent (default to False)
79
80 """
81 if context is None:
82 context = {}
83 if attachments is None:
84 attachments = {}
85 attachment_obj = self.pool.get('ir.attachment')
86 for param in (email_to, email_cc, email_bcc):
87 if param and not isinstance(param, list):
88 param = [param]
89 msg_vals = {
90 'subject': subject,
91 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
92 'user_id': uid,
93 'model': model,
94 'res_id': res_id,
95 'body_text': body if subtype != 'html' else False,
96 'body_html': body if subtype == 'html' else False,
97 'email_from': email_from,
98 'return_path': return_path or email_from, # MODIF
99 'email_to': email_to and ','.join(email_to) or '',
100 'email_cc': email_cc and ','.join(email_cc) or '',
101 'email_bcc': email_bcc and ','.join(email_bcc) or '',
102 'reply_to': reply_to,
103 'message_id': message_id,
104 'references': references,
105 'subtype': subtype,
106 'headers': headers, # serialize the dict on the fly
107 'mail_server_id': mail_server_id,
108 'state': 'outgoing',
109 'auto_delete': auto_delete
110 }
111 if 'workitem_id' in context and context['workitem_id']:
112 msg_vals.update({'workitem_id': context['workitem_id']})
113 email_msg_id = self.create(cr, uid, msg_vals, context)
114 attachment_ids = []
115 for fname, fcontent in attachments.iteritems():
116 attachment_data = {
117 'name': fname,
118 'datas_fname': fname,
119 'datas': fcontent and fcontent.encode('base64'),
120 'res_model': self._name,
121 'res_id': email_msg_id,
122 }
123 if 'default_type' in context:
124 del context['default_type']
125 attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
126 if attachment_ids:
127 self.write(cr, uid, email_msg_id, {'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
128 return email_msg_id
129
130 def parse_message(self, message, save_original=False):
131 """Parses a string or email.message.Message representing an
132 RFC-2822 email, and returns a generic dict holding the
133 message details.
134
135 :param message: the message to parse
136 :type message: email.message.Message | string | unicode
137 :param bool save_original: whether the returned dict
138 should include an ``original`` entry with the base64
139 encoded source of the message.
140 :rtype: dict
141 :return: A dict with the following structure, where each
142 field may not be present if missing in original
143 message::
144
145 { 'message-id': msg_id,
146 'subject': subject,
147 'from': from,
148 'to': to,
149 'cc': cc,
150 'headers' : { 'X-Mailer': mailer,
151 #.. all X- headers...
152 },
153 'subtype': msg_mime_subtype,
154 'body_text': plaintext_body
155 'body_html': html_body,
156 'attachments': [('file1', 'bytes'),
157 ('file2', 'bytes') }
158 # ...
159 'original': source_of_email,
160 }
161 """
162 msg_txt = message
163 if isinstance(message, str):
164 msg_txt = email.message_from_string(message)
165
166 # Warning: message_from_string doesn't always work correctly on unicode,
167 # we must use utf-8 strings here :-(
168 if isinstance(message, unicode):
169 message = message.encode('utf-8')
170 msg_txt = email.message_from_string(message)
171
172 message_id = msg_txt.get('message-id', False)
173 msg = {}
174
175 if save_original:
176 # save original, we need to be able to read the original email sometimes
177 msg['original'] = message.as_string() if isinstance(message, Message) \
178 else message
179 msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
180
181 if not message_id:
182 # Very unusual situation, be we should be fault-tolerant here
183 message_id = time.time()
184 msg_txt['message-id'] = message_id
185 _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
186
187 fields = msg_txt.keys()
188 msg['id'] = message_id
189 msg['message-id'] = message_id
190
191 if 'Subject' in fields:
192 msg['subject'] = decode(msg_txt.get('Subject'))
193
194 if 'Content-Type' in fields:
195 msg['content-type'] = msg_txt.get('Content-Type')
196
197 if 'From' in fields:
198 msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
199
200 # MODIF
201 if 'Return-Path' in fields:
202 msg['return_path'] = decode(msg_txt.get('Return-Path')) or msg['from']
203 # END MODIF
204
205 if 'To' in fields:
206 msg['to'] = decode(msg_txt.get('To'))
207
208 if 'Delivered-To' in fields:
209 msg['to'] = decode(msg_txt.get('Delivered-To'))
210
211 if 'CC' in fields:
212 msg['cc'] = decode(msg_txt.get('CC'))
213
214 if 'Cc' in fields:
215 msg['cc'] = decode(msg_txt.get('Cc'))
216
217 if 'Reply-To' in fields:
218 msg['reply'] = decode(msg_txt.get('Reply-To'))
219
220 if 'Date' in fields:
221 date_hdr = decode(msg_txt.get('Date'))
222 msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
223
224 if 'Content-Transfer-Encoding' in fields:
225 msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
226
227 if 'References' in fields:
228 msg['references'] = msg_txt.get('References')
229
230 if 'In-Reply-To' in fields:
231 msg['in-reply-to'] = msg_txt.get('In-Reply-To')
232
233 msg['headers'] = {}
234 msg['subtype'] = 'plain'
235 for item in msg_txt.items():
236 if item[0].startswith('X-'):
237 msg['headers'].update({item[0]: item[1]})
238 if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
239 encoding = msg_txt.get_content_charset()
240 body = msg_txt.get_payload(decode=True)
241 if 'text/html' in msg.get('content-type', ''):
242 msg['body_html'] = body
243 msg['subtype'] = 'html'
244 body = tools.html2plaintext(body)
245 msg['body_text'] = tools.ustr(body, encoding)
246
247 attachments = []
248 if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
249 body = ""
250 if 'multipart/alternative' in msg.get('content-type', ''):
251 msg['subtype'] = 'alternative'
252 else:
253 msg['subtype'] = 'mixed'
254 for part in msg_txt.walk():
255 if part.get_content_maintype() == 'multipart':
256 continue
257
258 encoding = part.get_content_charset()
259 filename = part.get_filename()
260 if part.get_content_maintype()=='text':
261 content = part.get_payload(decode=True)
262 if filename:
263 attachments.append((filename, content))
264 content = tools.ustr(content, encoding)
265 if part.get_content_subtype() == 'html':
266 msg['body_html'] = content
267 msg['subtype'] = 'html' # html version prevails
268 body = tools.ustr(tools.html2plaintext(content))
269 body = body.replace('&#13;', '')
270 elif part.get_content_subtype() == 'plain':
271 body = content
272 elif part.get_content_maintype() in ('application', 'image'):
273 if filename :
274 attachments.append((filename,part.get_payload(decode=True)))
275 else:
276 res = part.get_payload(decode=True)
277 body += tools.ustr(res, encoding)
278
279 msg['body_text'] = body
280 msg['attachments'] = attachments
281
282 # for backwards compatibility:
283 msg['body'] = msg['body_text']
284 msg['sub_type'] = msg['subtype'] or 'plain'
285 return msg
286
287 def send(self, cr, uid, ids, auto_commit=False, context=None):
288 """Sends the selected emails immediately, ignoring their current
289 state (mails that have already been sent should not be passed
290 unless they should actually be re-sent).
291 Emails successfully delivered are marked as 'sent', and those
292 that fail to be deliver are marked as 'exception', and the
293 corresponding error message is output in the server logs.
294
295 :param bool auto_commit: whether to force a commit of the message
296 status after sending each message (meant
297 only for processing by the scheduler),
298 should never be True during normal
299 transactions (default: False)
300 :return: True
301 """
302 if context is None:
303 context = {}
304 ir_mail_server = self.pool.get('ir.mail_server')
305 self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
306 for message in self.browse(cr, uid, ids, context=context):
307 try:
308 attachments = []
309 for attach in message.attachment_ids:
310 attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
311
312 body = message.body_html if message.subtype == 'html' else message.body_text
313 body_alternative = None
314 subtype_alternative = None
315 if message.subtype == 'html' and message.body_text:
316 # we have a plain text alternative prepared, pass it to
317 # build_message instead of letting it build one
318 body_alternative = message.body_text
319 subtype_alternative = 'plain'
320
321 msg = ir_mail_server.build_email(
322 email_from=message.email_from,
323 return_path=message.return_path, # MODIF
324 email_to=to_email(message.email_to),
325 subject=message.subject,
326 body=body,
327 body_alternative=body_alternative,
328 email_cc=to_email(message.email_cc),
329 email_bcc=to_email(message.email_bcc),
330 reply_to=message.reply_to,
331 attachments=attachments, message_id=message.message_id,
332 references=message.references,
333 object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
334 subtype=message.subtype,
335 subtype_alternative=subtype_alternative,
336 headers=message.headers and ast.literal_eval(message.headers))
337 res = ir_mail_server.send_email(cr, uid, msg,
338 mail_server_id=message.mail_server_id.id,
339 context=context)
340 if res:
341 message.write({'state':'sent', 'message_id': res})
342 else:
343 message.write({'state':'exception'})
344
345 # if auto_delete=True then delete that sent messages as well as attachments
346 message.refresh()
347 if message.state == 'sent' and message.auto_delete:
348 self.pool.get('ir.attachment').unlink(cr, uid,
349 [x.id for x in message.attachment_ids \
350 if x.res_model == self._name and \
351 x.res_id == message.id],
352 context=context)
353 message.unlink()
354 except Exception:
355 _logger.exception('failed sending mail.message %s', message.id)
356 message.write({'state':'exception'})
357
358 if auto_commit == True:
359 cr.commit()
360 return True
361
362 def create(self, cr, uid, values, context=None):
363 if 'workitem_id' in context:
364 values.update({'workitem_id': context['workitem_id']})
365 return super(mail_message, self).create(cr, uid, values, context)
0366
=== modified file 'marketing_campaign/marketing_campaign.py'
--- marketing_campaign/marketing_campaign.py 2012-08-14 14:10:40 +0000
+++ marketing_campaign/marketing_campaign.py 2012-08-27 10:03:27 +0000
@@ -21,7 +21,7 @@
2121
22import time22import time
23import base6423import base64
24import itertools24import hashlib
25from datetime import datetime25from datetime import datetime
26from dateutil.relativedelta import relativedelta26from dateutil.relativedelta import relativedelta
27from operator import itemgetter27from operator import itemgetter
@@ -44,9 +44,11 @@
4444
45DT_FMT = '%Y-%m-%d %H:%M:%S'45DT_FMT = '%Y-%m-%d %H:%M:%S'
4646
47
47def dict_map(f, d):48def dict_map(f, d):
48 return dict((k, f(v)) for k,v in d.items())49 return dict((k, f(v)) for k,v in d.items())
4950
51
50def _find_fieldname(model, field):52def _find_fieldname(model, field):
51 inherit_columns = dict_map(itemgetter(2), model._inherit_fields)53 inherit_columns = dict_map(itemgetter(2), model._inherit_fields)
52 all_columns = dict(inherit_columns, **model._columns)54 all_columns = dict(inherit_columns, **model._columns)
@@ -55,6 +57,14 @@
55 return fn57 return fn
56 raise ValueError('Field not found: %r' % (field,))58 raise ValueError('Field not found: %r' % (field,))
5759
60
61def compute_track_str(cr, uid, string, context=None):
62 db_uuid = osv.pooler.get_pool(cr.dbname).get('ir.config_parameter').get_param(cr, uid, 'database.uuid')
63 m = hashlib.md5()
64 m.update(db_uuid + '_' + string)
65 return m.hexdigest()
66
67
58class selection_converter(object):68class selection_converter(object):
59 """Format the selection in the browse record objects"""69 """Format the selection in the browse record objects"""
60 def __init__(self, value):70 def __init__(self, value):
@@ -88,6 +98,7 @@
8898
89 _columns = {99 _columns = {
90 'name': fields.char('Name', size=64, required=True),100 'name': fields.char('Name', size=64, required=True),
101 'track_str': fields.char('Tracking String', 32),
91 'object_id': fields.many2one('ir.model', 'Resource', required=True,102 'object_id': fields.many2one('ir.model', 'Resource', required=True,
92 help="Choose the resource on which you want \103 help="Choose the resource on which you want \
93this campaign to be run"),104this campaign to be run"),
@@ -128,6 +139,13 @@
128 'mode': lambda *a: 'test',139 'mode': lambda *a: 'test',
129 }140 }
130141
142 def create(self, cr, uid, vals, context=None):
143 res_id = super(marketing_campaign, self).create(cr, uid, vals, context)
144 track_str = compute_track_str(cr, uid, self._name + '_' + str(res_id))
145 vals = {'track_str': track_str}
146 self.pool.get(self._name).write(cr, uid, res_id, vals)
147 return res_id
148
131 def state_running_set(self, cr, uid, ids, *args):149 def state_running_set(self, cr, uid, ids, *args):
132 # TODO check that all subcampaigns are running150 # TODO check that all subcampaigns are running
133 campaign = self.browse(cr, uid, ids[0])151 campaign = self.browse(cr, uid, ids[0])
@@ -386,6 +404,7 @@
386404
387marketing_campaign_segment()405marketing_campaign_segment()
388406
407
389class marketing_campaign_activity(osv.osv):408class marketing_campaign_activity(osv.osv):
390 _name = "marketing.campaign.activity"409 _name = "marketing.campaign.activity"
391 _order = "name"410 _order = "name"
@@ -403,6 +422,7 @@
403422
404 _columns = {423 _columns = {
405 'name': fields.char('Name', size=128, required=True),424 'name': fields.char('Name', size=128, required=True),
425 'track_str': fields.char('Tracking String', 32),
406 'campaign_id': fields.many2one('marketing.campaign', 'Campaign',426 'campaign_id': fields.many2one('marketing.campaign', 'Campaign',
407 required = True, ondelete='cascade', select=1),427 required = True, ondelete='cascade', select=1),
408 'object_id': fields.related('campaign_id','object_id',428 'object_id': fields.related('campaign_id','object_id',
@@ -417,6 +437,8 @@
417 " - resource: the resource object this campaign item represents\n"437 " - resource: the resource object this campaign item represents\n"
418 " - transitions: list of campaign transitions outgoing from this activity\n"438 " - transitions: list of campaign transitions outgoing from this activity\n"
419 "...- re: Python regular expression module"),439 "...- re: Python regular expression module"),
440 'parent_response_cond': fields.char('Response to previous action', None),
441 '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.'),
420 'type': fields.selection(_action_types, 'Type', required=True,442 'type': fields.selection(_action_types, 'Type', required=True,
421 help="""The type of action to execute when an item enters this activity, such as:443 help="""The type of action to execute when an item enters this activity, such as:
422 - Email: send an email using a predefined email template444 - Email: send an email using a predefined email template
@@ -446,6 +468,7 @@
446 _defaults = {468 _defaults = {
447 'type': lambda *a: 'email',469 'type': lambda *a: 'email',
448 'condition': lambda *a: 'True',470 'condition': lambda *a: 'True',
471 'parent_no_response': False,
449 }472 }
450473
451 def search(self, cr, uid, args, offset=0, limit=None, order=None,474 def search(self, cr, uid, args, offset=0, limit=None, order=None,
@@ -479,10 +502,40 @@
479 return True502 return True
480503
481 def _process_wi_email(self, cr, uid, activity, workitem, context=None):504 def _process_wi_email(self, cr, uid, activity, workitem, context=None):
482 return self.pool.get('email.template').send_mail(cr, uid,505 retval = None
506 send_mail = True
507
508 target_model = self.pool.get(activity.object_id['model'])
509 target = target_model.browse(cr, uid, workitem.res_id, context)
510 # TODO: make sure opt_out is called opt_out in all possible mail-receiving models
511 send_mail &= 'opt_out' in target and not target.opt_out
512
513 if activity.campaign_id.partner_field_id:
514 partner_id_field = activity.campaign_id.partner_field_id.name
515 partner = target.__getattr__(partner_id_field)
516 send_mail &= not partner.opt_out
517
518 if send_mail:
519 retval = self.pool.get('email.template').send_mail(cr, uid,
483 activity.email_template_id.id,520 activity.email_template_id.id,
484 workitem.res_id, context=context)521 workitem.res_id, context=context)
485522
523 if retval:
524 self.pool.get('marketing.campaign.workitem').write(cr, uid, workitem.id, {'mail_sent': True})
525 tracklog_vals = {
526 'workitem_id': workitem.id,
527 'activity_id': workitem.activity_id.id,
528 'res_track_id': workitem.trackitem_id.id,
529 'mail_message_id': retval,
530 'log_date': datetime.now().strftime(DT_FMT),
531 'log_ip': '0.0.0.0',
532 'log_type': 'mail_sent',
533 'revenue': 0.0,
534 'log_url': 'N/A',
535 }
536 self.pool.get('marketing.campaign.tracklog').create(cr, uid, tracklog_vals)
537 return retval
538
486 #dead code539 #dead code
487 def _process_wi_action(self, cr, uid, activity, workitem, context=None):540 def _process_wi_action(self, cr, uid, activity, workitem, context=None):
488 if context is None:541 if context is None:
@@ -511,6 +564,26 @@
511 workitem = workitem_obj.browse(cr, uid, wi_id, context=context)564 workitem = workitem_obj.browse(cr, uid, wi_id, context=context)
512 return action(cr, uid, activity, workitem, context=context)565 return action(cr, uid, activity, workitem, context=context)
513566
567 def onchange_parent_no_response(self, cr, uid, ids, value, context=None):
568 parent_response_cond = None
569 if not value:
570 activities = self.pool.get(self._name).browse(cr, uid, ids, context)
571 if len(activities):
572 parent_response_cond = activities[0].parent_response_cond
573 return {'value': {'parent_response_cond': parent_response_cond}}
574
575 def write(self, cr, uid, ids, vals, context=None):
576 if 'parent_no_response' in vals and vals['parent_no_response']:
577 vals['parent_response_cond'] = None
578 return super(marketing_campaign_activity, self).write(cr, uid, ids, vals, context=context)
579
580 def create(self, cr, uid, vals, context=None):
581 res_id = super(marketing_campaign_activity, self).create(cr, uid, vals, context)
582 track_str = compute_track_str(cr, uid, self._name + '_' + str(res_id))
583 vals = {'track_str': track_str}
584 self.pool.get(self._name).write(cr, uid, res_id, vals, context=context)
585 return res_id
586
514marketing_campaign_activity()587marketing_campaign_activity()
515588
516class marketing_campaign_transition(osv.osv):589class marketing_campaign_transition(osv.osv):
@@ -637,6 +710,7 @@
637 return [('id', 'in', list(set(matching_workitems)))]710 return [('id', 'in', list(set(matching_workitems)))]
638711
639 _columns = {712 _columns = {
713 'parent_id': fields.many2one('marketing.campaign.workitem', 'Parent'),
640 'segment_id': fields.many2one('marketing.campaign.segment', 'Segment', readonly=True),714 'segment_id': fields.many2one('marketing.campaign.segment', 'Segment', readonly=True),
641 'activity_id': fields.many2one('marketing.campaign.activity','Activity',715 'activity_id': fields.many2one('marketing.campaign.activity','Activity',
642 required=True, readonly=True),716 required=True, readonly=True),
@@ -646,6 +720,8 @@
646 type='many2one', relation='ir.model', string='Resource', select=1, readonly=True, store=True),720 type='many2one', relation='ir.model', string='Resource', select=1, readonly=True, store=True),
647 'res_id': fields.integer('Resource ID', select=1, readonly=True),721 'res_id': fields.integer('Resource ID', select=1, readonly=True),
648 'res_name': fields.function(_res_name_get, string='Resource Name', fnct_search=_resource_search, type="char", size=64),722 'res_name': fields.function(_res_name_get, string='Resource Name', fnct_search=_resource_search, type="char", size=64),
723 'trackitem_id': fields.many2one('marketing.campaign.trackitem', 'Tracking Tag'),
724 'tracklog_ids': fields.one2many('marketing.campaign.tracklog', 'workitem_id', 'Tracking Logs'),
649 'date': fields.datetime('Execution Date', help='If date is not set, this workitem has to be run manually', readonly=True),725 'date': fields.datetime('Execution Date', help='If date is not set, this workitem has to be run manually', readonly=True),
650 'partner_id': fields.many2one('res.partner', 'Partner', select=1, readonly=True),726 'partner_id': fields.many2one('res.partner', 'Partner', select=1, readonly=True),
651 'state': fields.selection([ ('todo', 'To Do'),727 'state': fields.selection([ ('todo', 'To Do'),
@@ -653,7 +729,15 @@
653 ('exception', 'Exception'),729 ('exception', 'Exception'),
654 ('done', 'Done'),730 ('done', 'Done'),
655 ], 'Status', readonly=True),731 ], 'Status', readonly=True),
656 'error_msg' : fields.text('Error Message', readonly=True)732 'error_msg' : fields.text('Error Message', readonly=True),
733 # Come from the tracklogs, but here for ease of reporting..
734 'mail_sent': fields.boolean('Mail Sent'),
735 'mail_opened': fields.boolean('Mail Opened'),
736 'mail_forwarded': fields.boolean('Mail Forwarded'),
737 'mail_bounced': fields.boolean('Mail Bounced'),
738 'subscribers': fields.integer('Subscribers'),
739 'unsubscribed': fields.boolean('Unsubscribed'),
740 'clicks': fields.integer('Page Views'),
657 }741 }
658 _defaults = {742 _defaults = {
659 'state': lambda *a: 'todo',743 'state': lambda *a: 'todo',
@@ -698,6 +782,35 @@
698 else:782 else:
699 workitem.unlink(context=context)783 workitem.unlink(context=context)
700 return784 return
785 # Make it easy for the user to express conditions
786 # First: absense of response
787 if activity.parent_no_response:
788 searchvals = [
789 ('workitem_id', '=', workitem.parent_id.id),
790 ('log_type', 'not in', ['mail_sent', 'mail_opened', 'url_viewed']),
791 ]
792 parent_responses = self.pool.get('marketing.campaign.tracklog').search(cr, uid, searchvals, context=context)
793 if len(parent_responses):
794 if activity.keep_if_condition_not_met:
795 workitem.write({'state': 'cancelled'}, context=context)
796 else:
797 workitem.unlink(context=context)
798 return
799 # Next, specific response
800 parent_response_cond = activity.parent_response_cond
801 if parent_response_cond and workitem.parent_id:
802 searchvals = [
803 ('workitem_id', '=', workitem.parent_id.id),
804 ('log_type', '=', parent_response_cond),
805 ]
806 parent_responses = self.pool.get('marketing.campaign.tracklog').search(cr, uid, searchvals, context=context)
807 if not len(parent_responses):
808 if activity.keep_if_condition_not_met:
809 workitem.write({'state': 'cancelled'}, context=context)
810 else:
811 workitem.unlink(context=context)
812 return
813 # End modif
701 result = True814 result = True
702 if campaign_mode in ('manual', 'active'):815 if campaign_mode in ('manual', 'active'):
703 Activities = self.pool.get('marketing.campaign.activity')816 Activities = self.pool.get('marketing.campaign.activity')
@@ -711,7 +824,7 @@
711824
712 if result:825 if result:
713 # process _chain826 # process _chain
714 workitem = workitem.browse(context=context)[0] # reload827 workitem = workitem.browse(context=context)[0] # reload
715 date = datetime.strptime(workitem.date, DT_FMT)828 date = datetime.strptime(workitem.date, DT_FMT)
716829
717 for transition in activity.to_ids:830 for transition in activity.to_ids:
@@ -732,6 +845,7 @@
732 'partner_id': workitem.partner_id.id,845 'partner_id': workitem.partner_id.id,
733 'res_id': workitem.res_id,846 'res_id': workitem.res_id,
734 'state': 'todo',847 'state': 'todo',
848 'parent_id': workitem.id, # Keep track of parent workitem so we can use that for condition lookups
735 }849 }
736 wi_id = self.create(cr, uid, values, context=context)850 wi_id = self.create(cr, uid, values, context=context)
737851
@@ -760,7 +874,18 @@
760 context=context)874 context=context)
761875
762 def process(self, cr, uid, workitem_ids, context=None):876 def process(self, cr, uid, workitem_ids, context=None):
877 if context is None:
878 context = {}
763 for wi in self.browse(cr, uid, workitem_ids, context=context):879 for wi in self.browse(cr, uid, workitem_ids, context=context):
880 campaign_track_str = wi.campaign_id.track_str
881 activity_track_str = wi.activity_id.track_str
882 res_track_str = wi.trackitem_id.track_str
883 context.update({
884 'campaign_track_str': campaign_track_str,
885 'activity_track_str': activity_track_str,
886 'res_track_str': res_track_str,
887 'workitem_id': wi.id,
888 })
764 self._process_one(cr, uid, wi, context=context)889 self._process_one(cr, uid, wi, context=context)
765 return True890 return True
766891
@@ -819,17 +944,55 @@
819 raise osv.except_osv(_('No preview'),_('The current step for this item has no email or report to preview.'))944 raise osv.except_osv(_('No preview'),_('The current step for this item has no email or report to preview.'))
820 return res945 return res
821946
947 def create(self, cr, uid, vals, context=None):
948 # Get model we're working on via our associated activity
949 activity_obj = self.pool.get('marketing.campaign.activity').browse(cr, uid, vals['activity_id'])
950 search_domain = [
951 ('model_id', '=', activity_obj.object_id.id),
952 ('res_id', '=', vals['res_id']),
953 ]
954 trackitem_obj = self.pool.get('marketing.campaign.trackitem')
955 found_trackitems = trackitem_obj.search(cr, uid, search_domain)
956 # And if no trackitem is fond, corresponding the criteria, create one
957 if len(found_trackitems):
958 trackitem_id = found_trackitems[0]
959 else:
960 wvals = {
961 'model_id': activity_obj.object_id.id,
962 'res_id': vals['res_id'],
963 }
964 trackitem_id = trackitem_obj.create(cr, uid, wvals, context)
965 vals.update({'trackitem_id': trackitem_id})
966 return super(marketing_campaign_workitem, self).create(cr, uid, vals, context)
967
822marketing_campaign_workitem()968marketing_campaign_workitem()
823969
824class email_template(osv.osv):970
825 _inherit = "email.template"971class marketing_campaign_trackitem(osv.osv):
826 _defaults = {972 _name = 'marketing.campaign.trackitem'
827 'model_id': lambda obj, cr, uid, context: context.get('object_id',False),973 _rec_name = 'track_str'
974 _description = 'Tracking Tags'
975
976 _columns = {
977 'model_id': fields.many2one('ir.model', 'Resource', required=True),
978 'res_id': fields.integer('Resource ID', required=True),
979 'track_str': fields.char('Resource Code', 32),
828 }980 }
829981
830 # TODO: add constraint to prevent disabling / disapproving an email account used in a running campaign982 def create(self, cr, uid, vals, context=None):
831983 res_id = super(marketing_campaign_trackitem, self).create(cr, uid, vals, context)
832email_template()984 ir_model = self.pool.get('ir.model').browse(cr, uid, vals['model_id'], context=context)
985 model_name = ir_model.model
986 if 'track_str' not in vals:
987 res_track_str = compute_track_str(cr, uid, model_name + '_' + str(vals['res_id']))
988 wvals = {
989 'track_str': res_track_str,
990 }
991 self.pool.get('marketing.campaign.trackitem').write(cr, uid, res_id, wvals, context)
992 return res_id
993
994marketing_campaign_trackitem()
995
833996
834class report_xml(osv.osv):997class report_xml(osv.osv):
835 _inherit = 'ir.actions.report.xml'998 _inherit = 'ir.actions.report.xml'
836999
=== modified file 'marketing_campaign/marketing_campaign_view.xml'
--- marketing_campaign/marketing_campaign_view.xml 2012-08-08 13:06:14 +0000
+++ marketing_campaign/marketing_campaign_view.xml 2012-08-27 10:03:27 +0000
@@ -261,6 +261,8 @@
261 </group>261 </group>
262 <group >262 <group >
263 <group>263 <group>
264 <field name="parent_response_cond" attrs="{'readonly': [('parent_no_response', '=', True)]}"/>
265 <field name="parent_no_response" on_change="onchange_parent_no_response(parent_no_response)"/>
264 <field name="condition" widget="char"/>266 <field name="condition" widget="char"/>
265 <field name="keep_if_condition_not_met"/>267 <field name="keep_if_condition_not_met"/>
266 </group>268 </group>
267269
=== modified file 'marketing_campaign/report/__init__.py'
--- marketing_campaign/report/__init__.py 2011-01-14 00:11:01 +0000
+++ marketing_campaign/report/__init__.py 2012-08-27 10:03:27 +0000
@@ -20,5 +20,6 @@
20##############################################################################20##############################################################################
2121
22import campaign_analysis22import campaign_analysis
23import campaign_tracking
23# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:24# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
2425
2526
=== modified file 'marketing_campaign/report/campaign_analysis.py'
--- marketing_campaign/report/campaign_analysis.py 2012-07-10 12:34:00 +0000
+++ marketing_campaign/report/campaign_analysis.py 2012-08-27 10:03:27 +0000
@@ -43,6 +43,7 @@
43 ((ca_obj.campaign_id.fixed_cost or 1.00) / len(wi_ids))43 ((ca_obj.campaign_id.fixed_cost or 1.00) / len(wi_ids))
44 result[ca_obj.id] = total_cost44 result[ca_obj.id] = total_cost
45 return result45 return result
46
46 _columns = {47 _columns = {
47 'res_id' : fields.integer('Resource', readonly=True),48 'res_id' : fields.integer('Resource', readonly=True),
48 'year': fields.char('Year', size=4, readonly=True),49 'year': fields.char('Year', size=4, readonly=True),
@@ -66,17 +67,32 @@
66 type="float", digits_compute=dp.get_precision('Account')),67 type="float", digits_compute=dp.get_precision('Account')),
67 'revenue': fields.float('Revenue', readonly=True, digits_compute=dp.get_precision('Account')),68 'revenue': fields.float('Revenue', readonly=True, digits_compute=dp.get_precision('Account')),
68 'count' : fields.integer('# of Actions', readonly=True),69 'count' : fields.integer('# of Actions', readonly=True),
69 'state': fields.selection([('todo', 'To Do'),70 # 'state': fields.selection([('todo', 'To Do'),
70 ('exception', 'Exception'), ('done', 'Done'),71 # ('exception', 'Exception'), ('done', 'Done'),
71 ('cancelled', 'Cancelled')], 'Status', readonly=True),72 # ('cancelled', 'Cancelled')], 'Status', readonly=True),
73 # TODO: clean these up; don't think we still use them all
74 'mail_sent': fields.integer('Mail Sent'),
75 'mail_sent_bool': fields.boolean('Mail Sent B'),
76 'mail_opened': fields.integer('Mail Opened'),
77 'mail_opened_bool': fields.boolean('Mail Opened B'),
78 'mail_forwarded': fields.integer('Mail Forwarded'),
79 'mail_forwarded_bool': fields.boolean('Mail Forwarded B'),
80 'mail_bounced': fields.integer('Mail Bounced'),
81 'mail_bounced_bool': fields.boolean('Mail Bounced B'),
82 # 'subscribers': fields.integer('Subscribers'),
83 'unsubscribed': fields.integer('Unsubscribed'),
84 'unsubscribed_bool': fields.boolean('Unsubscribed B'),
85 'clicks': fields.integer('Page Views'),
72 }86 }
87
73 def init(self, cr):88 def init(self, cr):
74 tools.drop_view_if_exists(cr, 'campaign_analysis')89 tools.drop_view_if_exists(cr, self._table)
75 cr.execute("""90 args = (self._table)
76 create or replace view campaign_analysis as (91 query = """
92 create or replace view %s as (
77 select93 select
78 min(wi.id) as id,94 wi.id as id,
79 min(wi.res_id) as res_id,95 wi.res_id as res_id,
80 to_char(wi.date::date, 'YYYY') as year,96 to_char(wi.date::date, 'YYYY') as year,
81 to_char(wi.date::date, 'MM') as month,97 to_char(wi.date::date, 'MM') as month,
82 to_char(wi.date::date, 'YYYY-MM-DD') as day,98 to_char(wi.date::date, 'YYYY-MM-DD') as day,
@@ -85,19 +101,82 @@
85 wi.activity_id as activity_id,101 wi.activity_id as activity_id,
86 wi.segment_id as segment_id,102 wi.segment_id as segment_id,
87 wi.partner_id as partner_id ,103 wi.partner_id as partner_id ,
88 wi.state as state,104 wi.mail_sent as mail_sent_bool,
89 sum(act.revenue) as revenue,105 wi.mail_sent::int as mail_sent,
90 count(*) as count106 wi.mail_opened as mail_opened_bool,
107 wi.mail_opened::int as mail_opened,
108 wi.mail_forwarded as mail_forwarded_bool,
109 wi.mail_forwarded::int as mail_forwarded,
110 wi.mail_bounced as mail_bounced_bool,
111 wi.mail_bounced::int as mail_bounced,
112 wi.unsubscribed as unsubscribed_bool,
113 wi.unsubscribed::int as unsubscribed,
114 (select count(*) from marketing_campaign_tracklog tl
115 where tl.log_type = 'url_viewed'
116 and tl.workitem_id = wi.id)
117 as clicks,
118 coalesce(act.revenue +
119 (select sum(revenue) from marketing_campaign_tracklog tl
120 where tl.workitem_id = wi.id),
121 0.0) as revenue
91 from122 from
92 marketing_campaign_workitem wi123 marketing_campaign_workitem wi
93 left join res_partner p on (p.id=wi.partner_id)124 left join res_partner p on (p.id=wi.partner_id)
94 left join marketing_campaign_segment s on (s.id=wi.segment_id)125 left join marketing_campaign_segment s on (s.id=wi.segment_id)
95 left join marketing_campaign_activity act on (act.id= wi.activity_id)126 left join marketing_campaign_activity act on (act.id= wi.activity_id)
96 group by127 group by
97 s.campaign_id,wi.activity_id,wi.segment_id,wi.partner_id,wi.state,128 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
98 wi.date::date
99 )129 )
100 """)130 """ % args
131 cr.execute(query)
132
101campaign_analysis()133campaign_analysis()
102134
135class campaign_click_analysis(osv.osv):
136 _name = "campaign.click_analysis"
137 _inherit = "campaign.analysis"
138 _description = 'Click Analysis'
139 _auto = False
140
141 _columns = {
142 # 'subscribers': fields.integer('Subscribers'),
143 'log_url': fields.char('URL', None),
144 'clicks': fields.integer('Page Views'),
145 }
146
147 def init(self, cr):
148 tools.drop_view_if_exists(cr, self._table)
149 args = (self._table)
150 query = """
151 create or replace view %s as (
152 select
153 tl.id as id,
154 wi.id as wi_id,
155 tl.res_id as res_id,
156 to_char(tl.log_date::date, 'YYYY') as year,
157 to_char(tl.log_date::date, 'MM') as month,
158 to_char(tl.log_date::date, 'YYYY-MM-DD') as day,
159 tl.log_date::date as date,
160 s.campaign_id as campaign_id,
161 wi.activity_id as activity_id,
162 wi.segment_id as segment_id,
163 wi.partner_id as partner_id ,
164 1 as clicks,
165 coalesce(tl.log_url, 'N/A') as log_url,
166 coalesce(act.revenue + tl.revenue, 0.0) as revenue
167 from
168 marketing_campaign_tracklog tl
169 left join marketing_campaign_workitem wi on (tl.workitem_id = wi.id)
170 left join res_partner p on (p.id=wi.partner_id)
171 left join marketing_campaign_segment s on (s.id=wi.segment_id)
172 left join marketing_campaign_activity act on (act.id= wi.activity_id)
173 where tl.log_type = 'url_viewed'
174 group by
175 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
176 )
177 """ % args
178 cr.execute(query)
179
180campaign_click_analysis()
181
103# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:182# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
104183
=== modified file 'marketing_campaign/report/campaign_analysis_view.xml'
--- marketing_campaign/report/campaign_analysis_view.xml 2012-08-08 13:06:14 +0000
+++ marketing_campaign/report/campaign_analysis_view.xml 2012-08-27 10:03:27 +0000
@@ -10,14 +10,24 @@
10 <field name="month" invisible="1"/>10 <field name="month" invisible="1"/>
11 <field name="day" invisible="1"/>11 <field name="day" invisible="1"/>
12 <field name="date" invisible="1"/>12 <field name="date" invisible="1"/>
13 <field name="state" invisible="1"/>13 <!-- <field name="state" invisible="1"/> -->
14 <field name="campaign_id" invisible="1"/>14 <field name="campaign_id" invisible="1"/>
15 <field name="activity_id" invisible="1"/>15 <field name="activity_id" invisible="1"/>
16 <field name="segment_id" invisible="1"/>16 <field name="segment_id" invisible="1"/>
17 <field name="partner_id" invisible="1"/>17 <field name="partner_id" invisible="1"/>
18 <field name="country_id" invisible="1"/>18 <field name="country_id" invisible="1"/>
19 <field name="res_id" invisible="1"/>19 <field name="res_id" invisible="1"/>
20 <field name="count"/>20 <field name="mail_sent"/>
21 <field name="mail_sent_bool" invisible="1"/>
22 <field name="mail_opened"/>
23 <field name="mail_opened_bool" invisible="1"/>
24 <field name="mail_forwarded"/>
25 <field name="mail_forwarded_bool" invisible="1"/>
26 <field name="mail_bounced"/>
27 <field name="mail_bounced_bool" invisible="1"/>
28 <field name="unsubscribed"/>
29 <field name="unsubscribed_bool" invisible="1"/>
30 <field name="clicks"/>
21 <field name="total_cost" string="Cost"/><!-- sum="Cost"/-->31 <field name="total_cost" string="Cost"/><!-- sum="Cost"/-->
22 <field name="revenue"/>32 <field name="revenue"/>
23 </tree>33 </tree>
@@ -29,6 +39,7 @@
29 <field name="model">campaign.analysis</field>39 <field name="model">campaign.analysis</field>
30 <field name="arch" type="xml">40 <field name="arch" type="xml">
31 <search string="Campaign Analysis">41 <search string="Campaign Analysis">
42<<<<<<< TREE
32 <field name="date"/>43 <field name="date"/>
33 <filter icon="terp-gtk-go-back-rtl" string="To Do" domain="[('state','=','todo')]"/>44 <filter icon="terp-gtk-go-back-rtl" string="To Do" domain="[('state','=','todo')]"/>
34 <filter icon="terp-dialog-close" string="Done" domain="[('state','=','done')]"/>45 <filter icon="terp-dialog-close" string="Done" domain="[('state','=','done')]"/>
@@ -38,12 +49,45 @@
38 <field name="segment_id"/>49 <field name="segment_id"/>
39 <field name="partner_id"/>50 <field name="partner_id"/>
40 <field name="country_id"/>51 <field name="country_id"/>
52=======
53 <group>
54 <field name="date"/>
55<!-- <separator orientation="vertical"/>
56 <filter icon="terp-gtk-go-back-rtl"
57 string="To Do"
58 domain="[('state','=','todo')]"/>
59 <filter icon="terp-dialog-close"
60 string="Done"
61 domain="[('state','=','done')]"/>
62 <filter icon="terp-emblem-important"
63 string="Exceptions"
64 domain="[('state','=','exception')]"/>
65 --> <separator orientation="vertical"/>
66 <field name="campaign_id"/>
67 <field name="activity_id"/>
68 <field name="segment_id"/>
69 <field name="partner_id"/>
70 <field name="country_id"/>
71 </group>
72 <newline/>
73>>>>>>> MERGE-SOURCE
41 <group expand="0" string="Group By...">74 <group expand="0" string="Group By...">
42 <filter string="Campaign" name="Campaign" icon="terp-gtk-jump-to-rtl" context="{'group_by':'campaign_id'}" />75 <filter string="Campaign" name="Campaign" icon="terp-gtk-jump-to-rtl" context="{'group_by':'campaign_id'}" />
43 <filter string="Segment" name="Segment" icon="terp-stock_symbol-selection" context="{'group_by':'segment_id'}"/>76 <filter string="Segment" name="Segment" icon="terp-stock_symbol-selection" context="{'group_by':'segment_id'}"/>
44 <filter string="Activity" name="activity" icon="terp-stock_align_left_24" context="{'group_by':'activity_id'}"/>77 <filter string="Activity" name="activity" icon="terp-stock_align_left_24" context="{'group_by':'activity_id'}"/>
45 <filter string="Resource" icon="terp-accessories-archiver" context="{'group_by':'res_id'}"/>78 <filter string="Resource" icon="terp-accessories-archiver" context="{'group_by':'res_id'}"/>
79<<<<<<< TREE
46 <filter string="Status" icon="terp-stock_effects-object-colorize" context="{'group_by':'state'}"/>80 <filter string="Status" icon="terp-stock_effects-object-colorize" context="{'group_by':'state'}"/>
81=======
82 <separator orientation="vertical"/>
83 <filter string="Mail Sent" icon="terp-mail-" context="{'group_by':'mail_sent_bool'}"/>
84 <filter string="Mail Opened" icon="terp-mail-message-new" context="{'group_by':'mail_opened_bool'}"/>
85 <filter string="Mail Forwarded" icon="terp-mail-forward" context="{'group_by':'mail_forwarded_bool'}"/>
86 <filter string="Mail Bounced" icon="terp-mail_delete" context="{'group_by':'mail_bounced_bool'}"/>
87 <filter string="Unsubscribed" icon="terp-mail_delete" context="{'group_by':'unsubscribed_bool'}"/>
88
89 <separator orientation="vertical"/>
90>>>>>>> MERGE-SOURCE
47 <filter string="Partner" icon="terp-partner" context="{'group_by':'partner_id'}"/>91 <filter string="Partner" icon="terp-partner" context="{'group_by':'partner_id'}"/>
48 <filter string="Day" icon="terp-go-today" context="{'group_by':'day'}"/>92 <filter string="Day" icon="terp-go-today" context="{'group_by':'day'}"/>
49 <filter string="Month" icon="terp-go-month" context="{'group_by':'month'}"/>93 <filter string="Month" icon="terp-go-month" context="{'group_by':'month'}"/>
@@ -53,17 +97,97 @@
53 </field>97 </field>
54 </record>98 </record>
5599
56 <record id="action_campaign_analysis_all" model="ir.actions.act_window">100
57 <field name="name">Campaign Analysis</field>101 <record id="view_campaign_click_analysis_tree" model="ir.ui.view">
58 <field name="res_model">campaign.analysis</field>102 <field name="name">campaign.click_analysis.tree</field>
59 <field name="view_type">form</field>103 <field name="model">campaign.click_analysis</field>
60 <field name="view_mode">tree</field>104 <field name="type">tree</field>
61 <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>105 <field name="arch" type="xml">
62 <field name="search_view_id" ref="view_campaign_analysis_search"/>106 <tree string="Marketing Reports">
63 </record>107 <field name="year" invisible="1"/>
108 <field name="month" invisible="1"/>
109 <field name="day" invisible="1"/>
110 <field name="date" invisible="1"/>
111 <!-- <field name="state" invisible="1"/> -->
112 <field name="campaign_id" invisible="1"/>
113 <field name="activity_id" invisible="1"/>
114 <field name="segment_id" invisible="1"/>
115 <field name="partner_id" invisible="1"/>
116 <!-- <field name="country_id" invisible="1"/> -->
117 <field name="res_id" invisible="1"/>
118 <field name="log_url" invisible="1"/>
119 <field name="total_cost" string="Cost"/><!-- sum="Cost"/-->
120 <field name="revenue"/>
121 </tree>
122 </field>
123 </record>
124
125 <record id="view_campaign_click_analysis_search" model="ir.ui.view">
126 <field name="name">campaign.click_analysis.search</field>
127 <field name="model">campaign.click_analysis</field>
128 <field name="type">search</field>
129 <field name="arch" type="xml">
130 <search string="Click Analysis">
131 <group>
132 <filter icon="terp-go-year" name="year"
133 string="Year"
134 domain="[('year','=',time.strftime('%%Y'))]"/>
135 <separator orientation="vertical"/>
136
137 <filter icon="terp-go-month"
138 string="Month" name="This Month"
139 domain="[('month','=',time.strftime('%%m'))]"/>
140 <filter icon="terp-go-month" string=" Month-1 "
141 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'))]"/>
142
143 <filter icon="terp-go-today"
144 string="Today"
145 domain="[('date','=',time.strftime('%%Y/%%m/%%d'))]"/>
146 <separator orientation="vertical"/>
147 <field name="campaign_id"/>
148 <field name="activity_id"/>
149 <field name="segment_id"/>
150 <field name="partner_id"/>
151 <field name="country_id"/>
152 </group>
153 <newline/>
154 <group expand="0" string="Group By...">
155 <filter string="Campaign" name="Campaign" icon="terp-gtk-jump-to-rtl" context="{'group_by':'campaign_id'}" />
156 <filter string="Segment" name="Segment" icon="terp-stock_symbol-selection" context="{'group_by':'segment_id'}" />
157 <filter string="Activity" name="activity" icon="terp-stock_align_left_24" context="{'group_by':'activity_id'}" />
158 <filter string="Resource" icon="terp-accessories-archiver" context="{'group_by':'res_id'}"/>
159 <filter string="url" name="URL" icon="STOCK_FILE" context="{'group_by':'log_url'}"/>
160 <separator orientation="vertical"/>
161 <filter string="Partner" icon="terp-partner" context="{'group_by':'partner_id'}"/>
162 <separator orientation="vertical"/>
163 <filter string="Day" icon="terp-go-today" context="{'group_by':'day'}"/>
164 <filter string="Month" icon="terp-go-month" context="{'group_by':'month'}"/>
165 <filter string="Year" icon="terp-go-year" context="{'group_by':'year'}"/>
166 </group>
167 </search>
168 </field>
169 </record>
170
171 <record id="action_campaign_analysis_all" model="ir.actions.act_window">
172 <field name="name">E-Mail Analysis</field>
173 <field name="res_model">campaign.analysis</field>
174 <field name="view_type">form</field>
175 <field name="view_mode">tree</field>
176 <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>
177 <field name="search_view_id" ref="view_campaign_analysis_search"/>
178 </record>
179
180 <record id="action_campaign_click_analysis" model="ir.actions.act_window">
181 <field name="name">Click Analysis</field>
182 <field name="res_model">campaign.click_analysis</field>
183 <field name="view_type">form</field>
184 <field name="view_mode">tree</field>
185 <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>
186 <field name="search_view_id" ref="view_campaign_click_analysis_search"/>
187 </record>
64188
65 <menuitem name="Marketing" id="base.menu_report_marketing" parent="base.menu_reporting" sequence="45"/>189 <menuitem name="Marketing" id="base.menu_report_marketing" parent="base.menu_reporting" sequence="45"/>
66 <menuitem action="action_campaign_analysis_all" id="menu_action_campaign_analysis_all" parent="base.menu_report_marketing" sequence="2"/>190 <menuitem action="action_campaign_analysis_all" id="menu_action_campaign_analysis_all" parent="base.menu_report_marketing" sequence="2"/>
67191 <menuitem action="action_campaign_click_analysis" id="menu_action_campaign_click_analysis" parent="base.menu_report_marketing" sequence="3"/>
68 </data>192 </data>
69</openerp>193</openerp>
70194
=== added file 'marketing_campaign/report/campaign_tracking.py'
--- marketing_campaign/report/campaign_tracking.py 1970-01-01 00:00:00 +0000
+++ marketing_campaign/report/campaign_tracking.py 2012-08-27 10:03:27 +0000
@@ -0,0 +1,188 @@
1from osv import osv, fields
2import re
3from datetime import datetime
4
5
6class campaign_tracklog(osv.osv):
7 _name = 'marketing.campaign.tracklog'
8 _description = 'Campaign Logs'
9
10 # These are copy/pasted from marketing.campaign.workitem
11 def _res_name_get(self, cr, uid, ids, field_name, arg, context=None):
12 res = dict.fromkeys(ids, 'N/A')
13 for tl in self.browse(cr, uid, ids, context=context):
14 if not tl.workitem_id:
15 continue
16 for wi in self.pool.get('marketing.campaign.workitem').browse(cr, uid, [tl.workitem_id.id], context=context):
17 if not wi.res_id:
18 continue
19
20 proxy = self.pool.get(wi.object_id.model)
21 if not proxy.exists(cr, uid, [wi.res_id]):
22 continue
23 ng = proxy.name_get(cr, uid, [wi.res_id], context=context)
24 if ng:
25 res[tl.id] = ng[0][1]
26 return res
27
28 def _resource_search(self, cr, uid, obj, name, args, domain=None, context=None):
29 """Returns id of tracklogs whose resource_name matches with the given name"""
30 if not len(args):
31 return []
32
33 condition_name = None
34 for domain_item in args:
35 # we only use the first domain criterion and ignore all the rest including operators
36 if isinstance(domain_item, (list,tuple)) and len(domain_item) == 3 and domain_item[0] == 'res_name':
37 condition_name = [None, domain_item[1], domain_item[2]]
38 break
39
40 assert condition_name, "Invalid search domain for marketing_campaign_workitem.res_name. It should use 'res_name'"
41
42 cr.execute("""select w.id, w.res_id, m.model \
43 from marketing_campaign_workitem w \
44 left join marketing_campaign_activity a on (a.id=w.activity_id)\
45 left join marketing_campaign c on (c.id=a.campaign_id)\
46 left join ir_model m on (m.id=c.object_id)
47 """)
48 res = cr.fetchall()
49 workitem_map = {}
50 matching_workitems = []
51 for id, res_id, model in res:
52 workitem_map.setdefault(model,{}).setdefault(res_id,set()).add(id)
53 for model, id_map in workitem_map.iteritems():
54 model_pool = self.pool.get(model)
55 condition_name[0] = model_pool._rec_name
56 condition = [('id', 'in', id_map.keys()), condition_name]
57 for res_id in model_pool.search(cr, uid, condition, context=context):
58 matching_workitems.extend(id_map[res_id])
59 return [('workitem_id', 'in', list(set(matching_workitems)))]
60 # end copy/paste
61
62 _columns = {
63 'workitem_id': fields.many2one('marketing.campaign.workitem', 'Work Item', ondelete="cascade"),
64 'activity_id': fields.many2one('marketing.campaign.activity', 'Campaign Activity', ondelete="cascade"),
65 'campaign_id': fields.related('activity_id', 'campaign_id', type='many2one', relation='marketing.campaign', string='Campaign', store=True),
66 'res_track_id': fields.many2one('marketing.campaign.trackitem', 'Tracking Tag'),
67 'model_id': fields.related('res_track_id', 'model_id', type='many2one', relation='ir.model', string='Resource Type', store=True),
68 'res_id': fields.related('res_track_id', 'res_id', type='integer', string='Resource ID', store=True),
69 'res_name': fields.function(_res_name_get, string='Resource Name', fnct_search=_resource_search, type="char", size=None),
70 'mail_message_id': fields.many2one('mail.message', 'Mail Message'),
71 'log_date': fields.datetime('Logged Date'),
72 'log_ip': fields.char('Logged IP', None),
73 'log_type': fields.char('Access Type', None),
74 'revenue': fields.float('Revenue', digits=(0,2)),
75 'log_url': fields.char('Logged URL', None),
76 }
77
78 _defaults = {
79 'log_ip': '0.0.0.0',
80 'log_date': lambda *a: datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
81 'revenue': 0,
82 'log_url': 'N/A',
83 }
84
85 def onchange_activity(self, cr, uid, ids, activity_id, context=None):
86 value = {}
87 if activity_id:
88 activity = self.pool.get('marketing.campaign.activity').browse(cr, uid, activity_id, context)
89 value['campaign_id'] = activity.campaign_id.id
90 value['model_id'] = activity.object_id.id
91 return {'value': value}
92
93 def onchange_trackitem(self, cr, uid, ids, res_track_id, context=None):
94 value = {}
95 if res_track_id:
96 trackitem = self.pool.get('marketing.campaign.trackitem').browse(cr, uid, res_track_id, context)
97 value['model_id'] = trackitem.model_id.id
98 value['res_id'] = trackitem.res_id
99 if trackitem.res_id:
100 target_model = self.pool.get(trackitem.model_id['model'])
101 discard, value['res_name'] = target_model.name_get(cr, uid, [trackitem.res_id], context=context)[0]
102 return {'value': value}
103
104 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
105 my_id = campaign_id = activity_id = trackitem_id = None
106 to_user = msg_dict['to'].split('@')[0]
107 if len(to_user.split('+')) > 1:
108 # Track string encoded in return-path's address tag
109 track_str = re.sub('=', '@', to_user.split('+')[1])
110 if len(track_str.split('-')) == 3:
111 campaign_str, activity_str, trackitem_str = track_str.split('-')
112 # Campaign
113 campaign_ids = self.pool.get('marketing.campaign').search(cr, uid, [('track_str', '=', campaign_str)])
114 if len(campaign_ids):
115 campaign_id = campaign_ids[0]
116 # Activity
117 activity_ids = self.pool.get('marketing.campaign.activity').search(cr, uid, [('track_str', '=', activity_str)])
118 if len(activity_ids):
119 activity_id = activity_ids[0]
120 # Trackitem
121 trackitem_ids = self.pool.get('marketing.campaign.trackitem').search(cr, uid, [('track_str', '=', trackitem_str)])
122 if len(trackitem_ids):
123 trackitem_id = trackitem_ids[0]
124 if campaign_id is not None and activity_id is not None and trackitem_id is not None:
125 wi_search_vals = [('campaign_id', '=', campaign_id),
126 ('activity_id', '=', activity_id),
127 ('trackitem_id', '=', trackitem_id)]
128 workitem_ids = self.pool.get('marketing.campaign.workitem').search(cr, uid, wi_search_vals)
129 if len(workitem_ids):
130 workitem_id = workitem_ids[0]
131 my_create_vals = {
132 'res_track_id': trackitem_id,
133 'log_type': 'mail_bounced',
134 'activity_id': activity_id,
135 'workitem_id': workitem_id,
136 }
137 my_id = self.create(cr, uid, my_create_vals)
138 return my_id
139
140 def create(self, cr, uid, vals, context=None):
141 # Check which workitem we belong to
142 res_id = self.pool.get('marketing.campaign.trackitem').browse(cr, uid, vals['res_track_id']).res_id
143 args = [
144 ('activity_id', '=', vals['activity_id']),
145 ('res_id', '=', res_id),
146 ]
147 wi_obj = self.pool.get('marketing.campaign.workitem')
148 wi_id = wi_obj.search(cr, uid, args, context=context)[0]
149 vals.update({'workitem_id': wi_id})
150 # Check which mail message we belong to
151 mail_msg_obj = self.pool.get('mail.message')
152 mail_msg_ids = mail_msg_obj.search(cr, uid, [('workitem_id', '=', wi_id)])
153 if len(mail_msg_ids):
154 vals.update({'mail_message_id': mail_msg_ids[0]})
155 retval = super(campaign_tracklog, self).create(cr, uid, vals, context=context)
156
157 # Update related workitem if necessary
158 wvals = {}
159 if vals['log_type'] in ['mail_opened', 'mail_forwarded']:
160 wvals.update({'mail_opened': True})
161 # If opened from multiple IPs, consider it a forward.
162 # We're probably never gonna see "mail_forwarded" in the wild
163 fwd_search = [
164 ('workitem_id', '=', wi_id),
165 ('log_type', '=', 'mail_opened'),
166 ('log_ip', '!=', vals['log_ip']),
167 ]
168 if len(self.pool.get(self._name).search(cr, uid, fwd_search)) \
169 or vals['log_type'] == 'mail_forwarded':
170 wvals.update({'mail_forwarded': True})
171 # We're probably never gonna see "mail_bounced" in the wild.
172 # TODO: implement logic for bounces, unsubscribes
173 if vals['log_type'] == 'mail_bounced':
174 wvals.update({'mail_bounced': True})
175 if vals['log_type'] == 'unsubscribe':
176 wvals.update({'unsubscribed': True})
177 # Check if target has optin or optout stuffi
178 wi = wi_obj.browse(cr, uid, wi_id, context)
179 target_model = self.pool.get(wi.object_id['model'])
180 target = target_model.browse(cr, uid, res_id, context)
181 # TODO: check it's opt_out everywhere
182 if 'opt_out' in target:
183 target_wvals = {
184 'opt_out': True,
185 }
186 target_model.write(cr, uid, res_id, target_wvals)
187 wi_obj.write(cr, uid, [wi_id], wvals, context)
188 return retval
0189
=== added file 'marketing_campaign/report/campaign_tracking_view.xml'
--- marketing_campaign/report/campaign_tracking_view.xml 1970-01-01 00:00:00 +0000
+++ marketing_campaign/report/campaign_tracking_view.xml 2012-08-27 10:03:27 +0000
@@ -0,0 +1,53 @@
1<?xml version="1.0" encoding="utf-8"?>
2<openerp>
3 <data>
4 <record model="ir.actions.act_window" id="action_marketing_campaign_log">
5 <field name="name">Campaign Logs</field>
6 <field name="res_model">marketing.campaign.tracklog</field>
7 <field name="view_type">form</field>
8 <field name="view_mode">tree,form</field>
9 <field name="help">Marketing Campaign Response Logs</field>
10 </record>
11
12 <record id="view_marketing_campaign_log_form" model="ir.ui.view">
13 <field name="name">marketing.campaign.log.form</field>
14 <field name="model">marketing.campaign.tracklog</field>
15 <field name="type">form</field>
16 <field name="arch" type="xml">
17 <form string="Logs">
18 <field name="campaign_id" readonly="True"/>
19 <field name="model_id" readonly="True"/>
20 <field name="activity_id" on_change="onchange_activity(activity_id)"/>
21 <field name="res_name" readonly="True"/>
22 <field name="res_track_id" on_change="onchange_trackitem(res_track_id)"/>
23 <field name="res_id" readonly="True"/>
24 <field name="log_date"/>
25 <field name="log_type"/>
26 <field name="log_ip"/>
27 <field name="revenue"/>
28 <field name="log_url"/>
29 </form>
30 </field>
31 </record>
32
33 <record id="view_marketing_campaign_log_list" model="ir.ui.view">
34 <field name="name">marketing.campaign.log.list</field>
35 <field name="model">marketing.campaign.tracklog</field>
36 <field name="type">tree</field>
37 <field name="arch" type="xml">
38 <form string="Logs">
39 <field name="log_date"/>
40 <field name="campaign_id"/>
41 <field name="activity_id"/>
42 <field name="model_id"/>
43 <field name="res_name"/>
44 <field name="log_type"/>
45 <field name="log_ip"/>
46 <field name="revenue"/>
47 </form>
48 </field>
49 </record>
50
51 <menuitem parent="base.menu_report_marketing" id="menu_marketing_campaign_log" action="action_marketing_campaign_log" />
52 </data>
53</openerp>

Subscribers

People subscribed via source and target branches

to all changes: