diff --git a/addons/marketing_campaign/__openerp__.py b/addons/marketing_campaign/__openerp__.py index c60db8c24e2..a71ce85c0b8 100644 --- a/addons/marketing_campaign/__openerp__.py +++ b/addons/marketing_campaign/__openerp__.py @@ -1,14 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - { 'name': 'Marketing Campaigns', 'version': '1.1', - 'depends': ['document', - 'mail', - 'decimal_precision' - ], + 'depends': ['document', 'mail', 'decimal_precision'], 'category': 'Marketing', 'description': """ This module provides leads automation through marketing campaigns (campaigns can in fact be defined on any resource, not just CRM Leads). @@ -44,7 +40,4 @@ input segments, workflow. 'report/campaign_analysis_view.xml', ], 'demo': ['data/marketing_campaign_demo.xml'], - 'test': ['test/marketing_campaign.yml'], - 'installable': True, - 'auto_install': False, } diff --git a/addons/marketing_campaign/data/marketing_campaign_data.xml b/addons/marketing_campaign/data/marketing_campaign_data.xml index 4f337b1d60e..986b58508c9 100644 --- a/addons/marketing_campaign/data/marketing_campaign_data.xml +++ b/addons/marketing_campaign/data/marketing_campaign_data.xml @@ -1,5 +1,5 @@ - - + + @@ -27,4 +27,4 @@ - + diff --git a/addons/marketing_campaign/data/marketing_campaign_demo.xml b/addons/marketing_campaign/data/marketing_campaign_demo.xml index 06fb4545360..ad808c35734 100644 --- a/addons/marketing_campaign/data/marketing_campaign_demo.xml +++ b/addons/marketing_campaign/data/marketing_campaign_demo.xml @@ -1,5 +1,5 @@ - - + + welcome new partner @@ -100,5 +100,4 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/addons/marketing_campaign/data/marketing_campaign_workflow.xml b/addons/marketing_campaign/data/marketing_campaign_workflow.xml index ad3fb0ddcca..8f32522423b 100644 --- a/addons/marketing_campaign/data/marketing_campaign_workflow.xml +++ b/addons/marketing_campaign/data/marketing_campaign_workflow.xml @@ -1,6 +1,5 @@ - - + @@ -140,5 +139,4 @@ state_draft_set - - + diff --git a/addons/marketing_campaign/models/ir_actions.py b/addons/marketing_campaign/models/ir_actions.py index 5883400f7d0..cb9a55db6cc 100644 --- a/addons/marketing_campaign/models/ir_actions.py +++ b/addons/marketing_campaign/models/ir_actions.py @@ -1,15 +1,16 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from openerp.osv import osv +from odoo import api, models -class report_xml(osv.osv): + +class IrActionsReportXml(models.Model): _inherit = 'ir.actions.report.xml' - def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False): - if context is None: - context = {} - object_id = context.get('object_id') - if object_id: - model = self.pool.get('ir.model').browse(cr, uid, object_id, context=context).model + + @api.model + def search(self, args, offset=0, limit=None, order=None, count=False): + model_id = self.env.context.get('object_id') + if model_id: + model = self.env['ir.model'].browse(model_id).model args.append(('model', '=', model)) - return super(report_xml, self).search(cr, uid, args, offset=offset, limit=limit, order=order, context=context, count=count) + return super(IrActionsReportXml, self).search(args, offset=offset, limit=limit, order=order, count=count) diff --git a/addons/marketing_campaign/models/mail_template.py b/addons/marketing_campaign/models/mail_template.py index 6a8d1a47d2c..5b6d6be7777 100644 --- a/addons/marketing_campaign/models/mail_template.py +++ b/addons/marketing_campaign/models/mail_template.py @@ -1,14 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from openerp.osv import osv +from odoo import fields, models -class mail_template(osv.osv): + +class MailTemplate(models.Model): _inherit = "mail.template" - _defaults = { - 'model_id': lambda obj, cr, uid, context: context.get('object_id',False), - } + + model_id = fields.Many2one('ir.model', default=lambda self: self.env.context.get('object_id', False)) # TODO: add constraint to prevent disabling / disapproving an email account used in a running campaign - - diff --git a/addons/marketing_campaign/models/marketing_campaign.py b/addons/marketing_campaign/models/marketing_campaign.py index e4eb585d3e2..597f5eabc89 100644 --- a/addons/marketing_campaign/models/marketing_campaign.py +++ b/addons/marketing_campaign/models/marketing_campaign.py @@ -2,20 +2,17 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import time -import base64 from datetime import datetime from dateutil.relativedelta import relativedelta from traceback import format_exception from sys import exc_info -from openerp.tools.safe_eval import safe_eval as eval import re import openerp.addons.decimal_precision as dp -from openerp import api, SUPERUSER_ID -from openerp.osv import fields, osv -from openerp.report import render_report -from openerp.tools.translate import _ -from openerp.exceptions import UserError +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools.safe_eval import safe_eval as eval + _intervalTypes = { 'hours': lambda interval: relativedelta(hours=interval), @@ -24,77 +21,71 @@ _intervalTypes = { 'years': lambda interval: relativedelta(years=interval), } -DT_FMT = '%Y-%m-%d %H:%M:%S' - - -class marketing_campaign(osv.osv): +class MarketingCampaign(models.Model): _name = "marketing.campaign" _description = "Marketing Campaign" - def _count_segments(self, cr, uid, ids, field_name, arg, context=None): - res = {} - try: - for segments in self.browse(cr, uid, ids, context=context): - res[segments.id] = len(segments.segment_ids) - except: - pass - return res + 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_compute=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') - _columns = { - '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, 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. -Test in Realtime - It creates and processes all the activities directly but does not send emails or produce reports. -With Manual Confirmation - the campaigns runs normally, but the user has to validate all workitem manually. -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), - '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_compute=dp.get_precision('Product Price')), - 'segment_ids': fields.one2many('marketing.campaign.segment', 'campaign_id', 'Segments', readonly=False), - 'segments_count': fields.function(_count_segments, type='integer', string='Segments') - } + @api.multi + def _compute_segments_count(self): + for campaign in self: + campaign.segments_count = len(campaign.segment_ids) - _defaults = { - 'state': lambda *a: 'draft', - 'mode': lambda *a: 'test', - } - - def state_running_set(self, cr, uid, ids, *args): + @api.multi + def state_running_set(self): # TODO check that all subcampaigns are running - campaign = self.browse(cr, uid, ids[0]) + self.ensure_one() - if not campaign.activity_ids: + 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 campaign.activity_ids: + for activity in self.activity_ids: if activity.start: has_start = True if activity.signal and len(activity.from_ids) == 0: @@ -103,186 +94,166 @@ Normal - the campaign runs normally and automatically sends all emails and repor 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(cr, uid, ids, {'state': 'running'}) + return self.write({'state': 'running'}) - def state_done_set(self, cr, uid, ids, *args): + @api.multi + def state_done_set(self): # TODO check that this campaign is not a subcampaign in running mode. - segment_ids = self.pool.get('marketing.campaign.segment').search(cr, uid, - [('campaign_id', 'in', ids), - ('state', '=', 'running')]) - if segment_ids : + 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.")) - self.write(cr, uid, ids, {'state': 'done'}) - return True + return self.write({'state': 'done'}) - def state_cancel_set(self, cr, uid, ids, *args): + @api.multi + def state_cancel_set(self): # TODO check that this campaign is not a subcampaign in running mode. - self.write(cr, uid, ids, {'state': 'cancelled'}) - return True + return self.write({'state': 'cancelled'}) - def _get_partner_for(self, campaign, record): - partner_field = campaign.partner_field_id.name + def _get_partner_for(self, record): + partner_field = self.partner_field_id.name if partner_field: return record[partner_field] - elif campaign.object_id.model == 'res.partner': + 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, cr, uid, id, default=None, context=None): + self.ensure_one() raise UserError(_('Duplicating campaigns is not supported.')) - def _find_duplicate_workitems(self, cr, uid, record, campaign_rec, context=None): + 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 """ - Workitems = self.pool.get('marketing.campaign.workitem') - duplicate_workitem_domain = [('res_id','=', record.id), - ('campaign_id','=', campaign_rec.id)] - unique_field = campaign_rec.unique_field_id + 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.pool[campaign_rec.object_id.model].search(cr, uid, - [(unique_field.name, '=', unique_value)], context=context) + 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), - ('campaign_id','=', campaign_rec.id)] - return Workitems.search(cr, uid, duplicate_workitem_domain, context=context) + duplicate_workitem_domain = [ + ('res_id', 'in', similar_res_ids), + ('campaign_id', '=', self.id) + ] + return self.env['marketing.campaign.workitem'].search(duplicate_workitem_domain) - -class marketing_campaign_segment(osv.osv): +class MarketingCampaignSegment(models.Model): _name = "marketing.campaign.segment" _description = "Campaign Segment" _order = "name" - def _get_next_sync(self, cr, uid, ids, fn, args, context=None): + 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.pool.get('ir.model.data').get_object(cr, SUPERUSER_ID, 'marketing_campaign', 'ir_cron_marketing_campaign_every_day', context=context) - next_sync = sync_job and sync_job.nextcall or False - return dict.fromkeys(ids, next_sync) + 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 - _columns = { - 'name': fields.char('Name', required=True), - 'campaign_id': fields.many2one('marketing.campaign', 'Campaign', required=True, select=1, ondelete="cascade"), - 'object_id': fields.related('campaign_id','object_id', type='many2one', relation='ir.model', 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', - 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), - '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.function(_get_next_sync, string='Next Synchronization', type='datetime', help="Next time the synchronization job is scheduled to run automatically"), - } + @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')) - _defaults = { - 'state': lambda *a: 'draft', - 'sync_mode': lambda *a: 'create_date', - } - - def _check_model(self, cr, uid, ids, context=None): - for obj in self.browse(cr, uid, ids, context=context): - if not obj.ir_filter_id: - return True - if obj.campaign_id.object_id.model != obj.ir_filter_id.model_id: - return False - return True - - _constraints = [ - (_check_model, 'Model of filter must be same as resource model of Campaign ', ['ir_filter_id,campaign_id']), - ] - - def onchange_campaign_id(self, cr, uid, ids, campaign_id): - res = {'domain':{'ir_filter_id':[]}} - campaign_pool = self.pool.get('marketing.campaign') - if campaign_id: - campaign = campaign_pool.browse(cr, uid, campaign_id) - model_name = self.pool.get('ir.model').read(cr, uid, [campaign.object_id.id], ['model']) - if model_name: - mod_name = model_name[0]['model'] - res['domain'] = {'ir_filter_id': [('model_id', '=', mod_name)]} + @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: - res['value'] = {'ir_filter_id': False} + res.ir_filter_id = False return res - def state_running_set(self, cr, uid, ids, *args): - segment = self.browse(cr, uid, ids[0]) + @api.multi + def state_running_set(self): + self.ensure_one() vals = {'state': 'running'} - if not segment.date_run: - vals['date_run'] = time.strftime('%Y-%m-%d %H:%M:%S') - self.write(cr, uid, ids, vals) - return True + if not self.date_run: + vals['date_run'] = fields.Datetime.now() + return self.write(vals) - def state_done_set(self, cr, uid, ids, *args): - wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid, - [('state', '=', 'todo'), ('segment_id', 'in', ids)]) - self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'}) - self.write(cr, uid, ids, {'state': 'done','date_done': time.strftime('%Y-%m-%d %H:%M:%S')}) - return True + @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()}) - def state_cancel_set(self, cr, uid, ids, *args): - wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid, - [('state', '=', 'todo'), ('segment_id', 'in', ids)]) - self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'}) - self.write(cr, uid, ids, {'state': 'cancelled','date_done': time.strftime('%Y-%m-%d %H:%M:%S')}) - return True + @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()}) - def synchroniz(self, cr, uid, ids, *args): - self.process_segment(cr, uid, ids) - return True + @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')]) - @api.cr_uid_ids_context - def process_segment(self, cr, uid, segment_ids=None, context=None): - Workitems = self.pool.get('marketing.campaign.workitem') - Campaigns = self.pool.get('marketing.campaign') - if not segment_ids: - segment_ids = self.search(cr, uid, [('state', '=', 'running')], context=context) - - action_date = time.strftime('%Y-%m-%d %H:%M:%S') - campaigns = set() - for segment in self.browse(cr, uid, segment_ids, context=context): + action_date = fields.Datetime.now() + campaigns = self.env['marketing.campaign'] + for segment in self: if segment.campaign_id.state != 'running': continue - campaigns.add(segment.campaign_id.id) - act_ids = self.pool.get('marketing.campaign.activity').search(cr, - uid, [('start', '=', True), ('campaign_id', '=', segment.campaign_id.id)], context=context) + campaigns |= segment.campaign_id + activity_ids = Activities.search([('start', '=', True), ('campaign_id', '=', segment.campaign_id.id)]).ids - model_obj = self.pool[segment.object_id.model] 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 += eval(segment.ir_filter_id.domain) - object_ids = model_obj.search(cr, uid, criteria, context=context) # XXX TODO: rewrite this loop more efficiently without doing 1 search per record! - for record in model_obj.browse(cr, uid, object_ids, context=context): + 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 Campaigns._find_duplicate_workitems(cr, uid, record, segment.campaign_id, context=context): + if segment.sync_mode in ('write_date', 'all'): + if segment.campaign_id._find_duplicate_workitems(record): continue wi_vals = { @@ -292,106 +263,86 @@ class marketing_campaign_segment(osv.osv): 'res_id': record.id } - partner = self.pool.get('marketing.campaign')._get_partner_for(segment.campaign_id, record) + partner = segment.campaign_id._get_partner_for(record) if partner: wi_vals['partner_id'] = partner.id - for act_id in act_ids: - wi_vals['activity_id'] = act_id - Workitems.create(cr, uid, wi_vals, context=context) + for activity_id in activity_ids: + wi_vals['activity_id'] = activity_id + Workitems.create(wi_vals) - self.write(cr, uid, segment.id, {'sync_last_date':action_date}, context=context) - Workitems.process_all(cr, uid, list(campaigns), context=context) + segment.write({'sync_last_date': action_date}) + Workitems.process_all(campaigns.ids) return True -class marketing_campaign_activity(osv.osv): +class MarketingCampaignActivity(models.Model): _name = "marketing.campaign.activity" _order = "name" _description = "Campaign Activity" - _action_types = [ + 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'), - # TODO implement the subcampaigns. - # TODO implement the subcampaign out. disallow out transitions from - # subcampaign activities ? - #('subcampaign', 'Sub-Campaign'), - ] + ], '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_compute=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.") - _columns = { - 'name': fields.char('Name', required=True), - 'campaign_id': fields.many2one('marketing.campaign', 'Campaign', - required = True, ondelete='cascade', select=1), - 'object_id': fields.related('campaign_id','object_id', - type='many2one', relation='ir.model', - string='Object', readonly=True), - 'start': fields.boolean('Start', help= "This activity is launched when the campaign starts.", select=True), - 'condition': fields.text('Condition', size=256, required=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"), - 'type': fields.selection(_action_types, 'Type', required=True, - help="""The type of action to execute when an item enters this activity, such as: - - Email: send an email using a predefined email template - - Report: print an existing Report defined on the resource item and save it into a specific directory - - 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', 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')), - '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=0), - '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) - _defaults = { - 'type': lambda *a: 'email', - 'condition': lambda *a: 'True', - } + @api.multi + def _process_wi_email(self, workitem): + self.ensure_one() + return self.email_template_id.send_mail(workitem.res_id) - def search(self, cr, uid, args, offset=0, limit=None, order=None, - context=None, count=False): - if context == None: - context = {} - if 'segment_id' in context and context['segment_id']: - segment_obj = self.pool.get('marketing.campaign.segment').browse(cr, - uid, context['segment_id']) - act_ids = [] - for activity in segment_obj.campaign_id.activity_ids: - act_ids.append(activity.id) - return act_ids - return super(marketing_campaign_activity, self).search(cr, uid, args, - offset, limit, order, context, count) - - def process(self, cr, uid, act_id, wi_id, context=None): - activity = self.browse(cr, uid, act_id, context=context) - method = '_process_wi_%s' % (activity.type,) + @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)) - - workitem_obj = self.pool.get('marketing.campaign.workitem') - workitem = workitem_obj.browse(cr, uid, wi_id, context=context) - return action(cr, uid, activity, workitem, context=context) + raise NotImplementedError('Method %r is not implemented on %r object.' % (method, self._model)) + return action(workitem) -class marketing_campaign_transition(osv.osv): +class MarketingCampaignTransition(models.Model): _name = "marketing.campaign.transition" _description = "Campaign Transition" @@ -402,7 +353,23 @@ class marketing_campaign_transition(osv.osv): ('years', 'Year(s)'), ] - def _get_name(self, cr, uid, ids, fn, args, context=None): + 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'), @@ -410,219 +377,154 @@ class marketing_campaign_transition(osv.osv): 'cosmetic': _('Cosmetic'), } # get the translations of the values of selection field 'interval_type' - fields = self.fields_get(cr, uid, ['interval_type'], context=context) - interval_type_selection = dict(fields['interval_type']['selection']) + model_fields = self.fields_get(['interval_type']) + interval_type_selection = dict(model_fields['interval_type']['selection']) - result = dict.fromkeys(ids, False) - for trans in self.browse(cr, uid, ids, context=context): + for transition in self: values = { - 'interval_nbr': trans.interval_nbr, - 'interval_type': interval_type_selection.get(trans.interval_type, ''), + 'interval_nbr': transition.interval_nbr, + 'interval_type': interval_type_selection.get(transition.interval_type, ''), } - result[trans.id] = formatters[trans.trigger] % values - return result + 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, cr, uid, ids, context=None): - assert len(ids) == 1 - transition = self.browse(cr, uid, ids[0], context=context) - if transition.trigger != 'time': + def _delta(self): + self.ensure_one() + if self.trigger != 'time': raise ValueError('Delta is only relevant for timed transition.') - return relativedelta(**{str(transition.interval_type): transition.interval_nbr}) + return relativedelta(**{str(self.interval_type): self.interval_nbr}) - _columns = { - 'name': fields.function(_get_name, string='Name', - type='char', size=128), - 'activity_from_id': fields.many2one('marketing.campaign.activity', - 'Previous Activity', select=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), - 'interval_type': fields.selection(_interval_units, 'Interval Unit', - required=True), - - 'trigger': fields.selection([('auto', 'Automatic'), - ('time', 'Time'), - ('cosmetic', 'Cosmetic'), # fake plastic transition - ], - 'Trigger', required=True, - help="How is the destination workitem triggered"), - } - - _defaults = { - 'interval_nbr': 1, - 'interval_type': 'days', - 'trigger': 'time', - } - def _check_campaign(self, cr, uid, ids, context=None): - for obj in self.browse(cr, uid, ids, context=context): - if obj.activity_from_id.campaign_id != obj.activity_to_id.campaign_id: - return False - return True - - _constraints = [ - (_check_campaign, 'The To/From Activity of transition must be of the same Campaign ', ['activity_from_id,activity_to_id']), - ] - - _sql_constraints = [ - ('interval_positive', 'CHECK(interval_nbr >= 0)', 'The interval must be positive or zero') - ] - - -class marketing_campaign_workitem(osv.osv): +class MarketingCampaignWorkitem(models.Model): _name = "marketing.campaign.workitem" _description = "Campaign Workitem" - def _res_name_get(self, cr, uid, ids, field_name, arg, context=None): - res = dict.fromkeys(ids, '/') - for wi in self.browse(cr, uid, ids, context=context): - if not wi.res_id: - continue + 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) - proxy = self.pool[wi.object_id.model] - if not proxy.exists(cr, uid, [wi.res_id]): + 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: + workitem.res_name = '/' continue - ng = proxy.name_get(cr, uid, [wi.res_id], context=context) - if ng: - res[wi.id] = ng[0][1] - return res + workitem.res_name = record.name_get()[0][1] - def _resource_search(self, cr, uid, obj, name, args, domain=None, context=None): - """Returns id of workitem whose resource_name matches with the given name""" - if not len(args): + 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 - for domain_item in args: - # we only use the first domain criterion and ignore all the rest including operators - if isinstance(domain_item, (list,tuple)) and len(domain_item) == 3 and domain_item[0] == 'res_name': - condition_name = [None, domain_item[1], domain_item[2]] - break + condition_name = [None, operator, operand] - assert condition_name, "Invalid search domain for marketing_campaign_workitem.res_name. It should use 'res_name'" - - 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 = cr.fetchall() + 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) + workitem_map.setdefault(model, {}).setdefault(res_id, set()).add(id) for model, id_map in workitem_map.iteritems(): - model_pool = self.pool[model] - condition_name[0] = model_pool._rec_name + Model = self.env[model] + condition_name[0] = Model._rec_name condition = [('id', 'in', id_map.keys()), condition_name] - for res_id in model_pool.search(cr, uid, condition, context=context): - matching_workitems.extend(id_map[res_id]) + for record in Model.search(condition): + matching_workitems.extend(id_map[record.id]) return [('id', 'in', list(set(matching_workitems)))] - _columns = { - '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.related('activity_id', 'campaign_id', - type='many2one', relation='marketing.campaign', string='Campaign', readonly=True, store=True), - 'object_id': fields.related('activity_id', 'campaign_id', 'object_id', - type='many2one', relation='ir.model', string='Resource', select=1, readonly=True, store=True), - 'res_id': fields.integer('Resource ID', select=1, readonly=True), - 'res_name': fields.function(_res_name_get, string='Resource Name', fnct_search=_resource_search, type="char", size=64), - 'date': fields.datetime('Execution Date', help='If date is not set, this workitem has to be run manually', readonly=True), - 'partner_id': fields.many2one('res.partner', 'Partner', select=1, readonly=True), - 'state': fields.selection([ ('todo', 'To Do'), - ('cancelled', 'Cancelled'), - ('exception', 'Exception'), - ('done', 'Done'), - ], 'Status', readonly=True, copy=False), - 'error_msg' : fields.text('Error Message', readonly=True) - } - _defaults = { - 'state': lambda *a: 'todo', - 'date': False, - } + @api.multi + def button_draft(self): + return self.filtered(lambda workitem: workitem.state in ('exception', 'cancelled')).write({'state': 'todo'}) - @api.cr_uid_ids_context - def button_draft(self, cr, uid, workitem_ids, context=None): - for wi in self.browse(cr, uid, workitem_ids, context=context): - if wi.state in ('exception', 'cancelled'): - self.write(cr, uid, [wi.id], {'state':'todo'}, context=context) - return True + @api.multi + def button_cancel(self): + return self.filtered(lambda workitem: workitem.state in ('todo', 'exception')).write({'state': 'cancelled'}) - @api.cr_uid_ids_context - def button_cancel(self, cr, uid, workitem_ids, context=None): - for wi in self.browse(cr, uid, workitem_ids, context=context): - if wi.state in ('todo','exception'): - self.write(cr, uid, [wi.id], {'state':'cancelled'}, context=context) - return True - - def _process_one(self, cr, uid, workitem, context=None): - if workitem.state != 'todo': + @api.multi + def _process_one(self): + self.ensure_one() + if self.state != 'todo': return False - activity = workitem.activity_id - proxy = self.pool[workitem.object_id.model] - object_id = proxy.browse(cr, uid, workitem.res_id, context=context) + activity = self.activity_id + resource = self.env[self.object_id.model].browse(self.res_id) eval_context = { 'activity': activity, - 'workitem': workitem, - 'object': object_id, - 'resource': object_id, + 'workitem': self, + 'object': resource, + 'resource': resource, 'transitions': activity.to_ids, 're': re, } try: condition = activity.condition - campaign_mode = workitem.campaign_id.mode + campaign_mode = self.campaign_id.mode if condition: if not eval(condition, eval_context): if activity.keep_if_condition_not_met: - workitem.write({'state': 'cancelled'}) + self.write({'state': 'cancelled'}) else: - workitem.unlink() + self.unlink() return result = True if campaign_mode in ('manual', 'active'): - Activities = self.pool.get('marketing.campaign.activity') - result = Activities.process(cr, uid, activity.id, workitem.id, - context=context) + result = activity.process(self) - values = dict(state='done') - if not workitem.date: - values['date'] = datetime.now().strftime(DT_FMT) - workitem.write(values) + values = {'state': 'done'} + if not self.date: + values['date'] = fields.Datetime.now() + self.write(values) if result: # process _chain - workitem.refresh() # reload - date = datetime.strptime(workitem.date, DT_FMT) + 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 = date + launch_date = execution_date elif transition.trigger == 'time': - launch_date = date + transition._delta() + launch_date = execution_date + transition._delta() if launch_date: - launch_date = launch_date.strftime(DT_FMT) + launch_date = fields.Datetime.to_string(launch_date) values = { 'date': launch_date, - 'segment_id': workitem.segment_id.id, + 'segment_id': self.segment_id.id, 'activity_id': transition.activity_to_id.id, - 'partner_id': workitem.partner_id.id, - 'res_id': workitem.res_id, + 'partner_id': self.partner_id.id, + 'res_id': self.res_id, 'state': 'todo', } - wi_id = self.create(cr, uid, values, context=context) + workitem = self.create(values) # Now, depending on the trigger and the campaign mode # we know whether we must run the newly created workitem. @@ -635,75 +537,66 @@ class marketing_campaign_workitem(osv.osv): # auto Y Y N Y # - run = (transition.trigger == 'auto' \ - and campaign_mode != 'manual') \ - or (transition.trigger == 'time' \ - and campaign_mode == 'test') + run = (transition.trigger == 'auto' and campaign_mode != 'manual') or (transition.trigger == 'time' and campaign_mode == 'test') if run: - new_wi = self.browse(cr, uid, wi_id, context) - self._process_one(cr, uid, new_wi, context) + workitem._process_one() except Exception: tb = "".join(format_exception(*exc_info())) - workitem.write({'state': 'exception', 'error_msg': tb}) + self.write({'state': 'exception', 'error_msg': tb}) - @api.cr_uid_ids_context - def process(self, cr, uid, workitem_ids, context=None): - for wi in self.browse(cr, uid, workitem_ids, context=context): - self._process_one(cr, uid, wi, context=context) + @api.multi + def process(self): + for workitem in self: + workitem._process_one() return True - def process_all(self, cr, uid, camp_ids=None, context=None): - camp_obj = self.pool.get('marketing.campaign') + @api.model + def process_all(self, camp_ids=None): if camp_ids is None: - camp_ids = camp_obj.search(cr, uid, [('state','=','running')], context=context) - for camp in camp_obj.browse(cr, uid, camp_ids, context=context): - if camp.mode == 'manual': - # manual states are not processed automatically - continue + 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', '=', camp.id), ('state', '=', 'todo'), ('date', '!=', False)] - if camp.mode in ('test_realtime', 'active'): - domain += [('date','<=', time.strftime('%Y-%m-%d %H:%M:%S'))] - - workitem_ids = self.search(cr, uid, domain, context=context) - if not workitem_ids: + 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 - - self.process(cr, uid, workitem_ids, context=context) + workitems.process() return True - def preview(self, cr, uid, ids, context=None): + @api.multi + def preview(self): + self.ensure_one() res = {} - wi_obj = self.browse(cr, uid, ids[0], context=context) - if wi_obj.activity_id.type == 'email': - view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'mail', 'email_template_preview_form') + 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': context, - 'views': [(view_id and view_id[1] or 0, 'form')], + '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}"% - (wi_obj.activity_id.email_template_id.id, - wi_obj.res_id) + 'context': "{'template_id': %d,'default_res_id': %d}" % (self.activity_id.email_template_id.id, self.res_id) } - elif wi_obj.activity_id.type == 'report': + elif self.activity_id.action_type == 'report': datas = { - 'ids': [wi_obj.res_id], - 'model': wi_obj.object_id.model + 'ids': [self.res_id], + 'model': self.object_id.model } res = { - 'type' : 'ir.actions.report.xml', - 'report_name': wi_obj.activity_id.report_id.report_name, - 'datas' : datas, + '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 - diff --git a/addons/marketing_campaign/report/campaign_analysis.py b/addons/marketing_campaign/report/campaign_analysis.py index bb0a8e6510d..82d7efac3f9 100644 --- a/addons/marketing_campaign/report/campaign_analysis.py +++ b/addons/marketing_campaign/report/campaign_analysis.py @@ -1,80 +1,79 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from openerp import tools -from openerp.osv import fields, osv +from odoo import tools +from odoo import api, fields, models -class campaign_analysis(osv.osv): +class CampaignAnalysis(models.Model): _name = "campaign.analysis" _description = "Campaign Analysis" _auto = False _rec_name = 'date' - def _total_cost(self, cr, uid, ids, field_name, arg, context=None): - """ - @param cr: the current row, from the database cursor, - @param uid: the current user’s ID for security checks, - @param ids: List of case and section Data’s IDs - @param context: A standard dictionary for contextual values - """ - result = {} - for ca_obj in self.browse(cr, uid, ids, context=context): - wi_ids = self.pool.get('marketing.campaign.workitem').search(cr, uid, - [('segment_id.campaign_id', '=', ca_obj.campaign_id.id)]) - total_cost = ca_obj.activity_id.variable_cost + \ - ((ca_obj.campaign_id.fixed_cost or 1.00) / len(wi_ids)) - result[ca_obj.id] = total_cost - return result - _columns = { - 'res_id' : fields.integer('Resource', readonly=True), - 'year': fields.char('Execution Year', size=4, readonly=True), - 'month': fields.selection([('01','January'), ('02','February'), - ('03','March'), ('04','April'),('05','May'), ('06','June'), - ('07','July'), ('08','August'), ('09','September'), - ('10','October'), ('11','November'), ('12','December')], - 'Execution Month', readonly=True), - 'day': fields.char('Execution Day', size=10, readonly=True), - 'date': fields.date('Execution Date', readonly=True, select=True), - 'campaign_id': fields.many2one('marketing.campaign', 'Campaign', - readonly=True), - 'activity_id': fields.many2one('marketing.campaign.activity', 'Activity', - readonly=True), - 'segment_id': fields.many2one('marketing.campaign.segment', 'Segment', - readonly=True), - 'partner_id': fields.many2one('res.partner', 'Partner', readonly=True), - 'country_id': fields.related('partner_id', 'country_id', - type='many2one', relation='res.country',string='Country'), - 'total_cost' : fields.function(_total_cost, string='Cost', - type="float"), - 'revenue': fields.float('Revenue', readonly=True, digits=0), - 'count' : fields.integer('# of Actions', readonly=True), - 'state': fields.selection([('todo', 'To Do'), - ('exception', 'Exception'), ('done', 'Done'), - ('cancelled', 'Cancelled')], 'Status', readonly=True), - } - def init(self, cr): - tools.drop_view_if_exists(cr, 'campaign_analysis') - cr.execute(""" - create or replace view campaign_analysis as ( - select - min(wi.id) as id, - min(wi.res_id) as res_id, - to_char(wi.date::date, 'YYYY') as year, - to_char(wi.date::date, 'MM') as month, - to_char(wi.date::date, 'YYYY-MM-DD') as day, - wi.date::date as date, - s.campaign_id as campaign_id, - wi.activity_id as activity_id, - wi.segment_id as segment_id, - wi.partner_id as partner_id , - wi.state as state, - sum(act.revenue) as revenue, - count(*) as count - from + + res_id = fields.Integer('Resource', readonly=True) + year = fields.Char('Execution Year', readonly=True) + month = fields.Selection([ + ('01', 'January'), + ('02', 'February'), + ('03', 'March'), + ('04', 'April'), + ('05', 'May'), + ('06', 'June'), + ('07', 'July'), + ('08', 'August'), + ('09', 'September'), + ('10', 'October'), + ('11', 'November'), + ('12', 'December') + ], 'Execution Month', readonly=True) + day = fields.Char('Execution Day', readonly=True) + date = fields.Date('Execution Date', readonly=True, index=True) + campaign_id = fields.Many2one('marketing.campaign', 'Campaign', readonly=True) + activity_id = fields.Many2one('marketing.campaign.activity', 'Activity', readonly=True) + segment_id = fields.Many2one('marketing.campaign.segment', 'Segment', readonly=True) + partner_id = fields.Many2one('res.partner', 'Partner', readonly=True) + country_id = fields.Many2one('res.country', related='partner_id.country_id', string='Country') + total_cost = fields.Float(compute='_compute_total_cost', string='Cost') + revenue = fields.Float('Revenue', readonly=True, digits=0) + count = fields.Integer('# of Actions', readonly=True) + state = fields.Selection([ + ('todo', 'To Do'), + ('exception', 'Exception'), + ('done', 'Done'), + ('cancelled', 'Cancelled') + ], 'Status', readonly=True) + + @api.multi + def _compute_total_cost(self): + for analysis in self: + wi_count = self.env['marketing.campaign.workitem'].search_count([('segment_id.campaign_id', '=', analysis.campaign_id.id)]) + analysis.total_cost = analysis.activity.variable_cost + ((analysis.campaign_id.fixed_cost or 1.00) / wi_count) + + @api.model_cr + def init(self): + tools.drop_view_if_exists(self.env.cr, 'campaign_analysis') + self.env.cr.execute(""" + CREATE OR REPLACE VIEW campaign_analysis AS ( + SELECT + min(wi.id) AS id, + min(wi.res_id) AS res_id, + to_char(wi.date::date, 'YYYY') AS year, + to_char(wi.date::date, 'MM') AS month, + to_char(wi.date::date, 'YYYY-MM-DD') AS day, + wi.date::date AS date, + s.campaign_id AS campaign_id, + wi.activity_id AS activity_id, + wi.segment_id AS segment_id, + wi.partner_id AS partner_id , + wi.state AS state, + sum(act.revenue) AS revenue, + count(*) AS count + FROM marketing_campaign_workitem wi - left join res_partner p on (p.id=wi.partner_id) - left join marketing_campaign_segment s on (s.id=wi.segment_id) - left join marketing_campaign_activity act on (act.id= wi.activity_id) - group by + LEFT JOIN res_partner p ON (p.id=wi.partner_id) + LEFT JOIN marketing_campaign_segment s ON (s.id=wi.segment_id) + LEFT JOIN marketing_campaign_activity act ON (act.id= wi.activity_id) + GROUP BY s.campaign_id,wi.activity_id,wi.segment_id,wi.partner_id,wi.state, wi.date::date ) diff --git a/addons/marketing_campaign/report/campaign_analysis_view.xml b/addons/marketing_campaign/report/campaign_analysis_view.xml index cd241efe07f..d6daa647a50 100644 --- a/addons/marketing_campaign/report/campaign_analysis_view.xml +++ b/addons/marketing_campaign/report/campaign_analysis_view.xml @@ -1,7 +1,5 @@ - - - - + + campaign.analysis.pivot campaign.analysis @@ -83,6 +81,4 @@ - - - + diff --git a/addons/marketing_campaign/security/marketing_campaign_security.xml b/addons/marketing_campaign/security/marketing_campaign_security.xml index ab0b3e303f9..7178ba0905f 100644 --- a/addons/marketing_campaign/security/marketing_campaign_security.xml +++ b/addons/marketing_campaign/security/marketing_campaign_security.xml @@ -1,6 +1,5 @@ - - - + + User @@ -16,6 +15,4 @@ - - - + diff --git a/addons/marketing_campaign/test/marketing_campaign.yml b/addons/marketing_campaign/test/marketing_campaign.yml deleted file mode 100644 index 3d540b4ccb7..00000000000 --- a/addons/marketing_campaign/test/marketing_campaign.yml +++ /dev/null @@ -1,145 +0,0 @@ -- - In order to test process of compaign, I start compaign. -- - !workflow {model: marketing.campaign, action: state_running_set, ref: marketing_campaign_openerppartnerchannel} -- - I check the campaign on Running mode after started. -- - !assert {model: marketing.campaign, id: marketing_campaign_openerppartnerchannel}: - - state == 'running' -- - I start this segment after assinged campaign. -- - !workflow {model: marketing.campaign.segment, action: state_running_set, ref: marketing_campaign_segment0} -- - I check the segment on Running mode after started. -- - !assert {model: marketing.campaign.segment, id: marketing_campaign_segment0}: - - state == 'running' -- - I synchronized segment manually to see all step of activity and process covered on this campaign. -- - !python {model: marketing.campaign.segment}: | - segment_id = self.browse(cr ,uid ,ref("marketing_campaign_segment0") ,context) - assert segment_id.date_next_sync, 'Next Synchronization date is not calculated.' - self.synchroniz(cr, uid, [ref("marketing_campaign_segment0")]) -- - I cancel Marketing Workitems. -- - !python {model: marketing.campaign.workitem}: | - ids = self.search(cr, uid, [('segment_id', '=', ref('marketing_campaign_segment0')), - ('campaign_id', '=', ref('marketing_campaign_openerppartnerchannel'))]) - self.button_cancel(cr, uid, ids) - record = self.browse(cr, uid, ids[0]) - assert record.state == 'cancelled' or record.state == 'done' , 'Marketing Workitem shoud be in cancel state.' -- - I set Marketing Workitems in draft state. -- - !python {model: marketing.campaign.workitem}: | - ids = self.search(cr, uid, [('segment_id', '=', ref('marketing_campaign_segment0')), - ('campaign_id', '=', ref('marketing_campaign_openerppartnerchannel'))]) - self.button_draft(cr, uid, ids) - record = self.browse(cr, uid, ids[0]) - assert record.state == 'todo' or record.state == 'done' , 'Marketing Workitem shoud be in draft state.' -- - I process follow-up of first activity. -- - !python {model: marketing.campaign.workitem}: | - ids = self.search(cr, uid, [('segment_id', '=', ref('marketing_campaign_segment0')), - ('campaign_id', '=', ref('marketing_campaign_openerppartnerchannel')), ('activity_id', '=', ref('marketing_campaign_activity_0'))]) - assert ids, 'Follow-up item is not created for first activity.' - work_item_id = self.browse(cr ,uid ,ids[0] ,context) - assert work_item_id.res_name, 'Resource Name is not defined.' - self.process(cr, uid, ids) - record = self.browse(cr, uid, ids)[0] - assert record.state == "done", "Follow-up item should be closed after process." -- - I check follow-up detail of second activity after process of first activity. -- - !python {model: marketing.campaign.workitem}: | - ids = self.search(cr, uid, [('segment_id', '=', ref('marketing_campaign_segment0')), - ('campaign_id', '=', ref('marketing_campaign_openerppartnerchannel')), ('activity_id', '=', ref('marketing_campaign_activity_1'))]) - assert ids, 'Follow-up item is not created for second activity.' -- - Now I increase credit limit of customer -- - !python {model: res.partner}: | - self.write(cr, uid, [ref("base.res_partner_2")], {'credit_limit':41000}, context=context) -- - I process follow-up of second activity after set draft. -- - !python {model: marketing.campaign.workitem}: | - ids = self.search(cr, uid, [('segment_id', '=', ref('marketing_campaign_segment0')), - ('campaign_id', '=', ref('marketing_campaign_openerppartnerchannel')), ('activity_id', '=', ref('marketing_campaign_activity_1'))]) - self.button_draft(cr, uid, ids, context=context) - self.process(cr, uid, ids, context=context) - record = self.browse(cr, uid, ids[0], context=context) - assert record.state == "done", "Follow-up item should be closed after process." -- - I check follow-up detail of third activity after process of second activity. -- - !python {model: marketing.campaign.workitem}: | - ids = self.search(cr, uid, [('segment_id', '=', ref('marketing_campaign_segment0')), - ('campaign_id', '=', ref('marketing_campaign_openerppartnerchannel')), ('activity_id', '=', ref('marketing_campaign_activity_2'))]) - assert ids, 'Follow-up item is not created for third activity.' -- - Now I increase credit limit of customer -- - !python {model: res.partner}: | - self.write(cr, uid, [ref("base.res_partner_2")], {'credit_limit':151000}, context=context) -- - I process follow-up of third activity after set draft. -- - !python {model: marketing.campaign.workitem}: | - ids = self.search(cr, uid, [('segment_id', '=', ref('marketing_campaign_segment0')), - ('campaign_id', '=', ref('marketing_campaign_openerppartnerchannel')), ('activity_id', '=', ref('marketing_campaign_activity_2'))]) - self.button_draft(cr, uid, ids, context=context) - self.process(cr, uid, ids, context=context) - record = self.browse(cr, uid, ids[0], context=context) - assert record.state == "done", "Follow-up item should be closed after process." -- - I print workitem report. -- - !python {model: marketing.campaign.workitem}: | - ids = self.search(cr, uid, [('segment_id', '=', ref('marketing_campaign_segment0')), - ('campaign_id', '=', ref('marketing_campaign_openerppartnerchannel')), ('activity_id', '=', ref('marketing_campaign_activity_2'))]) - self.preview(cr, uid, ids) -- - I cancel segmentation because of some activity. -- - !workflow {model: marketing.campaign.segment, action: state_cancel_set, ref: marketing_campaign_segment0} -- - I check the segmentation is canceled. -- - !assert {model: marketing.campaign.segment, id: marketing_campaign_segment0}: - - state == 'cancelled' -- - I reopen the segmentation. -- - !workflow {model: marketing.campaign.segment, action: state_draft_set, ref: marketing_campaign_segment0} -- - !workflow {model: marketing.campaign.segment, action: state_running_set, ref: marketing_campaign_segment0} -- - I check the segment on Running mode after started. -- - !assert {model: marketing.campaign.segment, id: marketing_campaign_segment0}: - - state == 'running' -- - I close segmentation After completion of all activity. -- - !workflow {model: marketing.campaign.segment, action: state_done_set, ref: marketing_campaign_segment0} -- - I check the segmentation is done. -- - !assert {model: marketing.campaign.segment, id: marketing_campaign_segment0}: - - state == 'done' -- - I close this campaing. -- - !workflow {model: marketing.campaign.segment, action: state_done_set, ref: marketing_campaign_openerppartnerchannel} -- - I check the campaing is done. -- - !assert {model: marketing.campaign.segment, id: marketing_campaign_openerppartnerchannel}: - - state == 'done' - diff --git a/addons/marketing_campaign/tests/__init__.py b/addons/marketing_campaign/tests/__init__.py new file mode 100644 index 00000000000..0f036ac5661 --- /dev/null +++ b/addons/marketing_campaign/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import test_marketing_campaign_flow \ No newline at end of file diff --git a/addons/marketing_campaign/tests/test_marketing_campaign_flow.py b/addons/marketing_campaign/tests/test_marketing_campaign_flow.py new file mode 100644 index 00000000000..e84e1ef6288 --- /dev/null +++ b/addons/marketing_campaign/tests/test_marketing_campaign_flow.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import common + + +class TestMarketingCampaignFlow(common.TransactionCase): + + def test_00_marketing_campaign_flow(self): + + Workitem = self.env['marketing.campaign.workitem'] + + # In order to test process of compaign, I start compaign. + partner_channel = self.env.ref('marketing_campaign.marketing_campaign_openerppartnerchannel') + partner_channel.signal_workflow('state_running_set') + + # I check the campaign on Running mode after started. + self.assertEqual(partner_channel.state, 'running', 'The campaign should be on Running mode after having started.') + + # I start this segment after assinged campaign. + segment0 = self.env.ref('marketing_campaign.marketing_campaign_segment0') + segment0.signal_workflow('state_running_set') + + # I check the segment on Running mode after started. + self.assertEqual(segment0.state, 'running', 'The segment should be on Running mode after having started.') + + # I synchronized segment manually to see all step of activity and process covered on this campaign. + self.assertTrue(segment0.date_next_sync, 'Next Synchronization date is not calculated.') + segment0.process_segment() + + # I cancel Marketing Workitems. + workitems = Workitem.search([ + ('segment_id', '=', segment0.id), + ('campaign_id', '=', partner_channel.id) + ]) + workitems.button_cancel() + self.assertTrue(workitems[0].state in ('cancelled', 'done'), 'Marketing Workitem shoud be in cancel state.') + + # I set Marketing Workitems in draft state. + workitems.button_draft() + self.assertTrue(workitems[0].state in ('todo', 'done'), 'Marketing Workitem shoud be in draft state.') + + # I process follow-up of first activity. + activity0_id = self.ref('marketing_campaign.marketing_campaign_activity_0') + workitems = Workitem.search([ + ('segment_id', '=', segment0.id), + ('campaign_id', '=', partner_channel.id), + ('activity_id', '=', activity0_id) + ]) + self.assertTrue(workitems, 'Follow-up item is not created for first activity.') + self.assertTrue(workitems[0].res_name, 'Resource Name is not defined.') + workitems.process() + self.assertEqual(workitems[0].state, 'done', "Follow-up item should be closed after process.") + + # I check follow-up detail of second activity after process of first activity. + activity1_id = self.ref('marketing_campaign.marketing_campaign_activity_1') + workitems = Workitem.search([ + ('segment_id', '=', segment0.id), + ('campaign_id', '=', partner_channel.id), + ('activity_id', '=', activity1_id) + ]) + self.assertTrue(workitems, 'Follow-up item is not created for second activity.') + + # Now I increase credit limit of customer + self.env.ref("base.res_partner_2").write({'credit_limit': 41000}) + + # I process follow-up of second activity after set draft. + workitems.button_draft() + workitems.process() + self.assertEqual(workitems[0].state, 'done', "Follow-up item should be closed after process.") + + # I check follow-up detail of third activity after process of second activity. + activity2_id = self.ref('marketing_campaign.marketing_campaign_activity_2') + workitems = Workitem.search([ + ('segment_id', '=', segment0.id), + ('campaign_id', '=', partner_channel.id), + ('activity_id', '=', activity2_id) + ]) + self.assertTrue(workitems, 'Follow-up item is not created for third activity.') + + # Now I increase credit limit of customer + self.env.ref("base.res_partner_2").write({'credit_limit': 151000}) + + # I process follow-up of third activity after set draft. + workitems.button_draft() + workitems.process() + self.assertEqual(workitems[0].state, 'done', "Follow-up item should be closed after process.") + + # I print workitem report + workitems.preview() + + # I cancel segmentation because of some activity. + segment0.signal_workflow('state_cancel_set') + + # I check the segmentation is canceled. + self.assertEqual(segment0.state, 'cancelled', 'Segment should be in cancelled state.') + + # I reopen the segmentation. + segment0.signal_workflow('state_draft_set') + segment0.signal_workflow('state_running_set') + + # I check the segment on Running mode after started. + self.assertEqual(segment0.state, 'running', 'Segment should be in running state.') + + # I close segmentation After completion of all activity. + segment0.signal_workflow('state_done_set') + + # I check the segmentation is done. + self.assertEqual(segment0.state, 'done', 'Segment should be in done state.') + + # I close this campaing. + partner_channel.signal_workflow('state_done_set') + + # I check the campaing is done. + self.assertEqual(partner_channel.state, 'done', 'Campaign should be in done state.') diff --git a/addons/marketing_campaign/views/marketing_campaign_views.xml b/addons/marketing_campaign/views/marketing_campaign_views.xml index 1eb6572e23a..9bd7dfc126f 100644 --- a/addons/marketing_campaign/views/marketing_campaign_views.xml +++ b/addons/marketing_campaign/views/marketing_campaign_views.xml @@ -1,7 +1,5 @@ - - - - + + @@ -113,7 +111,7 @@ - + @@ -203,7 +201,7 @@ - +