Merge lp:~openerp-dev/openobject-addons/trunk-campaign-tracking-jri into lp:openobject-addons
- trunk-campaign-tracking-jri
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
OpenERP Core Team | Pending | ||
Review via email: mp+116298@code.launchpad.net |
Commit message
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(' ', '') |
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','<=', (datetime.date.today() - relativedelta(day=31, months=1)).strftime('%%Y-%%m-%%d')),('create_date','>=',(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> |