Server IP : 127.0.0.2 / Your IP : 3.147.103.209 Web Server : Apache/2.4.18 (Ubuntu) System : User : www-data ( ) PHP Version : 7.0.33-0ubuntu0.16.04.16 Disable Function : disk_free_space,disk_total_space,diskfreespace,dl,exec,fpaththru,getmyuid,getmypid,highlight_file,ignore_user_abord,leak,listen,link,opcache_get_configuration,opcache_get_status,passthru,pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,php_uname,phpinfo,posix_ctermid,posix_getcwd,posix_getegid,posix_geteuid,posix_getgid,posix_getgrgid,posix_getgrnam,posix_getgroups,posix_getlogin,posix_getpgid,posix_getpgrp,posix_getpid,posix,_getppid,posix_getpwnam,posix_getpwuid,posix_getrlimit,posix_getsid,posix_getuid,posix_isatty,posix_kill,posix_mkfifo,posix_setegid,posix_seteuid,posix_setgid,posix_setpgid,posix_setsid,posix_setuid,posix_times,posix_ttyname,posix_uname,pclose,popen,proc_open,proc_close,proc_get_status,proc_nice,proc_terminate,shell_exec,source,show_source,system,virtual MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : ON | Pkexec : ON Directory : /opt/odoo/addons/marketing_campaign/models/ |
Upload File : |
# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from dateutil.relativedelta import relativedelta from traceback import format_exception from sys import exc_info import re from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError from odoo.tools.safe_eval import safe_eval import odoo.addons.decimal_precision as dp _intervalTypes = { 'hours': lambda interval: relativedelta(hours=interval), 'days': lambda interval: relativedelta(days=interval), 'months': lambda interval: relativedelta(months=interval), 'years': lambda interval: relativedelta(years=interval), } class MarketingCampaign(models.Model): _name = "marketing.campaign" _description = "Marketing Campaign" name = fields.Char('Name', required=True) object_id = fields.Many2one('ir.model', 'Resource', required=True, help="Choose the resource on which you want this campaign to be run") partner_field_id = fields.Many2one('ir.model.fields', 'Partner Field', domain="[('model_id', '=', object_id), ('ttype', '=', 'many2one'), ('relation', '=', 'res.partner')]", help="The generated workitems will be linked to the partner related to the record. " "If the record is the partner itself leave this field empty. " "This is useful for reporting purposes, via the Campaign Analysis or Campaign Follow-up views.") unique_field_id = fields.Many2one('ir.model.fields', 'Unique Field', domain="[('model_id', '=', object_id), ('ttype', 'in', ['char','int','many2one','text','selection'])]", help='If set, this field will help segments that work in "no duplicates" mode to avoid ' 'selecting similar records twice. Similar records are records that have the same value for ' 'this unique field. For example by choosing the "email_from" field for CRM Leads you would prevent ' 'sending the same campaign to the same email address again. If not set, the "no duplicates" segments ' "will only avoid selecting the same record again if it entered the campaign previously. " "Only easily comparable fields like textfields, integers, selections or single relationships may be used.") mode = fields.Selection([ ('test', 'Test Directly'), ('test_realtime', 'Test in Realtime'), ('manual', 'With Manual Confirmation'), ('active', 'Normal') ], 'Mode', required=True, default="test", help="Test - It creates and process all the activities directly (without waiting " "for the delay on transitions) but does not send emails or produce reports. \n" "Test in Realtime - It creates and processes all the activities directly but does " "not send emails or produce reports.\n" "With Manual Confirmation - the campaigns runs normally, but the user has to \n " "validate all workitem manually.\n" "Normal - the campaign runs normally and automatically sends all emails and " "reports (be very careful with this mode, you're live!)") state = fields.Selection([ ('draft', 'New'), ('running', 'Running'), ('cancelled', 'Cancelled'), ('done', 'Done') ], 'Status', copy=False, default="draft") activity_ids = fields.One2many('marketing.campaign.activity', 'campaign_id', 'Activities') 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=dp.get_precision('Product Price')) segment_ids = fields.One2many('marketing.campaign.segment', 'campaign_id', 'Segments', readonly=False) segments_count = fields.Integer(compute='_compute_segments_count', string='Segments') @api.multi def _compute_segments_count(self): for campaign in self: campaign.segments_count = len(campaign.segment_ids) @api.multi def state_draft_set(self): return self.write({'state': 'draft'}) @api.multi def state_running_set(self): # TODO check that all subcampaigns are running self.ensure_one() if not self.activity_ids: raise UserError(_("The campaign cannot be started. There are no activities in it.")) has_start = False has_signal_without_from = False for activity in self.activity_ids: if activity.start: has_start = True if activity.signal and len(activity.from_ids) == 0: has_signal_without_from = True if not has_start and not has_signal_without_from: raise UserError(_("The campaign cannot be started. It does not have any starting activity. Modify campaign's activities to mark one as the starting point.")) return self.write({'state': 'running'}) @api.multi def state_done_set(self): # TODO check that this campaign is not a subcampaign in running mode. if self.mapped('segment_ids').filtered(lambda segment: segment.state == 'running'): raise UserError(_("The campaign cannot be marked as done before all segments are closed.")) return self.write({'state': 'done'}) @api.multi def state_cancel_set(self): # TODO check that this campaign is not a subcampaign in running mode. return self.write({'state': 'cancelled'}) def _get_partner_for(self, record): partner_field = self.partner_field_id.name if partner_field: return record[partner_field] elif self.object_id.model == 'res.partner': return record return None # prevent duplication until the server properly duplicates several levels of nested o2m @api.multi def copy(self, default=None): self.ensure_one() raise UserError(_('Duplicating campaigns is not supported.')) def _find_duplicate_workitems(self, record): """Finds possible duplicates workitems for a record in this campaign, based on a uniqueness field. :param record: browse_record to find duplicates workitems for. :param campaign_rec: browse_record of campaign """ self.ensure_one() duplicate_workitem_domain = [('res_id', '=', record.id), ('campaign_id', '=', self.id)] unique_field = self.unique_field_id if unique_field: unique_value = getattr(record, unique_field.name, None) if unique_value: if unique_field.ttype == 'many2one': unique_value = unique_value.id similar_res_ids = self.env[self.object_id.model].search([(unique_field.name, '=', unique_value)]) if similar_res_ids: duplicate_workitem_domain = [ ('res_id', 'in', similar_res_ids.ids), ('campaign_id', '=', self.id) ] return self.env['marketing.campaign.workitem'].search(duplicate_workitem_domain) class MarketingCampaignSegment(models.Model): _name = "marketing.campaign.segment" _description = "Campaign Segment" _order = "name" name = fields.Char('Name', required=True) campaign_id = fields.Many2one('marketing.campaign', 'Campaign', required=True, index=True, ondelete="cascade") object_id = fields.Many2one('ir.model', related='campaign_id.object_id', string='Resource') ir_filter_id = fields.Many2one('ir.filters', 'Filter', ondelete="restrict", domain=lambda self: [('model_id', '=', self.object_id._name)], help="Filter to select the matching resource records that belong to this segment. " "New filters can be created and saved using the advanced search on the list view of the Resource. " "If no filter is set, all records are selected without filtering. " "The synchronization mode may also add a criterion to the filter.") sync_last_date = fields.Datetime('Last Synchronization', help="Date on which this segment was synchronized last time (automatically or manually)") sync_mode = fields.Selection([ ('create_date', 'Only records created after last sync'), ('write_date', 'Only records modified after last sync (no duplicates)'), ('all', 'All records (no duplicates)')], 'Synchronization mode', default='create_date', help="Determines an additional criterion to add to the filter when selecting new records to inject in the campaign. " '"No duplicates" prevents selecting records which have already entered the campaign previously.' 'If the campaign has a "unique field" set, "no duplicates" will also prevent selecting records which have ' 'the same value for the unique field as other records that already entered the campaign.') state = fields.Selection([ ('draft', 'New'), ('cancelled', 'Cancelled'), ('running', 'Running'), ('done', 'Done')], 'Status', copy=False, default='draft') date_run = fields.Datetime('Launch Date', help="Initial start date of this segment.") date_done = fields.Datetime('End Date', help="Date this segment was last closed or cancelled.") date_next_sync = fields.Datetime(compute='_compute_date_next_sync', string='Next Synchronization', help="Next time the synchronization job is scheduled to run automatically") def _compute_date_next_sync(self): # next auto sync date is same for all segments sync_job = self.sudo().env.ref('marketing_campaign.ir_cron_marketing_campaign_every_day') self.date_next_sync = sync_job and sync_job.nextcall or False @api.constrains('ir_filter_id', 'campaign_id') def _check_model(self): if self.filtered(lambda segment: segment.ir_filter_id and segment.campaign_id.object_id.model != segment.ir_filter_id.model_id): raise ValidationError(_('Model of filter must be same as resource model of Campaign')) @api.onchange('campaign_id') def onchange_campaign_id(self): res = {'domain': {'ir_filter_id': []}} model = self.campaign_id.object_id.model if model: res['domain']['ir_filter_id'] = [('model_id', '=', model)] else: self.ir_filter_id = False return res @api.multi def state_draft_set(self): return self.write({'state': 'draft'}) @api.multi def state_running_set(self): self.ensure_one() vals = {'state': 'running'} if not self.date_run: vals['date_run'] = fields.Datetime.now() return self.write(vals) @api.multi def state_done_set(self): self.env["marketing.campaign.workitem"].search([ ('state', '=', 'todo'), ('segment_id', 'in', self.ids) ]).write({'state': 'cancelled'}) return self.write({'state': 'done', 'date_done': fields.Datetime.now()}) @api.multi def state_cancel_set(self): self.env["marketing.campaign.workitem"].search([ ('state', '=', 'todo'), ('segment_id', 'in', self.ids) ]).write({'state': 'cancelled'}) return self.write({'state': 'cancelled', 'date_done': fields.Datetime.now()}) @api.multi def process_segment(self): Workitems = self.env['marketing.campaign.workitem'] Activities = self.env['marketing.campaign.activity'] if not self: self = self.search([('state', '=', 'running')]) action_date = fields.Datetime.now() campaigns = self.env['marketing.campaign'] for segment in self: if segment.campaign_id.state != 'running': continue campaigns |= segment.campaign_id activity_ids = Activities.search([('start', '=', True), ('campaign_id', '=', segment.campaign_id.id)]).ids criteria = [] if segment.sync_last_date and segment.sync_mode != 'all': criteria += [(segment.sync_mode, '>', segment.sync_last_date)] if segment.ir_filter_id: criteria += safe_eval(segment.ir_filter_id.domain) # XXX TODO: rewrite this loop more efficiently without doing 1 search per record! for record in self.env[segment.object_id.model].search(criteria): # avoid duplicate workitem for the same resource if segment.sync_mode in ('write_date', 'all'): if segment.campaign_id._find_duplicate_workitems(record): continue wi_vals = { 'segment_id': segment.id, 'date': action_date, 'state': 'todo', 'res_id': record.id } partner = segment.campaign_id._get_partner_for(record) if partner: wi_vals['partner_id'] = partner.id for activity_id in activity_ids: wi_vals['activity_id'] = activity_id Workitems.create(wi_vals) segment.write({'sync_last_date': action_date}) Workitems.process_all(campaigns.ids) return True class MarketingCampaignActivity(models.Model): _name = "marketing.campaign.activity" _order = "name" _description = "Campaign Activity" name = fields.Char('Name', required=True) campaign_id = fields.Many2one('marketing.campaign', 'Campaign', required=True, ondelete='cascade', index=True) object_id = fields.Many2one(related='campaign_id.object_id', relation='ir.model', string='Object', readonly=True) start = fields.Boolean('Start', help="This activity is launched when the campaign starts.", index=True) condition = fields.Text('Condition', required=True, default="True", help="Python expression to decide whether the activity can be executed, otherwise it will be deleted or cancelled." "The expression may use the following [browsable] variables:\n" " - activity: the campaign activity\n" " - workitem: the campaign workitem\n" " - resource: the resource object this campaign item represents\n" " - transitions: list of campaign transitions outgoing from this activity\n" "...- re: Python regular expression module") action_type = fields.Selection([ ('email', 'Email'), ('report', 'Report'), ('action', 'Custom Action'), ], 'Type', required=True, oldname="type", default="email", help="The type of action to execute when an item enters this activity, such as:\n" "- Email: send an email using a predefined email template \n" "- Report: print an existing Report defined on the resource item and save it into a specific directory \n" "- Custom Action: execute a predefined action, e.g. to modify the fields of the resource record") email_template_id = fields.Many2one('mail.template', "Email Template", help='The email to send when this activity is activated') report_id = fields.Many2one('ir.actions.report.xml', "Report", help='The report to generate when this activity is activated') server_action_id = fields.Many2one('ir.actions.server', string='Action', help="The action to perform when this activity is activated") to_ids = fields.One2many('marketing.campaign.transition', 'activity_from_id', 'Next Activities') from_ids = fields.One2many('marketing.campaign.transition', 'activity_to_id', 'Previous Activities') variable_cost = fields.Float('Variable Cost', digits=dp.get_precision('Product Price'), 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") revenue = fields.Float('Revenue', digits=0, 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") signal = fields.Char('Signal', help="An activity with a signal can be called programmatically. Be careful, the workitem is always created when " "a signal is sent") keep_if_condition_not_met = fields.Boolean("Don't Delete Workitems", help="By activating this option, workitems that aren't executed because the condition is not met are marked as " "cancelled instead of being deleted.") @api.model def search(self, args, offset=0, limit=None, order=None, count=False): if 'segment_id' in self.env.context: return self.env['marketing.campaign.segment'].browse(self.env.context['segment_id']).campaign_id.activity_ids return super(MarketingCampaignActivity, self).search(args, offset, limit, order, count) @api.multi def _process_wi_email(self, workitem): self.ensure_one() return self.email_template_id.send_mail(workitem.res_id) @api.multi def _process_wi_report(self, workitem): self.ensure_one() return self.report_id.render_report(workitem.res_id, self.report_id.report_name, None) @api.multi def _process_wi_action(self, workitem): self.ensure_one() return self.server_action_id.run() @api.multi def process(self, workitem): self.ensure_one() method = '_process_wi_%s' % (self.action_type,) action = getattr(self, method, None) if not action: raise NotImplementedError('Method %r is not implemented on %r object.' % (method, self._name)) return action(workitem) class MarketingCampaignTransition(models.Model): _name = "marketing.campaign.transition" _description = "Campaign Transition" _interval_units = [ ('hours', 'Hour(s)'), ('days', 'Day(s)'), ('months', 'Month(s)'), ('years', 'Year(s)'), ] name = fields.Char(compute='_compute_name', string='Name') activity_from_id = fields.Many2one('marketing.campaign.activity', 'Previous Activity', index=1, required=True, ondelete="cascade") activity_to_id = fields.Many2one('marketing.campaign.activity', 'Next Activity', required=True, ondelete="cascade") interval_nbr = fields.Integer('Interval Value', required=True, default=1) interval_type = fields.Selection(_interval_units, 'Interval Unit', required=True, default='days') trigger = fields.Selection([ ('auto', 'Automatic'), ('time', 'Time'), ('cosmetic', 'Cosmetic'), # fake plastic transition ], 'Trigger', required=True, default='time', help="How is the destination workitem triggered") _sql_constraints = [ ('interval_positive', 'CHECK(interval_nbr >= 0)', 'The interval must be positive or zero') ] def _compute_name(self): # name formatters that depend on trigger formatters = { 'auto': _('Automatic transition'), 'time': _('After %(interval_nbr)d %(interval_type)s'), 'cosmetic': _('Cosmetic'), } # get the translations of the values of selection field 'interval_type' model_fields = self.fields_get(['interval_type']) interval_type_selection = dict(model_fields['interval_type']['selection']) for transition in self: values = { 'interval_nbr': transition.interval_nbr, 'interval_type': interval_type_selection.get(transition.interval_type, ''), } transition.name = formatters[transition.trigger] % values @api.constrains('activity_from_id', 'activity_to_id') def _check_campaign(self): if self.filtered(lambda transition: transition.activity_from_id.campaign_id != transition.activity_to_id.campaign_id): return ValidationError(_('The To/From Activity of transition must be of the same Campaign')) def _delta(self): self.ensure_one() if self.trigger != 'time': raise ValueError('Delta is only relevant for timed transition.') return relativedelta(**{str(self.interval_type): self.interval_nbr}) class MarketingCampaignWorkitem(models.Model): _name = "marketing.campaign.workitem" _description = "Campaign Workitem" segment_id = fields.Many2one('marketing.campaign.segment', 'Segment', readonly=True) activity_id = fields.Many2one('marketing.campaign.activity', 'Activity', required=True, readonly=True) campaign_id = fields.Many2one('marketing.campaign', related='activity_id.campaign_id', string='Campaign', readonly=True, store=True) object_id = fields.Many2one('ir.model', related='activity_id.campaign_id.object_id', string='Resource', index=1, readonly=True, store=True) res_id = fields.Integer('Resource ID', index=1, readonly=True) res_name = fields.Char(compute='_compute_res_name', string='Resource Name', search='_search_res_name') date = fields.Datetime('Execution Date', readonly=True, default=False, help='If date is not set, this workitem has to be run manually') partner_id = fields.Many2one('res.partner', 'Partner', index=1, readonly=True) state = fields.Selection([ ('todo', 'To Do'), ('cancelled', 'Cancelled'), ('exception', 'Exception'), ('done', 'Done'), ], 'Status', readonly=True, copy=False, default='todo') error_msg = fields.Text('Error Message', readonly=True) def _compute_res_name(self): for workitem in self: proxy = self.env[workitem.object_id.model] record = proxy.browse(workitem.res_id) if not workitem.res_id or not record.exists(): workitem.res_name = '/' continue workitem.res_name = record.name_get()[0][1] def _search_res_name(self, operator, operand): """Returns a domain with ids of workitem whose `operator` matches with the given `operand`""" if not operand: return [] condition_name = [None, operator, operand] self.env.cr.execute(""" SELECT w.id, w.res_id, m.model FROM marketing_campaign_workitem w \ LEFT JOIN marketing_campaign_activity a ON (a.id=w.activity_id)\ LEFT JOIN marketing_campaign c ON (c.id=a.campaign_id)\ LEFT JOIN ir_model m ON (m.id=c.object_id) """) res = self.env.cr.fetchall() workitem_map = {} matching_workitems = [] for id, res_id, model in res: workitem_map.setdefault(model, {}).setdefault(res_id, set()).add(id) for model, id_map in workitem_map.iteritems(): Model = self.env[model] condition_name[0] = Model._rec_name condition = [('id', 'in', id_map.keys()), condition_name] for record in Model.search(condition): matching_workitems.extend(id_map[record.id]) return [('id', 'in', list(set(matching_workitems)))] @api.multi def button_draft(self): return self.filtered(lambda workitem: workitem.state in ('exception', 'cancelled')).write({'state': 'todo'}) @api.multi def button_cancel(self): return self.filtered(lambda workitem: workitem.state in ('todo', 'exception')).write({'state': 'cancelled'}) @api.multi def _process_one(self): self.ensure_one() if self.state != 'todo': return False activity = self.activity_id resource = self.env[self.object_id.model].browse(self.res_id) eval_context = { 'activity': activity, 'workitem': self, 'object': resource, 'resource': resource, 'transitions': activity.to_ids, 're': re, } try: condition = activity.condition campaign_mode = self.campaign_id.mode if condition: if not safe_eval(condition, eval_context): if activity.keep_if_condition_not_met: self.write({'state': 'cancelled'}) else: self.unlink() return result = True if campaign_mode in ('manual', 'active'): result = activity.process(self) values = {'state': 'done'} if not self.date: values['date'] = fields.Datetime.now() self.write(values) if result: # process _chain self.refresh() # reload execution_date = fields.Datetime.from_string(self.date) for transition in activity.to_ids: if transition.trigger == 'cosmetic': continue launch_date = False if transition.trigger == 'auto': launch_date = execution_date elif transition.trigger == 'time': launch_date = execution_date + transition._delta() if launch_date: launch_date = fields.Datetime.to_string(launch_date) values = { 'date': launch_date, 'segment_id': self.segment_id.id, 'activity_id': transition.activity_to_id.id, 'partner_id': self.partner_id.id, 'res_id': self.res_id, 'state': 'todo', } workitem = self.create(values) # Now, depending on the trigger and the campaign mode # we know whether we must run the newly created workitem. # # rows = transition trigger \ colums = campaign mode # # test test_realtime manual normal (active) # time Y N N N # cosmetic N N N N # auto Y Y N Y # run = (transition.trigger == 'auto' and campaign_mode != 'manual') or (transition.trigger == 'time' and campaign_mode == 'test') if run: workitem._process_one() except Exception: tb = "".join(format_exception(*exc_info())) self.write({'state': 'exception', 'error_msg': tb}) @api.multi def process(self): for workitem in self: workitem._process_one() return True @api.model def process_all(self, camp_ids=None): if camp_ids is None: campaigns = self.env['marketing.campaign'].search([('state', '=', 'running')]) else: campaigns = self.env['marketing.campaign'].browse(camp_ids) for campaign in campaigns.filtered(lambda campaign: campaign.mode != 'manual'): while True: domain = [('campaign_id', '=', campaign.id), ('state', '=', 'todo'), ('date', '!=', False)] if campaign.mode in ('test_realtime', 'active'): domain += [('date', '<=', fields.Datetime.now())] workitems = self.search(domain) if not workitems: break workitems.process() return True @api.multi def preview(self): self.ensure_one() res = {} if self.activity_id.action_type == 'email': view_ref = self.env.ref('mail.email_template_preview_form') res = { 'name': _('Email Preview'), 'view_type': 'form', 'view_mode': 'form,tree', 'res_model': 'email_template.preview', 'view_id': False, 'context': self.env.context, 'views': [(view_ref and view_ref.id or False, 'form')], 'type': 'ir.actions.act_window', 'target': 'new', 'context': "{'template_id': %d,'default_res_id': %d}" % (self.activity_id.email_template_id.id, self.res_id) } elif self.activity_id.action_type == 'report': datas = { 'ids': [self.res_id], 'model': self.object_id.model } res = { 'type': 'ir.actions.report.xml', 'report_name': self.activity_id.report_id.report_name, 'datas': datas, } else: raise UserError(_('The current step for this item has no email or report to preview.')) return res