[REF] stock: refactoring of recompute stock operation links
[odoo/odoo.git] / addons / marketing_campaign / marketing_campaign.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2013 OpenERP SA (<http://openerp.com>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 import time
23 import base64
24 import itertools
25 from datetime import datetime
26 from dateutil.relativedelta import relativedelta
27 from operator import itemgetter
28 from traceback import format_exception
29 from sys import exc_info
30 from openerp.tools.safe_eval import safe_eval as eval
31 import re
32 from openerp.addons.decimal_precision import decimal_precision as dp
33
34 from openerp.osv import fields, osv
35 from openerp.report import render_report
36 from openerp.tools.translate import _
37
38 _intervalTypes = {
39     'hours': lambda interval: relativedelta(hours=interval),
40     'days': lambda interval: relativedelta(days=interval),
41     'months': lambda interval: relativedelta(months=interval),
42     'years': lambda interval: relativedelta(years=interval),
43 }
44
45 DT_FMT = '%Y-%m-%d %H:%M:%S'
46
47 def dict_map(f, d):
48     return dict((k, f(v)) for k,v in d.items())
49
50 def _find_fieldname(model, field):
51     inherit_columns = dict_map(itemgetter(2), model._inherit_fields)
52     all_columns = dict(inherit_columns, **model._columns)
53     for fn in all_columns:
54         if all_columns[fn] is field:
55             return fn
56     raise ValueError('Field not found: %r' % (field,))
57
58 class selection_converter(object):
59     """Format the selection in the browse record objects"""
60     def __init__(self, value):
61         self._value = value
62         self._str = value
63
64     def set_value(self, cr, uid, _self_again, record, field, lang):
65         # this design is terrible
66         # search fieldname from the field
67         fieldname = _find_fieldname(record._table, field)
68         context = dict(lang=lang.code)
69         fg = record._table.fields_get(cr, uid, [fieldname], context=context)
70         selection = dict(fg[fieldname]['selection'])
71         self._str = selection[self.value]
72
73     @property
74     def value(self):
75         return self._value
76
77     def __str__(self):
78         return self._str
79
80 translate_selections = {
81     'selection': selection_converter,
82 }
83
84
85 class marketing_campaign(osv.osv):
86     _name = "marketing.campaign"
87     _description = "Marketing Campaign"
88
89     _columns = {
90         'name': fields.char('Name', size=64, required=True),
91         'object_id': fields.many2one('ir.model', 'Resource', required=True,
92                                       help="Choose the resource on which you want \
93 this campaign to be run"),
94         'partner_field_id': fields.many2one('ir.model.fields', 'Partner Field',
95                                             domain="[('model_id', '=', object_id), ('ttype', '=', 'many2one'), ('relation', '=', 'res.partner')]",
96                                             help="The generated workitems will be linked to the partner related to the record. "\
97                                                   "If the record is the partner itself leave this field empty. "\
98                                                   "This is useful for reporting purposes, via the Campaign Analysis or Campaign Follow-up views."),
99         'unique_field_id': fields.many2one('ir.model.fields', 'Unique Field',
100                                             domain="[('model_id', '=', object_id), ('ttype', 'in', ['char','int','many2one','text','selection'])]",
101                                             help='If set, this field will help segments that work in "no duplicates" mode to avoid '\
102                                                  'selecting similar records twice. Similar records are records that have the same value for '\
103                                                  'this unique field. For example by choosing the "email_from" field for CRM Leads you would prevent '\
104                                                  'sending the same campaign to the same email address again. If not set, the "no duplicates" segments '\
105                                                  "will only avoid selecting the same record again if it entered the campaign previously. "\
106                                                  "Only easily comparable fields like textfields, integers, selections or single relationships may be used."),
107         'mode': fields.selection([('test', 'Test Directly'),
108                                 ('test_realtime', 'Test in Realtime'),
109                                 ('manual', 'With Manual Confirmation'),
110                                 ('active', 'Normal')],
111                                  'Mode', required=True, help= \
112 """Test - It creates and process all the activities directly (without waiting for the delay on transitions) but does not send emails or produce reports.
113 Test in Realtime - It creates and processes all the activities directly but does not send emails or produce reports.
114 With Manual Confirmation - the campaigns runs normally, but the user has to validate all workitem manually.
115 Normal - the campaign runs normally and automatically sends all emails and reports (be very careful with this mode, you're live!)"""),
116         'state': fields.selection([('draft', 'New'),
117                                    ('running', 'Running'),
118                                    ('cancelled', 'Cancelled'),
119                                    ('done', 'Done')],
120                                    'Status',),
121         'activity_ids': fields.one2many('marketing.campaign.activity',
122                                        'campaign_id', 'Activities'),
123         'fixed_cost': fields.float('Fixed Cost', help="Fixed cost for running this campaign. You may also specify variable cost and revenue on each campaign activity. Cost and Revenue statistics are included in Campaign Reporting.", digits_compute=dp.get_precision('Product Price')),
124     }
125
126     _defaults = {
127         'state': lambda *a: 'draft',
128         'mode': lambda *a: 'test',
129     }
130
131     def state_running_set(self, cr, uid, ids, *args):
132         # TODO check that all subcampaigns are running
133         campaign = self.browse(cr, uid, ids[0])
134
135         if not campaign.activity_ids:
136             raise osv.except_osv(_("Error"), _("The campaign cannot be started. There are no activities in it."))
137
138         has_start = False
139         has_signal_without_from = False
140
141         for activity in campaign.activity_ids:
142             if activity.start:
143                 has_start = True
144             if activity.signal and len(activity.from_ids) == 0:
145                 has_signal_without_from = True
146
147         if not has_start and not has_signal_without_from:
148             raise osv.except_osv(_("Error"), _("The campaign cannot be started. It does not have any starting activity. Modify campaign's activities to mark one as the starting point."))
149
150         return self.write(cr, uid, ids, {'state': 'running'})
151
152     def state_done_set(self, cr, uid, ids, *args):
153         # TODO check that this campaign is not a subcampaign in running mode.
154         segment_ids = self.pool.get('marketing.campaign.segment').search(cr, uid,
155                                             [('campaign_id', 'in', ids),
156                                             ('state', '=', 'running')])
157         if segment_ids :
158             raise osv.except_osv(_("Error"), _("The campaign cannot be marked as done before all segments are closed."))
159         self.write(cr, uid, ids, {'state': 'done'})
160         return True
161
162     def state_cancel_set(self, cr, uid, ids, *args):
163         # TODO check that this campaign is not a subcampaign in running mode.
164         self.write(cr, uid, ids, {'state': 'cancelled'})
165         return True
166
167     # dead code
168     def signal(self, cr, uid, model, res_id, signal, run_existing=True, context=None):
169         record = self.pool[model].browse(cr, uid, res_id, context)
170         return self._signal(cr, uid, record, signal, run_existing, context)
171
172     #dead code
173     def _signal(self, cr, uid, record, signal, run_existing=True, context=None):
174         if not signal:
175             raise ValueError('Signal cannot be False.')
176
177         Workitems = self.pool.get('marketing.campaign.workitem')
178         domain = [('object_id.model', '=', record._table._name),
179                   ('state', '=', 'running')]
180         campaign_ids = self.search(cr, uid, domain, context=context)
181         for campaign in self.browse(cr, uid, campaign_ids, context=context):
182             for activity in campaign.activity_ids:
183                 if activity.signal != signal:
184                     continue
185
186                 data = dict(activity_id=activity.id,
187                             res_id=record.id,
188                             state='todo')
189                 wi_domain = [(k, '=', v) for k, v in data.items()]
190
191                 wi_ids = Workitems.search(cr, uid, wi_domain, context=context)
192                 if wi_ids:
193                     if not run_existing:
194                         continue
195                 else:
196                     partner = self._get_partner_for(campaign, record)
197                     if partner:
198                         data['partner_id'] = partner.id
199                     wi_id = Workitems.create(cr, uid, data, context=context)
200                     wi_ids = [wi_id]
201                 Workitems.process(cr, uid, wi_ids, context=context)
202         return True
203
204     def _get_partner_for(self, campaign, record):
205         partner_field = campaign.partner_field_id.name
206         if partner_field:
207             return getattr(record, partner_field)
208         elif campaign.object_id.model == 'res.partner':
209             return record
210         return None
211
212     # prevent duplication until the server properly duplicates several levels of nested o2m
213     def copy(self, cr, uid, id, default=None, context=None):
214         raise osv.except_osv(_("Operation not supported"), _("You cannot duplicate a campaign, Not supported yet."))
215
216     def _find_duplicate_workitems(self, cr, uid, record, campaign_rec, context=None):
217         """Finds possible duplicates workitems for a record in this campaign, based on a uniqueness
218            field.
219
220            :param record: browse_record to find duplicates workitems for.
221            :param campaign_rec: browse_record of campaign
222         """
223         Workitems = self.pool.get('marketing.campaign.workitem')
224         duplicate_workitem_domain = [('res_id','=', record.id),
225                                      ('campaign_id','=', campaign_rec.id)]
226         unique_field = campaign_rec.unique_field_id
227         if unique_field:
228             unique_value = getattr(record, unique_field.name, None)
229             if unique_value:
230                 if unique_field.ttype == 'many2one':
231                     unique_value = unique_value.id
232                 similar_res_ids = self.pool[campaign_rec.object_id.model].search(cr, uid,
233                                     [(unique_field.name, '=', unique_value)], context=context)
234                 if similar_res_ids:
235                     duplicate_workitem_domain = [('res_id','in', similar_res_ids),
236                                                  ('campaign_id','=', campaign_rec.id)]
237         return Workitems.search(cr, uid, duplicate_workitem_domain, context=context)
238
239
240
241 class marketing_campaign_segment(osv.osv):
242     _name = "marketing.campaign.segment"
243     _description = "Campaign Segment"
244     _order = "name"
245
246     def _get_next_sync(self, cr, uid, ids, fn, args, context=None):
247         # next auto sync date is same for all segments
248         sync_job = self.pool.get('ir.model.data').get_object(cr, uid, 'marketing_campaign', 'ir_cron_marketing_campaign_every_day', context=context)
249         next_sync = sync_job and sync_job.nextcall or False
250         return dict.fromkeys(ids, next_sync)
251
252     _columns = {
253         'name': fields.char('Name', size=64,required=True),
254         'campaign_id': fields.many2one('marketing.campaign', 'Campaign', required=True, select=1, ondelete="cascade"),
255         'object_id': fields.related('campaign_id','object_id', type='many2one', relation='ir.model', string='Resource'),
256         'ir_filter_id': fields.many2one('ir.filters', 'Filter', ondelete="restrict",
257                             help="Filter to select the matching resource records that belong to this segment. "\
258                                  "New filters can be created and saved using the advanced search on the list view of the Resource. "\
259                                  "If no filter is set, all records are selected without filtering. "\
260                                  "The synchronization mode may also add a criterion to the filter."),
261         'sync_last_date': fields.datetime('Last Synchronization', help="Date on which this segment was synchronized last time (automatically or manually)"),
262         'sync_mode': fields.selection([('create_date', 'Only records created after last sync'),
263                                       ('write_date', 'Only records modified after last sync (no duplicates)'),
264                                       ('all', 'All records (no duplicates)')],
265                                       'Synchronization mode',
266                                       help="Determines an additional criterion to add to the filter when selecting new records to inject in the campaign. "\
267                                            '"No duplicates" prevents selecting records which have already entered the campaign previously.'\
268                                            'If the campaign has a "unique field" set, "no duplicates" will also prevent selecting records which have '\
269                                            'the same value for the unique field as other records that already entered the campaign.'),
270         'state': fields.selection([('draft', 'New'),
271                                    ('cancelled', 'Cancelled'),
272                                    ('running', 'Running'),
273                                    ('done', 'Done')],
274                                    'Status',),
275         'date_run': fields.datetime('Launch Date', help="Initial start date of this segment."),
276         'date_done': fields.datetime('End Date', help="Date this segment was last closed or cancelled."),
277         'date_next_sync': fields.function(_get_next_sync, string='Next Synchronization', type='datetime', help="Next time the synchronization job is scheduled to run automatically"),
278     }
279
280     _defaults = {
281         'state': lambda *a: 'draft',
282         'sync_mode': lambda *a: 'create_date',
283     }
284
285     def _check_model(self, cr, uid, ids, context=None):
286         for obj in self.browse(cr, uid, ids, context=context):
287             if not obj.ir_filter_id:
288                 return True
289             if obj.campaign_id.object_id.model != obj.ir_filter_id.model_id:
290                 return False
291         return True
292
293     _constraints = [
294         (_check_model, 'Model of filter must be same as resource model of Campaign ', ['ir_filter_id,campaign_id']),
295     ]
296
297     def onchange_campaign_id(self, cr, uid, ids, campaign_id):
298         res = {'domain':{'ir_filter_id':[]}}
299         campaign_pool = self.pool.get('marketing.campaign')
300         if campaign_id:
301             campaign = campaign_pool.browse(cr, uid, campaign_id)
302             model_name = self.pool.get('ir.model').read(cr, uid, [campaign.object_id.id], ['model'])
303             if model_name:
304                 mod_name = model_name[0]['model']
305                 res['domain'] = {'ir_filter_id': [('model_id', '=', mod_name)]}
306         else:
307             res['value'] = {'ir_filter_id': False}
308         return res
309
310     def state_running_set(self, cr, uid, ids, *args):
311         segment = self.browse(cr, uid, ids[0])
312         vals = {'state': 'running'}
313         if not segment.date_run:
314             vals['date_run'] = time.strftime('%Y-%m-%d %H:%M:%S')
315         self.write(cr, uid, ids, vals)
316         return True
317
318     def state_done_set(self, cr, uid, ids, *args):
319         wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid,
320                                 [('state', '=', 'todo'), ('segment_id', 'in', ids)])
321         self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'})
322         self.write(cr, uid, ids, {'state': 'done','date_done': time.strftime('%Y-%m-%d %H:%M:%S')})
323         return True
324
325     def state_cancel_set(self, cr, uid, ids, *args):
326         wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid,
327                                 [('state', '=', 'todo'), ('segment_id', 'in', ids)])
328         self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'})
329         self.write(cr, uid, ids, {'state': 'cancelled','date_done': time.strftime('%Y-%m-%d %H:%M:%S')})
330         return True
331
332     def synchroniz(self, cr, uid, ids, *args):
333         self.process_segment(cr, uid, ids)
334         return True
335
336     def process_segment(self, cr, uid, segment_ids=None, context=None):
337         Workitems = self.pool.get('marketing.campaign.workitem')
338         Campaigns = self.pool.get('marketing.campaign')
339         if not segment_ids:
340             segment_ids = self.search(cr, uid, [('state', '=', 'running')], context=context)
341
342         action_date = time.strftime('%Y-%m-%d %H:%M:%S')
343         campaigns = set()
344         for segment in self.browse(cr, uid, segment_ids, context=context):
345             if segment.campaign_id.state != 'running':
346                 continue
347
348             campaigns.add(segment.campaign_id.id)
349             act_ids = self.pool.get('marketing.campaign.activity').search(cr,
350                   uid, [('start', '=', True), ('campaign_id', '=', segment.campaign_id.id)], context=context)
351
352             model_obj = self.pool[segment.object_id.model]
353             criteria = []
354             if segment.sync_last_date and segment.sync_mode != 'all':
355                 criteria += [(segment.sync_mode, '>', segment.sync_last_date)]
356             if segment.ir_filter_id:
357                 criteria += eval(segment.ir_filter_id.domain)
358             object_ids = model_obj.search(cr, uid, criteria, context=context)
359
360             # XXX TODO: rewrite this loop more efficiently without doing 1 search per record!
361             for record in model_obj.browse(cr, uid, object_ids, context=context):
362                 # avoid duplicate workitem for the same resource
363                 if segment.sync_mode in ('write_date','all'):
364                     if Campaigns._find_duplicate_workitems(cr, uid, record, segment.campaign_id, context=context):
365                         continue
366
367                 wi_vals = {
368                     'segment_id': segment.id,
369                     'date': action_date,
370                     'state': 'todo',
371                     'res_id': record.id
372                 }
373
374                 partner = self.pool.get('marketing.campaign')._get_partner_for(segment.campaign_id, record)
375                 if partner:
376                     wi_vals['partner_id'] = partner.id
377
378                 for act_id in act_ids:
379                     wi_vals['activity_id'] = act_id
380                     Workitems.create(cr, uid, wi_vals, context=context)
381
382             self.write(cr, uid, segment.id, {'sync_last_date':action_date}, context=context)
383         Workitems.process_all(cr, uid, list(campaigns), context=context)
384         return True
385
386
387 class marketing_campaign_activity(osv.osv):
388     _name = "marketing.campaign.activity"
389     _order = "name"
390     _description = "Campaign Activity"
391
392     _action_types = [
393         ('email', 'Email'),
394         ('report', 'Report'),
395         ('action', 'Custom Action'),
396         # TODO implement the subcampaigns.
397         # TODO implement the subcampaign out. disallow out transitions from
398         # subcampaign activities ?
399         #('subcampaign', 'Sub-Campaign'),
400     ]
401
402     _columns = {
403         'name': fields.char('Name', size=128, required=True),
404         'campaign_id': fields.many2one('marketing.campaign', 'Campaign',
405                                             required = True, ondelete='cascade', select=1),
406         'object_id': fields.related('campaign_id','object_id',
407                                       type='many2one', relation='ir.model',
408                                       string='Object', readonly=True),
409         'start': fields.boolean('Start', help= "This activity is launched when the campaign starts.", select=True),
410         'condition': fields.text('Condition', size=256, required=True,
411                                  help="Python expression to decide whether the activity can be executed, otherwise it will be deleted or cancelled."
412                                  "The expression may use the following [browsable] variables:\n"
413                                  "   - activity: the campaign activity\n"
414                                  "   - workitem: the campaign workitem\n"
415                                  "   - resource: the resource object this campaign item represents\n"
416                                  "   - transitions: list of campaign transitions outgoing from this activity\n"
417                                  "...- re: Python regular expression module"),
418         'type': fields.selection(_action_types, 'Type', required=True,
419                                   help="""The type of action to execute when an item enters this activity, such as:
420    - Email: send an email using a predefined email template
421    - Report: print an existing Report defined on the resource item and save it into a specific directory
422    - Custom Action: execute a predefined action, e.g. to modify the fields of the resource record
423   """),
424         'email_template_id': fields.many2one('email.template', "Email Template", help='The email to send when this activity is activated'),
425         'report_id': fields.many2one('ir.actions.report.xml', "Report", help='The report to generate when this activity is activated', ),
426         'report_directory_id': fields.many2one('document.directory','Directory',
427                                 help="This folder is used to store the generated reports"),
428         'server_action_id': fields.many2one('ir.actions.server', string='Action',
429                                 help= "The action to perform when this activity is activated"),
430         'to_ids': fields.one2many('marketing.campaign.transition',
431                                             'activity_from_id',
432                                             'Next Activities'),
433         'from_ids': fields.one2many('marketing.campaign.transition',
434                                             'activity_to_id',
435                                             'Previous Activities'),
436         'variable_cost': fields.float('Variable Cost', help="Set a variable cost if you consider that every campaign item that has reached this point has entailed a certain cost. You can get cost statistics in the Reporting section", digits_compute=dp.get_precision('Product Price')),
437         'revenue': fields.float('Revenue', help="Set an expected revenue if you consider that every campaign item that has reached this point has generated a certain revenue. You can get revenue statistics in the Reporting section", digits_compute=dp.get_precision('Account')),
438         'signal': fields.char('Signal', size=128,
439                               help='An activity with a signal can be called programmatically. Be careful, the workitem is always created when a signal is sent'),
440         'keep_if_condition_not_met': fields.boolean("Don't Delete Workitems",
441                                                     help="By activating this option, workitems that aren't executed because the condition is not met are marked as cancelled instead of being deleted.")
442     }
443
444     _defaults = {
445         'type': lambda *a: 'email',
446         'condition': lambda *a: 'True',
447     }
448
449     def search(self, cr, uid, args, offset=0, limit=None, order=None,
450                                         context=None, count=False):
451         if context == None:
452             context = {}
453         if 'segment_id' in context  and context['segment_id']:
454             segment_obj = self.pool.get('marketing.campaign.segment').browse(cr,
455                                                     uid, context['segment_id'])
456             act_ids = []
457             for activity in segment_obj.campaign_id.activity_ids:
458                 act_ids.append(activity.id)
459             return act_ids
460         return super(marketing_campaign_activity, self).search(cr, uid, args,
461                                            offset, limit, order, context, count)
462
463     #dead code
464     def _process_wi_report(self, cr, uid, activity, workitem, context=None):
465         report_data, format = render_report(cr, uid, [], activity.report_id.report_name, {}, context=context)
466         attach_vals = {
467             'name': '%s_%s_%s'%(activity.report_id.report_name,
468                                 activity.name,workitem.partner_id.name),
469             'datas_fname': '%s.%s'%(activity.report_id.report_name,
470                                         activity.report_id.report_type),
471             'parent_id': activity.report_directory_id.id,
472             'datas': base64.encodestring(report_data),
473             'file_type': format
474         }
475         self.pool.get('ir.attachment').create(cr, uid, attach_vals)
476         return True
477
478     def _process_wi_email(self, cr, uid, activity, workitem, context=None):
479         return self.pool.get('email.template').send_mail(cr, uid,
480                                             activity.email_template_id.id,
481                                             workitem.res_id, context=context)
482
483     #dead code
484     def _process_wi_action(self, cr, uid, activity, workitem, context=None):
485         if context is None:
486             context = {}
487         server_obj = self.pool.get('ir.actions.server')
488
489         action_context = dict(context,
490                               active_id=workitem.res_id,
491                               active_ids=[workitem.res_id],
492                               active_model=workitem.object_id.model,
493                               workitem=workitem)
494         res = server_obj.run(cr, uid, [activity.server_action_id.id],
495                              context=action_context)
496         # server action return False if the action is performed
497         # except client_action, other and python code
498         return res == False and True or res
499
500     def process(self, cr, uid, act_id, wi_id, context=None):
501         activity = self.browse(cr, uid, act_id, context=context)
502         method = '_process_wi_%s' % (activity.type,)
503         action = getattr(self, method, None)
504         if not action:
505             raise NotImplementedError('Method %r is not implemented on %r object.' % (method, self))
506
507         workitem_obj = self.pool.get('marketing.campaign.workitem')
508         workitem = workitem_obj.browse(cr, uid, wi_id, context=context)
509         return action(cr, uid, activity, workitem, context=context)
510
511
512 class marketing_campaign_transition(osv.osv):
513     _name = "marketing.campaign.transition"
514     _description = "Campaign Transition"
515
516     _interval_units = [
517         ('hours', 'Hour(s)'), ('days', 'Day(s)'),
518         ('months', 'Month(s)'), ('years','Year(s)')
519     ]
520
521     def _get_name(self, cr, uid, ids, fn, args, context=None):
522         result = dict.fromkeys(ids, False)
523         formatters = {
524             'auto': _('Automatic transition'),
525             'time': _('After %(interval_nbr)d %(interval_type)s'),
526             'cosmetic': _('Cosmetic'),
527         }
528         for tr in self.browse(cr, uid, ids, context=context,
529                               fields_process=translate_selections):
530             result[tr.id] = formatters[tr.trigger.value] % tr
531         return result
532
533
534     def _delta(self, cr, uid, ids, context=None):
535         assert len(ids) == 1
536         transition = self.browse(cr, uid, ids[0], context=context)
537         if transition.trigger != 'time':
538             raise ValueError('Delta is only relevant for timed transition.')
539         return relativedelta(**{str(transition.interval_type): transition.interval_nbr})
540
541
542     _columns = {
543         'name': fields.function(_get_name, string='Name',
544                                 type='char', size=128),
545         'activity_from_id': fields.many2one('marketing.campaign.activity',
546                                             'Previous Activity', select=1,
547                                             required=True, ondelete="cascade"),
548         'activity_to_id': fields.many2one('marketing.campaign.activity',
549                                           'Next Activity',
550                                           required=True, ondelete="cascade"),
551         'interval_nbr': fields.integer('Interval Value', required=True),
552         'interval_type': fields.selection(_interval_units, 'Interval Unit',
553                                           required=True),
554
555         'trigger': fields.selection([('auto', 'Automatic'),
556                                      ('time', 'Time'),
557                                      ('cosmetic', 'Cosmetic'),  # fake plastic transition
558                                     ],
559                                     'Trigger', required=True,
560                                     help="How is the destination workitem triggered"),
561     }
562
563     _defaults = {
564         'interval_nbr': 1,
565         'interval_type': 'days',
566         'trigger': 'time',
567     }
568     def _check_campaign(self, cr, uid, ids, context=None):
569         for obj in self.browse(cr, uid, ids, context=context):
570             if obj.activity_from_id.campaign_id != obj.activity_to_id.campaign_id:
571                 return False
572         return True
573
574     _constraints = [
575             (_check_campaign, 'The To/From Activity of transition must be of the same Campaign ', ['activity_from_id,activity_to_id']),
576         ]
577
578     _sql_constraints = [
579         ('interval_positive', 'CHECK(interval_nbr >= 0)', 'The interval must be positive or zero')
580     ]
581
582
583 class marketing_campaign_workitem(osv.osv):
584     _name = "marketing.campaign.workitem"
585     _description = "Campaign Workitem"
586
587     def _res_name_get(self, cr, uid, ids, field_name, arg, context=None):
588         res = dict.fromkeys(ids, '/')
589         for wi in self.browse(cr, uid, ids, context=context):
590             if not wi.res_id:
591                 continue
592
593             proxy = self.pool[wi.object_id.model]
594             if not proxy.exists(cr, uid, [wi.res_id]):
595                 continue
596             ng = proxy.name_get(cr, uid, [wi.res_id], context=context)
597             if ng:
598                 res[wi.id] = ng[0][1]
599         return res
600
601     def _resource_search(self, cr, uid, obj, name, args, domain=None, context=None):
602         """Returns id of workitem whose resource_name matches  with the given name"""
603         if not len(args):
604             return []
605
606         condition_name = None
607         for domain_item in args:
608             # we only use the first domain criterion and ignore all the rest including operators
609             if isinstance(domain_item, (list,tuple)) and len(domain_item) == 3 and domain_item[0] == 'res_name':
610                 condition_name = [None, domain_item[1], domain_item[2]]
611                 break
612
613         assert condition_name, "Invalid search domain for marketing_campaign_workitem.res_name. It should use 'res_name'"
614
615         cr.execute("""select w.id, w.res_id, m.model  \
616                                 from marketing_campaign_workitem w \
617                                     left join marketing_campaign_activity a on (a.id=w.activity_id)\
618                                     left join marketing_campaign c on (c.id=a.campaign_id)\
619                                     left join ir_model m on (m.id=c.object_id)
620                                     """)
621         res = cr.fetchall()
622         workitem_map = {}
623         matching_workitems = []
624         for id, res_id, model in res:
625             workitem_map.setdefault(model,{}).setdefault(res_id,set()).add(id)
626         for model, id_map in workitem_map.iteritems():
627             model_pool = self.pool[model]
628             condition_name[0] = model_pool._rec_name
629             condition = [('id', 'in', id_map.keys()), condition_name]
630             for res_id in model_pool.search(cr, uid, condition, context=context):
631                 matching_workitems.extend(id_map[res_id])
632         return [('id', 'in', list(set(matching_workitems)))]
633
634     _columns = {
635         'segment_id': fields.many2one('marketing.campaign.segment', 'Segment', readonly=True),
636         'activity_id': fields.many2one('marketing.campaign.activity','Activity',
637              required=True, readonly=True),
638         'campaign_id': fields.related('activity_id', 'campaign_id',
639              type='many2one', relation='marketing.campaign', string='Campaign', readonly=True, store=True),
640         'object_id': fields.related('activity_id', 'campaign_id', 'object_id',
641              type='many2one', relation='ir.model', string='Resource', select=1, readonly=True, store=True),
642         'res_id': fields.integer('Resource ID', select=1, readonly=True),
643         'res_name': fields.function(_res_name_get, string='Resource Name', fnct_search=_resource_search, type="char", size=64),
644         'date': fields.datetime('Execution Date', help='If date is not set, this workitem has to be run manually', readonly=True),
645         'partner_id': fields.many2one('res.partner', 'Partner', select=1, readonly=True),
646         'state': fields.selection([ ('todo', 'To Do'),
647                                     ('cancelled', 'Cancelled'),
648                                     ('exception', 'Exception'),
649                                     ('done', 'Done'),
650                                    ], 'Status', readonly=True),
651         'error_msg' : fields.text('Error Message', readonly=True)
652     }
653     _defaults = {
654         'state': lambda *a: 'todo',
655         'date': False,
656     }
657
658     def button_draft(self, cr, uid, workitem_ids, context=None):
659         for wi in self.browse(cr, uid, workitem_ids, context=context):
660             if wi.state in ('exception', 'cancelled'):
661                 self.write(cr, uid, [wi.id], {'state':'todo'}, context=context)
662         return True
663
664     def button_cancel(self, cr, uid, workitem_ids, context=None):
665         for wi in self.browse(cr, uid, workitem_ids, context=context):
666             if wi.state in ('todo','exception'):
667                 self.write(cr, uid, [wi.id], {'state':'cancelled'}, context=context)
668         return True
669
670     def _process_one(self, cr, uid, workitem, context=None):
671         if workitem.state != 'todo':
672             return False
673
674         activity = workitem.activity_id
675         proxy = self.pool[workitem.object_id.model]
676         object_id = proxy.browse(cr, uid, workitem.res_id, context=context)
677
678         eval_context = {
679             'activity': activity,
680             'workitem': workitem,
681             'object': object_id,
682             'resource': object_id,
683             'transitions': activity.to_ids,
684             're': re,
685         }
686         try:
687             condition = activity.condition
688             campaign_mode = workitem.campaign_id.mode
689             if condition:
690                 if not eval(condition, eval_context):
691                     if activity.keep_if_condition_not_met:
692                         workitem.write({'state': 'cancelled'}, context=context)
693                     else:
694                         workitem.unlink(context=context)
695                     return
696             result = True
697             if campaign_mode in ('manual', 'active'):
698                 Activities = self.pool.get('marketing.campaign.activity')
699                 result = Activities.process(cr, uid, activity.id, workitem.id,
700                                             context=context)
701
702             values = dict(state='done')
703             if not workitem.date:
704                 values['date'] = datetime.now().strftime(DT_FMT)
705             workitem.write(values, context=context)
706
707             if result:
708                 # process _chain
709                 workitem = workitem.browse(context=context)[0] # reload
710                 date = datetime.strptime(workitem.date, DT_FMT)
711
712                 for transition in activity.to_ids:
713                     if transition.trigger == 'cosmetic':
714                         continue
715                     launch_date = False
716                     if transition.trigger == 'auto':
717                         launch_date = date
718                     elif transition.trigger == 'time':
719                         launch_date = date + transition._delta()
720
721                     if launch_date:
722                         launch_date = launch_date.strftime(DT_FMT)
723                     values = {
724                         'date': launch_date,
725                         'segment_id': workitem.segment_id.id,
726                         'activity_id': transition.activity_to_id.id,
727                         'partner_id': workitem.partner_id.id,
728                         'res_id': workitem.res_id,
729                         'state': 'todo',
730                     }
731                     wi_id = self.create(cr, uid, values, context=context)
732
733                     # Now, depending on the trigger and the campaign mode
734                     # we know whether we must run the newly created workitem.
735                     #
736                     # rows = transition trigger \ colums = campaign mode
737                     #
738                     #           test    test_realtime     manual      normal (active)
739                     # time       Y            N             N           N
740                     # cosmetic   N            N             N           N
741                     # auto       Y            Y             N           Y
742                     #
743
744                     run = (transition.trigger == 'auto' \
745                             and campaign_mode != 'manual') \
746                           or (transition.trigger == 'time' \
747                               and campaign_mode == 'test')
748                     if run:
749                         new_wi = self.browse(cr, uid, wi_id, context)
750                         self._process_one(cr, uid, new_wi, context)
751
752         except Exception:
753             tb = "".join(format_exception(*exc_info()))
754             workitem.write({'state': 'exception', 'error_msg': tb},
755                      context=context)
756
757     def process(self, cr, uid, workitem_ids, context=None):
758         for wi in self.browse(cr, uid, workitem_ids, context=context):
759             self._process_one(cr, uid, wi, context=context)
760         return True
761
762     def process_all(self, cr, uid, camp_ids=None, context=None):
763         camp_obj = self.pool.get('marketing.campaign')
764         if camp_ids is None:
765             camp_ids = camp_obj.search(cr, uid, [('state','=','running')], context=context)
766         for camp in camp_obj.browse(cr, uid, camp_ids, context=context):
767             if camp.mode == 'manual':
768                 # manual states are not processed automatically
769                 continue
770             while True:
771                 domain = [('campaign_id', '=', camp.id), ('state', '=', 'todo'), ('date', '!=', False)]
772                 if camp.mode in ('test_realtime', 'active'):
773                     domain += [('date','<=', time.strftime('%Y-%m-%d %H:%M:%S'))]
774
775                 workitem_ids = self.search(cr, uid, domain, context=context)
776                 if not workitem_ids:
777                     break
778
779                 self.process(cr, uid, workitem_ids, context=context)
780         return True
781
782     def preview(self, cr, uid, ids, context=None):
783         res = {}
784         wi_obj = self.browse(cr, uid, ids[0], context=context)
785         if wi_obj.activity_id.type == 'email':
786             view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'email_template', 'email_template_preview_form')
787             res = {
788                 'name': _('Email Preview'),
789                 'view_type': 'form',
790                 'view_mode': 'form,tree',
791                 'res_model': 'email_template.preview',
792                 'view_id': False,
793                 'context': context,
794                 'views': [(view_id and view_id[1] or 0, 'form')],
795                 'type': 'ir.actions.act_window',
796                 'target': 'new',
797                 'nodestroy':True,
798                 'context': "{'template_id':%d,'default_res_id':%d}"%
799                                 (wi_obj.activity_id.email_template_id.id,
800                                  wi_obj.res_id)
801             }
802
803         elif wi_obj.activity_id.type == 'report':
804             datas = {
805                 'ids': [wi_obj.res_id],
806                 'model': wi_obj.object_id.model
807             }
808             res = {
809                 'type' : 'ir.actions.report.xml',
810                 'report_name': wi_obj.activity_id.report_id.report_name,
811                 'datas' : datas,
812             }
813         else:
814             raise osv.except_osv(_('No preview'),_('The current step for this item has no email or report to preview.'))
815         return res
816
817
818 class email_template(osv.osv):
819     _inherit = "email.template"
820     _defaults = {
821         'model_id': lambda obj, cr, uid, context: context.get('object_id',False),
822     }
823
824     # TODO: add constraint to prevent disabling / disapproving an email account used in a running campaign
825
826
827 class report_xml(osv.osv):
828     _inherit = 'ir.actions.report.xml'
829     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
830         if context is None:
831             context = {}
832         object_id = context.get('object_id')
833         if object_id:
834             model = self.pool.get('ir.model').browse(cr, uid, object_id, context=context).model
835             args.append(('model', '=', model))
836         return super(report_xml, self).search(cr, uid, args, offset, limit, order, context, count)
837
838
839
840 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: