1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as
9 # published by the Free Software Foundation, either version 3 of the
10 # License, or (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 ##############################################################################
22 from osv import fields, osv
23 from datetime import datetime
26 from tools.translate import _
27 from crm import crm_case
32 CRM_LEAD_PENDING_STATES = (
33 crm.AVAILABLE_STATES[2][0], # Cancelled
34 crm.AVAILABLE_STATES[3][0], # Done
35 crm.AVAILABLE_STATES[4][0], # Pending
38 class crm_lead(crm_case, osv.osv):
41 _description = "Lead/Opportunity"
42 _order = "priority,date_action,id desc"
43 _inherit = ['mail.thread','res.partner.address']
45 # overridden because res.partner.address has an inconvenient name_get,
46 # especially if base_contact is installed.
47 def name_get(self, cr, user, ids, context=None):
48 if isinstance(ids, (int, long)):
50 return [(r['id'], tools.ustr(r[self._rec_name]))
51 for r in self.read(cr, user, ids, [self._rec_name], context)]
53 def _compute_day(self, cr, uid, ids, fields, args, context=None):
55 @param cr: the current row, from the database cursor,
56 @param uid: the current user’s ID for security checks,
57 @param ids: List of Openday’s IDs
58 @return: difference between current date and log date
59 @param context: A standard dictionary for contextual values
61 cal_obj = self.pool.get('resource.calendar')
62 res_obj = self.pool.get('resource.resource')
65 for lead in self.browse(cr, uid, ids, context=context):
70 if field == 'day_open':
72 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
73 date_open = datetime.strptime(lead.date_open, "%Y-%m-%d %H:%M:%S")
74 ans = date_open - date_create
75 date_until = lead.date_open
76 elif field == 'day_close':
78 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
79 date_close = datetime.strptime(lead.date_closed, "%Y-%m-%d %H:%M:%S")
80 date_until = lead.date_closed
81 ans = date_close - date_create
85 resource_ids = res_obj.search(cr, uid, [('user_id','=',lead.user_id.id)])
87 resource_id = resource_ids[0]
89 duration = float(ans.days)
90 if lead.section_id and lead.section_id.resource_calendar_id:
91 duration = float(ans.days) * 24
92 new_dates = cal_obj.interval_get(cr,
94 lead.section_id.resource_calendar_id and lead.section_id.resource_calendar_id.id or False,
95 datetime.strptime(lead.create_date, '%Y-%m-%d %H:%M:%S'),
100 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
101 for in_time, out_time in new_dates:
102 if in_time.date not in no_days:
103 no_days.append(in_time.date)
104 if out_time > date_until:
106 duration = len(no_days)
107 res[lead.id][field] = abs(int(duration))
110 def _history_search(self, cr, uid, obj, name, args, context=None):
112 msg_obj = self.pool.get('mail.message')
113 message_ids = msg_obj.search(cr, uid, [('email_from','!=',False), ('subject', args[0][1], args[0][2])], context=context)
114 lead_ids = self.search(cr, uid, [('message_ids', 'in', message_ids)], context=context)
117 return [('id', 'in', lead_ids)]
119 return [('id', '=', '0')]
121 def _get_email_subject(self, cr, uid, ids, fields, args, context=None):
123 for obj in self.browse(cr, uid, ids, context=context):
125 for msg in obj.message_ids:
127 res[obj.id] = msg.subject
132 # Overridden from res.partner.address:
133 'partner_id': fields.many2one('res.partner', 'Partner', ondelete='set null',
134 select=True, help="Optional linked partner, usually after conversion of the lead"),
136 'id': fields.integer('ID'),
137 'name': fields.char('Name', size=64, select=1),
138 'active': fields.boolean('Active', required=False),
139 'date_action_last': fields.datetime('Last Action', readonly=1),
140 'date_action_next': fields.datetime('Next Action', readonly=1),
141 'email_from': fields.char('Email', size=128, help="E-mail address of the contact", select=1),
142 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
143 select=True, help='When sending mails, the default email address is taken from the sales team.'),
144 'create_date': fields.datetime('Creation Date' , readonly=True),
145 'email_cc': fields.text('Global CC', size=252 , help="These email addresses will be added to the CC field of all inbound and outbound emails for this record before being sent. Separate multiple email addresses with a comma"),
146 'description': fields.text('Notes'),
147 'write_date': fields.datetime('Update Date' , readonly=True),
149 'categ_id': fields.many2one('crm.case.categ', 'Category', \
150 domain="['|',('section_id','=',section_id),('section_id','=',False), ('object_id.model', '=', 'crm.lead')]"),
151 'type_id': fields.many2one('crm.case.resource.type', 'Campaign', \
152 domain="['|',('section_id','=',section_id),('section_id','=',False)]", help="From which campaign (seminar, marketing campaign, mass mailing, ...) did this contact come from?"),
153 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel (mail, direct, phone, ...)"),
154 'contact_name': fields.char('Contact Name', size=64),
155 'partner_name': fields.char("Customer Name", size=64,help='The name of the future partner that will be created while converting the into opportunity', select=1),
156 'optin': fields.boolean('Opt-In', help="If opt-in is checked, this contact has accepted to receive emails."),
157 'optout': fields.boolean('Opt-Out', help="If opt-out is checked, this contact has refused to receive emails or unsubscribed to a campaign."),
158 'type':fields.selection([ ('lead','Lead'), ('opportunity','Opportunity'), ],'Type', help="Type is used to separate Leads and Opportunities"),
159 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority'),
160 'date_closed': fields.datetime('Closed', readonly=True),
161 'stage_id': fields.many2one('crm.case.stage', 'Stage', domain="[('section_ids', '=', section_id)]"),
162 'user_id': fields.many2one('res.users', 'Salesman'),
163 'referred': fields.char('Referred By', size=64),
164 'date_open': fields.datetime('Opened', readonly=True),
165 'day_open': fields.function(_compute_day, string='Days to Open', \
166 multi='day_open', type="float", store=True),
167 'day_close': fields.function(_compute_day, string='Days to Close', \
168 multi='day_close', type="float", store=True),
169 'state': fields.selection(crm.AVAILABLE_STATES, 'State', size=16, readonly=True,
170 help='The state is set to \'Draft\', when a case is created.\
171 \nIf the case is in progress the state is set to \'Open\'.\
172 \nWhen the case is over, the state is set to \'Done\'.\
173 \nIf the case needs to be reviewed then the state is set to \'Pending\'.'),
174 'message_ids': fields.one2many('mail.message', 'res_id', 'Messages', domain=[('model','=',_name)]),
175 'subjects': fields.function(_get_email_subject, fnct_search=_history_search, string='Subject of Email', type='char', size=64),
178 # Only used for type opportunity
179 'partner_address_id': fields.many2one('res.partner.address', 'Partner Contact', domain="[('partner_id','=',partner_id)]"),
180 'probability': fields.float('Probability (%)',group_operator="avg"),
181 'planned_revenue': fields.float('Expected Revenue'),
182 'ref': fields.reference('Reference', selection=crm._links_get, size=128),
183 'ref2': fields.reference('Reference 2', selection=crm._links_get, size=128),
184 'phone': fields.char("Phone", size=64),
185 'date_deadline': fields.date('Expected Closing'),
186 'date_action': fields.date('Next Action Date'),
187 'title_action': fields.char('Next Action', size=64),
188 'stage_id': fields.many2one('crm.case.stage', 'Stage', domain="[('section_ids', '=', section_id)]"),
192 'active': lambda *a: 1,
193 'user_id': crm_case._get_default_user,
194 'email_from': crm_case._get_default_email,
195 'state': lambda *a: 'draft',
196 'type': lambda *a: 'lead',
197 'section_id': crm_case._get_section,
198 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.lead', context=c),
199 'priority': lambda *a: crm.AVAILABLE_PRIORITIES[2][0],
200 #'stage_id': _get_stage_id,
203 def onchange_partner_address_id(self, cr, uid, ids, add, email=False):
204 """This function returns value of partner email based on Partner Address
207 return {'value': {'email_from': False, 'country_id': False}}
208 address = self.pool.get('res.partner.address').browse(cr, uid, add)
209 return {'value': {'email_from': address.email, 'phone': address.phone, 'country_id': address.country_id.id}}
211 def on_change_optin(self, cr, uid, ids, optin):
212 return {'value':{'optin':optin,'optout':False}}
214 def on_change_optout(self, cr, uid, ids, optout):
215 return {'value':{'optout':optout,'optin':False}}
217 def onchange_stage_id(self, cr, uid, ids, stage_id, context={}):
220 stage = self.pool.get('crm.case.stage').browse(cr, uid, stage_id, context)
221 if not stage.on_change:
223 return {'value':{'probability': stage.probability}}
225 def stage_find_percent(self, cr, uid, percent, section_id):
226 """ Return the first stage with a probability == percent
228 stage_pool = self.pool.get('crm.case.stage')
230 ids = stage_pool.search(cr, uid, [("probability", '=', percent), ("section_ids", 'in', [section_id])])
232 ids = stage_pool.search(cr, uid, [("probability", '=', percent)])
238 def stage_find_lost(self, cr, uid, section_id):
239 return self.stage_find_percent(cr, uid, 0.0, section_id)
241 def stage_find_won(self, cr, uid, section_id):
242 return self.stage_find_percent(cr, uid, 100.0, section_id)
244 def case_open(self, cr, uid, ids, *args):
245 for l in self.browse(cr, uid, ids):
246 # When coming from draft override date and stage otherwise just set state
247 if l.state == 'draft':
249 message = _("The lead '%s' has been opened.") % l.name
250 elif l.type == 'opportunity':
251 message = _("The opportunity '%s' has been opened.") % l.name
253 message = _("The case '%s' has been opened.") % l.name
254 self.log(cr, uid, l.id, message)
255 value = {'date_open': time.strftime('%Y-%m-%d %H:%M:%S')}
256 self.write(cr, uid, [l.id], value)
257 if l.type == 'opportunity' and not l.stage_id:
258 stage_id = self.stage_find(cr, uid, l.section_id.id or False, [('sequence','>',0)])
260 self.stage_set(cr, uid, [l.id], stage_id)
261 res = super(crm_lead, self).case_open(cr, uid, ids, *args)
264 def case_close(self, cr, uid, ids, *args):
265 res = super(crm_lead, self).case_close(cr, uid, ids, *args)
266 self.write(cr, uid, ids, {'date_closed': time.strftime('%Y-%m-%d %H:%M:%S')})
267 for case in self.browse(cr, uid, ids):
268 if case.type == 'lead':
269 message = _("The lead '%s' has been closed.") % case.name
271 message = _("The case '%s' has been closed.") % case.name
272 self.log(cr, uid, case.id, message)
275 def case_cancel(self, cr, uid, ids, *args):
276 """Overrides cancel for crm_case for setting probability
278 res = super(crm_lead, self).case_cancel(cr, uid, ids, args)
279 self.write(cr, uid, ids, {'probability' : 0.0})
282 def case_reset(self, cr, uid, ids, *args):
283 """Overrides reset as draft in order to set the stage field as empty
285 res = super(crm_lead, self).case_reset(cr, uid, ids, *args)
286 self.write(cr, uid, ids, {'stage_id': False, 'probability': 0.0})
289 def case_mark_lost(self, cr, uid, ids, *args):
290 """Mark the case as lost: state = done and probability = 0%
292 res = super(crm_lead, self).case_close(cr, uid, ids, *args)
293 self.write(cr, uid, ids, {'probability' : 0.0})
294 for l in self.browse(cr, uid, ids):
295 stage_id = self.stage_find_lost(cr, uid, l.section_id.id or False)
297 self.stage_set(cr, uid, [l.id], stage_id)
298 message = _("The opportunity '%s' has been marked as lost.") % l.name
299 self.log(cr, uid, l.id, message)
302 def case_mark_won(self, cr, uid, ids, *args):
303 """Mark the case as lost: state = done and probability = 0%
305 res = super(crm_lead, self).case_close(cr, uid, ids, *args)
306 self.write(cr, uid, ids, {'probability' : 100.0})
307 for l in self.browse(cr, uid, ids):
308 stage_id = self.stage_find_won(cr, uid, l.section_id.id or False)
310 self.stage_set(cr, uid, [l.id], stage_id)
311 message = _("The opportunity '%s' has been been won.") % l.name
312 self.log(cr, uid, l.id, message)
315 def convert_opportunity(self, cr, uid, ids, context=None):
316 """ Precomputation for converting lead to opportunity
320 context.update({'active_ids': ids})
322 data_obj = self.pool.get('ir.model.data')
326 for case in self.browse(cr, uid, ids, context=context):
327 context.update({'active_id': case.id})
328 data_id = data_obj._get_id(cr, uid, 'crm', 'view_crm_lead2opportunity_partner')
331 view_id1 = data_obj.browse(cr, uid, data_id, context=context).res_id
333 'name': _('Create Partner'),
335 'view_mode': 'form,tree',
336 'res_model': 'crm.lead2opportunity.partner',
339 'views': [(view_id1, 'form')],
340 'type': 'ir.actions.act_window',
346 def message_new(self, cr, uid, msg, custom_values=None, context=None):
347 """Automatically calls when new email message arrives"""
348 res_id = super(crm_lead, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
349 subject = msg.get('subject') or _("No Subject")
350 body = msg.get('body_text')
352 msg_from = msg.get('from')
353 priority = msg.get('priority')
356 'email_from': msg_from,
357 'email_cc': msg.get('cc'),
362 vals['priority'] = priority
363 vals.update(self.message_partner_by_email(cr, uid, msg.get('from', False)))
364 self.write(cr, uid, [res_id], vals, context)
367 def message_update(self, cr, uid, ids, msg, vals={}, default_act='pending', context=None):
368 if isinstance(ids, (str, int, long)):
371 super(crm_lead, self).message_update(cr, uid, ids, msg, context=context)
373 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
374 vals['priority'] = msg.get('priority')
376 'cost':'planned_cost',
377 'revenue': 'planned_revenue',
378 'probability':'probability'
381 for line in msg['body_text'].split('\n'):
383 res = tools.misc.command_re.match(line)
384 if res and maps.get(res.group(1).lower()):
385 key = maps.get(res.group(1).lower())
386 vls[key] = res.group(2).lower()
389 # Unfortunately the API is based on lists
390 # but we want to update the state based on the
391 # previous state, so we have to loop:
392 for case in self.browse(cr, uid, ids, context=context):
394 if case.state in CRM_LEAD_PENDING_STATES:
395 values.update(state=crm.AVAILABLE_STATES[1][0]) #re-open
396 res = self.write(cr, uid, [case.id], values, context=context)
399 def action_makeMeeting(self, cr, uid, ids, context=None):
401 This opens Meeting's calendar view to schedule meeting on current Opportunity
402 @return : Dictionary value for created Meeting view
405 for opp in self.browse(cr, uid, ids, context=context):
406 data_obj = self.pool.get('ir.model.data')
409 result = data_obj._get_id(cr, uid, 'crm', 'view_crm_case_meetings_filter')
410 res = data_obj.read(cr, uid, result, ['res_id'])
411 id1 = data_obj._get_id(cr, uid, 'crm', 'crm_case_calendar_view_meet')
412 id2 = data_obj._get_id(cr, uid, 'crm', 'crm_case_form_view_meet')
413 id3 = data_obj._get_id(cr, uid, 'crm', 'crm_case_tree_view_meet')
415 id1 = data_obj.browse(cr, uid, id1, context=context).res_id
417 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
419 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
422 'default_opportunity_id': opp.id,
423 'default_partner_id': opp.partner_id and opp.partner_id.id or False,
424 'default_user_id': uid,
425 'default_section_id': opp.section_id and opp.section_id.id or False,
426 'default_email_from': opp.email_from,
427 'default_state': 'open',
428 'default_name': opp.name
431 'name': _('Meetings'),
434 'view_mode': 'calendar,form,tree',
435 'res_model': 'crm.meeting',
437 'views': [(id1, 'calendar'), (id2, 'form'), (id3, 'tree')],
438 'type': 'ir.actions.act_window',
439 'search_view_id': res['res_id'],
445 def unlink(self, cr, uid, ids, context=None):
446 for lead in self.browse(cr, uid, ids, context):
447 if (not lead.section_id.allow_unlink) and (lead.state <> 'draft'):
448 raise osv.except_osv(_('Warning !'),
449 _('You can not delete this lead. You should better cancel it.'))
450 return super(crm_lead, self).unlink(cr, uid, ids, context)
453 def write(self, cr, uid, ids, vals, context=None):
457 if 'date_closed' in vals:
458 return super(crm_lead,self).write(cr, uid, ids, vals, context=context)
460 if 'stage_id' in vals and vals['stage_id']:
461 stage_obj = self.pool.get('crm.case.stage').browse(cr, uid, vals['stage_id'], context=context)
462 text = _("Changed Stage to: %s") % stage_obj.name
463 self.message_append(cr, uid, ids, text, body_text=text, context=context)
465 for case in self.browse(cr, uid, ids, context=context):
466 if case.type == 'lead' or context.get('stage_type',False)=='lead':
467 message = _("The stage of lead '%s' has been changed to '%s'.") % (case.name, stage_obj.name)
468 elif case.type == 'opportunity':
469 message = _("The stage of opportunity '%s' has been changed to '%s'.") % (case.name, stage_obj.name)
470 self.log(cr, uid, case.id, message)
471 return super(crm_lead,self).write(cr, uid, ids, vals, context)
475 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: