1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-today OpenERP SA (<http://www.openerp.com>)
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 ##############################################################################
23 from datetime import datetime
24 from operator import itemgetter
27 from openerp import SUPERUSER_ID
28 from openerp import tools
29 from openerp.addons.base.res.res_partner import format_address
30 from openerp.osv import fields, osv, orm
31 from openerp.tools.translate import _
32 from openerp.tools import email_re
34 CRM_LEAD_FIELDS_TO_MERGE = ['name',
67 class crm_lead(format_address, osv.osv):
70 _description = "Lead/Opportunity"
71 _order = "priority,date_action,id desc"
72 _inherit = ['mail.thread', 'ir.needaction_mixin', 'crm.tracking.mixin']
76 # this is only an heuristics; depending on your particular stage configuration it may not match all 'new' stages
77 'crm.mt_lead_create': lambda self, cr, uid, obj, ctx=None: obj.probability == 0 and obj.stage_id and obj.stage_id.sequence <= 1,
78 'crm.mt_lead_stage': lambda self, cr, uid, obj, ctx=None: (obj.stage_id and obj.stage_id.sequence > 1) and obj.probability < 100,
79 'crm.mt_lead_won': lambda self, cr, uid, obj, ctx=None: obj.probability == 100 and obj.stage_id and obj.stage_id.fold,
80 'crm.mt_lead_lost': lambda self, cr, uid, obj, ctx=None: obj.probability == 0 and obj.stage_id and obj.stage_id.fold and obj.stage_id.sequence > 1,
83 _mail_mass_mailing = _('Leads / Opportunities')
85 def get_empty_list_help(self, cr, uid, help, context=None):
86 context = dict(context or {})
87 if context.get('default_type') == 'lead':
88 context['empty_list_help_model'] = 'crm.case.section'
89 context['empty_list_help_id'] = context.get('default_section_id')
90 context['empty_list_help_document_name'] = _("leads")
91 return super(crm_lead, self).get_empty_list_help(cr, uid, help, context=context)
93 def _get_default_section_id(self, cr, uid, context=None):
94 """ Gives default section by checking if present in the context """
95 section_id = self._resolve_section_id_from_context(cr, uid, context=context) or False
97 section_id = self.pool.get('res.users').browse(cr, uid, uid, context).default_section_id.id or False
100 def _get_default_stage_id(self, cr, uid, context=None):
101 """ Gives default stage_id """
102 section_id = self._get_default_section_id(cr, uid, context=context)
103 return self.stage_find(cr, uid, [], section_id, [('fold', '=', False)], context=context)
105 def _resolve_section_id_from_context(self, cr, uid, context=None):
106 """ Returns ID of section based on the value of 'section_id'
107 context key, or None if it cannot be resolved to a single
112 if type(context.get('default_section_id')) in (int, long):
113 return context.get('default_section_id')
114 if isinstance(context.get('default_section_id'), basestring):
115 section_ids = self.pool.get('crm.case.section').name_search(cr, uid, name=context['default_section_id'], context=context)
116 if len(section_ids) == 1:
117 return int(section_ids[0][0])
120 def _resolve_type_from_context(self, cr, uid, context=None):
121 """ Returns the type (lead or opportunity) from the type context
122 key. Returns None if it cannot be resolved.
126 return context.get('default_type')
128 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
129 access_rights_uid = access_rights_uid or uid
130 stage_obj = self.pool.get('crm.case.stage')
131 order = stage_obj._order
132 # lame hack to allow reverting search, should just work in the trivial case
133 if read_group_order == 'stage_id desc':
134 order = "%s desc" % order
135 # retrieve section_id from the context and write the domain
136 # - ('id', 'in', 'ids'): add columns that should be present
137 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
138 # - OR ('section_ids', '=', section_id), ('fold', '=', False) if section_id: add section columns that are not folded
140 section_id = self._resolve_section_id_from_context(cr, uid, context=context)
142 search_domain += ['|', ('section_ids', '=', section_id)]
143 search_domain += [('id', 'in', ids)]
145 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
146 # retrieve type from the context (if set: choose 'type' or 'both')
147 type = self._resolve_type_from_context(cr, uid, context=context)
149 search_domain += ['|', ('type', '=', type), ('type', '=', 'both')]
151 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
152 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
153 # restore order of the search
154 result.sort(lambda x, y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
157 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
158 fold[stage.id] = stage.fold or False
161 def fields_view_get(self, cr, user, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
162 res = super(crm_lead, self).fields_view_get(cr, user, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
163 if view_type == 'form':
164 res['arch'] = self.fields_view_get_address(cr, user, res['arch'], context=context)
168 'stage_id': _read_group_stage_ids
171 def _compute_day(self, cr, uid, ids, fields, args, context=None):
173 :return dict: difference between current date and log date
176 for lead in self.browse(cr, uid, ids, context=context):
181 if field == 'day_open':
183 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
184 date_open = datetime.strptime(lead.date_open, "%Y-%m-%d %H:%M:%S")
185 ans = date_open - date_create
186 elif field == 'day_close':
188 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
189 date_close = datetime.strptime(lead.date_closed, "%Y-%m-%d %H:%M:%S")
190 ans = date_close - date_create
192 duration = abs(int(ans.days))
193 res[lead.id][field] = duration
195 def _meeting_count(self, cr, uid, ids, field_name, arg, context=None):
196 Event = self.pool['calendar.event']
198 opp_id: Event.search_count(cr,uid, [('opportunity_id', '=', opp_id)], context=context)
202 'partner_id': fields.many2one('res.partner', 'Partner', ondelete='set null', track_visibility='onchange',
203 select=True, help="Linked partner (optional). Usually created when converting the lead."),
205 'id': fields.integer('ID', readonly=True),
206 'name': fields.char('Subject', required=True, select=1),
207 'active': fields.boolean('Active', required=False),
208 'date_action_last': fields.datetime('Last Action', readonly=1),
209 'date_action_next': fields.datetime('Next Action', readonly=1),
210 'email_from': fields.char('Email', size=128, help="Email address of the contact", select=1),
211 'section_id': fields.many2one('crm.case.section', 'Sales Team',
212 select=True, track_visibility='onchange', help='When sending mails, the default email address is taken from the sales team.'),
213 'create_date': fields.datetime('Creation Date', readonly=True),
214 'email_cc': fields.text('Global CC', 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"),
215 'description': fields.text('Notes'),
216 'write_date': fields.datetime('Update Date', readonly=True),
217 'categ_ids': fields.many2many('crm.case.categ', 'crm_lead_category_rel', 'lead_id', 'category_id', 'Tags', \
218 domain="['|', ('section_id', '=', section_id), ('section_id', '=', False), ('object_id.model', '=', 'crm.lead')]", help="Classify and analyze your lead/opportunity categories like: Training, Service"),
219 'contact_name': fields.char('Contact Name', size=64),
220 'partner_name': fields.char("Customer Name", size=64,help='The name of the future partner company that will be created while converting the lead into opportunity', select=1),
221 'opt_out': fields.boolean('Opt-Out', oldname='optout',
222 help="If opt-out is checked, this contact has refused to receive emails for mass mailing and marketing campaign. "
223 "Filter 'Available for Mass Mailing' allows users to filter the leads when performing mass mailing."),
224 'type': fields.selection([ ('lead','Lead'), ('opportunity','Opportunity'), ],'Type', select=True, help="Type is used to separate Leads and Opportunities"),
225 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
226 'date_closed': fields.datetime('Closed', readonly=True, copy=False),
227 'stage_id': fields.many2one('crm.case.stage', 'Stage', track_visibility='onchange', select=True,
228 domain="['&', ('section_ids', '=', section_id), '|', ('type', '=', type), ('type', '=', 'both')]"),
229 'user_id': fields.many2one('res.users', 'Salesperson', select=True, track_visibility='onchange'),
230 'referred': fields.char('Referred By'),
231 'date_open': fields.datetime('Assigned', readonly=True),
232 'day_open': fields.function(_compute_day, string='Days to Assign',
233 multi='day_open', type="float",
234 store={'crm.lead': (lambda self, cr, uid, ids, c={}: ids, ['date_open'], 10)}),
235 'day_close': fields.function(_compute_day, string='Days to Close',
236 multi='day_open', type="float",
237 store={'crm.lead': (lambda self, cr, uid, ids, c={}: ids, ['date_closed'], 10)}),
238 'date_last_stage_update': fields.datetime('Last Stage Update', select=True),
240 # Messaging and marketing
241 'message_bounce': fields.integer('Bounce'),
242 # Only used for type opportunity
243 'probability': fields.float('Success Rate (%)', group_operator="avg"),
244 'planned_revenue': fields.float('Expected Revenue', track_visibility='always'),
245 'ref': fields.reference('Reference', selection=openerp.addons.base.res.res_request.referencable_models),
246 'ref2': fields.reference('Reference 2', selection=openerp.addons.base.res.res_request.referencable_models),
247 'phone': fields.char("Phone", size=64),
248 'date_deadline': fields.date('Expected Closing', help="Estimate of the date on which the opportunity will be won."),
249 'date_action': fields.date('Next Action Date', select=True),
250 'title_action': fields.char('Next Action'),
251 'color': fields.integer('Color Index'),
252 'partner_address_name': fields.related('partner_id', 'name', type='char', string='Partner Contact Name', readonly=True),
253 'partner_address_email': fields.related('partner_id', 'email', type='char', string='Partner Contact Email', readonly=True),
254 'company_currency': fields.related('company_id', 'currency_id', type='many2one', string='Currency', readonly=True, relation="res.currency"),
255 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
256 'user_login': fields.related('user_id', 'login', type='char', string='User Login', readonly=True),
258 # Fields for address, due to separation from crm and res.partner
259 'street': fields.char('Street'),
260 'street2': fields.char('Street2'),
261 'zip': fields.char('Zip', change_default=True, size=24),
262 'city': fields.char('City'),
263 'state_id': fields.many2one("res.country.state", 'State'),
264 'country_id': fields.many2one('res.country', 'Country'),
265 'phone': fields.char('Phone'),
266 'fax': fields.char('Fax'),
267 'mobile': fields.char('Mobile'),
268 'function': fields.char('Function'),
269 'title': fields.many2one('res.partner.title', 'Title'),
270 'company_id': fields.many2one('res.company', 'Company', select=1),
271 'payment_mode': fields.many2one('crm.payment.mode', 'Payment Mode', \
272 domain="[('section_id','=',section_id)]"),
273 'planned_cost': fields.float('Planned Costs'),
274 'meeting_count': fields.function(_meeting_count, string='# Meetings', type='integer'),
280 'user_id': lambda s, cr, uid, c: uid,
281 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
282 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
283 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.lead', context=c),
284 'priority': lambda *a: crm.AVAILABLE_PRIORITIES[2][0],
286 'date_last_stage_update': fields.datetime.now,
290 ('check_probability', 'check(probability >= 0 and probability <= 100)', 'The probability of closing the deal should be between 0% and 100%!')
293 def onchange_stage_id(self, cr, uid, ids, stage_id, context=None):
296 stage = self.pool.get('crm.case.stage').browse(cr, uid, stage_id, context=context)
297 if not stage.on_change:
299 vals = {'probability': stage.probability}
300 if stage.probability >= 100 or (stage.probability == 0 and stage.sequence > 1):
301 vals['date_closed'] = fields.datetime.now()
302 return {'value': vals}
304 def on_change_partner_id(self, cr, uid, ids, partner_id, context=None):
307 partner = self.pool.get('res.partner').browse(cr, uid, partner_id, context=context)
309 'partner_name': partner.parent_id.name if partner.parent_id else partner.name,
310 'contact_name': partner.name if partner.parent_id else False,
311 'street': partner.street,
312 'street2': partner.street2,
313 'city': partner.city,
314 'state_id': partner.state_id and partner.state_id.id or False,
315 'country_id': partner.country_id and partner.country_id.id or False,
316 'email_from': partner.email,
317 'phone': partner.phone,
318 'mobile': partner.mobile,
322 return {'value': values}
324 def on_change_user(self, cr, uid, ids, user_id, context=None):
325 """ When changing the user, also set a section_id or restrict section id
326 to the ones user_id is member of. """
327 section_id = self._get_default_section_id(cr, uid, context=context) or False
328 if user_id and not section_id:
329 section_ids = self.pool.get('crm.case.section').search(cr, uid, ['|', ('user_id', '=', user_id), ('member_ids', '=', user_id)], context=context)
331 section_id = section_ids[0]
332 return {'value': {'section_id': section_id}}
334 def stage_find(self, cr, uid, cases, section_id, domain=None, order='sequence', context=None):
335 """ Override of the base.stage method
336 Parameter of the stage search taken from the lead:
337 - type: stage type must be the same or 'both'
338 - section_id: if set, stages must belong to this section or
339 be a default stage; if not set, stages must be default
342 if isinstance(cases, (int, long)):
343 cases = self.browse(cr, uid, cases, context=context)
346 # check whether we should try to add a condition on type
347 avoid_add_type_term = any([term for term in domain if len(term) == 3 if term[0] == 'type'])
348 # collect all section_ids
351 if not cases and context.get('default_type'):
352 ctx_type = context.get('default_type')
355 section_ids.add(section_id)
358 section_ids.add(lead.section_id.id)
359 if lead.type not in types:
360 types.append(lead.type)
361 # OR all section_ids and OR with case_default
364 search_domain += [('|')] * len(section_ids)
365 for section_id in section_ids:
366 search_domain.append(('section_ids', '=', section_id))
367 search_domain.append(('case_default', '=', True))
368 # AND with cases types
369 if not avoid_add_type_term:
370 search_domain.append(('type', 'in', types))
371 # AND with the domain in parameter
372 search_domain += list(domain)
373 # perform search, return the first found
374 stage_ids = self.pool.get('crm.case.stage').search(cr, uid, search_domain, order=order, limit=1, context=context)
379 def case_mark_lost(self, cr, uid, ids, context=None):
380 """ Mark the case as lost: state=cancel and probability=0
383 for lead in self.browse(cr, uid, ids, context=context):
384 stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 0.0), ('fold', '=', True), ('sequence', '>', 1)], context=context)
386 if stages_leads.get(stage_id):
387 stages_leads[stage_id].append(lead.id)
389 stages_leads[stage_id] = [lead.id]
391 raise osv.except_osv(_('Warning!'),
392 _('To relieve your sales pipe and group all Lost opportunities, configure one of your sales stage as follow:\n'
393 'probability = 0 %, select "Change Probability Automatically".\n'
394 'Create a specific stage or edit an existing one by editing columns of your opportunity pipe.'))
395 for stage_id, lead_ids in stages_leads.items():
396 self.write(cr, uid, lead_ids, {'stage_id': stage_id}, context=context)
399 def case_mark_won(self, cr, uid, ids, context=None):
400 """ Mark the case as won: state=done and probability=100
403 for lead in self.browse(cr, uid, ids, context=context):
404 stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 100.0), ('fold', '=', True)], context=context)
406 if stages_leads.get(stage_id):
407 stages_leads[stage_id].append(lead.id)
409 stages_leads[stage_id] = [lead.id]
411 raise osv.except_osv(_('Warning!'),
412 _('To relieve your sales pipe and group all Won opportunities, configure one of your sales stage as follow:\n'
413 'probability = 100 % and select "Change Probability Automatically".\n'
414 'Create a specific stage or edit an existing one by editing columns of your opportunity pipe.'))
415 for stage_id, lead_ids in stages_leads.items():
416 self.write(cr, uid, lead_ids, {'stage_id': stage_id}, context=context)
419 def case_escalate(self, cr, uid, ids, context=None):
420 """ Escalates case to parent level """
421 for case in self.browse(cr, uid, ids, context=context):
422 data = {'active': True}
423 if case.section_id.parent_id:
424 data['section_id'] = case.section_id.parent_id.id
425 if case.section_id.parent_id.change_responsible:
426 if case.section_id.parent_id.user_id:
427 data['user_id'] = case.section_id.parent_id.user_id.id
429 raise osv.except_osv(_('Error!'), _("You are already at the top level of your sales-team category.\nTherefore you cannot escalate furthermore."))
430 self.write(cr, uid, [case.id], data, context=context)
433 def _merge_get_result_type(self, cr, uid, opps, context=None):
435 Define the type of the result of the merge. If at least one of the
436 element to merge is an opp, the resulting new element will be an opp.
437 Otherwise it will be a lead.
439 We'll directly use a list of browse records instead of a list of ids
440 for performances' sake: it will spare a second browse of the
443 :param list opps: list of browse records containing the leads/opps to process
444 :return string type: the type of the final element
447 if (opp.type == 'opportunity'):
452 def _merge_data(self, cr, uid, ids, oldest, fields, context=None):
454 Prepare lead/opp data into a dictionary for merging. Different types
455 of fields are processed in different ways:
456 - text: all the values are concatenated
457 - m2m and o2m: those fields aren't processed
458 - m2o: the first not null value prevails (the other are dropped)
459 - any other type of field: same as m2o
461 :param list ids: list of ids of the leads to process
462 :param list fields: list of leads' fields to process
463 :return dict data: contains the merged values
465 opportunities = self.browse(cr, uid, ids, context=context)
467 def _get_first_not_null(attr):
468 for opp in opportunities:
469 if hasattr(opp, attr) and bool(getattr(opp, attr)):
470 return getattr(opp, attr)
473 def _get_first_not_null_id(attr):
474 res = _get_first_not_null(attr)
475 return res and res.id or False
477 def _concat_all(attr):
478 return '\n\n'.join(filter(lambda x: x, [getattr(opp, attr) or '' for opp in opportunities if hasattr(opp, attr)]))
480 # Process the fields' values
482 for field_name in fields:
483 field_info = self._all_columns.get(field_name)
484 if field_info is None:
486 field = field_info.column
487 if field._type in ('many2many', 'one2many'):
489 elif field._type == 'many2one':
490 data[field_name] = _get_first_not_null_id(field_name) # !!
491 elif field._type == 'text':
492 data[field_name] = _concat_all(field_name) #not lost
494 data[field_name] = _get_first_not_null(field_name) #not lost
496 # Define the resulting type ('lead' or 'opportunity')
497 data['type'] = self._merge_get_result_type(cr, uid, opportunities, context)
500 def _mail_body(self, cr, uid, lead, fields, title=False, context=None):
503 body.append("%s\n" % (title))
505 for field_name in fields:
506 field_info = self._all_columns.get(field_name)
507 if field_info is None:
509 field = field_info.column
512 if field._type == 'selection':
513 if hasattr(field.selection, '__call__'):
514 key = field.selection(self, cr, uid, context=context)
516 key = field.selection
517 value = dict(key).get(lead[field_name], lead[field_name])
518 elif field._type == 'many2one':
520 value = lead[field_name].name_get()[0][1]
521 elif field._type == 'many2many':
523 for val in lead[field_name]:
524 field_value = val.name_get()[0][1]
525 value += field_value + ","
527 value = lead[field_name]
529 body.append("%s: %s" % (field.string, value or ''))
530 return "<br/>".join(body + ['<br/>'])
532 def _merge_notify(self, cr, uid, opportunity_id, opportunities, context=None):
534 Create a message gathering merged leads/opps information.
536 #TOFIX: mail template should be used instead of fix body, subject text
538 result_type = self._merge_get_result_type(cr, uid, opportunities, context)
539 if result_type == 'lead':
540 merge_message = _('Merged leads')
542 merge_message = _('Merged opportunities')
543 subject = [merge_message]
544 for opportunity in opportunities:
545 subject.append(opportunity.name)
546 title = "%s : %s" % (opportunity.type == 'opportunity' and _('Merged opportunity') or _('Merged lead'), opportunity.name)
547 fields = list(CRM_LEAD_FIELDS_TO_MERGE)
548 details.append(self._mail_body(cr, uid, opportunity, fields, title=title, context=context))
550 # Chatter message's subject
551 subject = subject[0] + ": " + ", ".join(subject[1:])
552 details = "\n\n".join(details)
553 return self.message_post(cr, uid, [opportunity_id], body=details, subject=subject, context=context)
555 def _merge_opportunity_history(self, cr, uid, opportunity_id, opportunities, context=None):
556 message = self.pool.get('mail.message')
557 for opportunity in opportunities:
558 for history in opportunity.message_ids:
559 message.write(cr, uid, history.id, {
560 'res_id': opportunity_id,
561 'subject' : _("From %s : %s") % (opportunity.name, history.subject)
566 def _merge_opportunity_attachments(self, cr, uid, opportunity_id, opportunities, context=None):
567 attach_obj = self.pool.get('ir.attachment')
569 # return attachments of opportunity
570 def _get_attachments(opportunity_id):
571 attachment_ids = attach_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', opportunity_id)], context=context)
572 return attach_obj.browse(cr, uid, attachment_ids, context=context)
574 first_attachments = _get_attachments(opportunity_id)
575 #counter of all attachments to move. Used to make sure the name is different for all attachments
577 for opportunity in opportunities:
578 attachments = _get_attachments(opportunity.id)
579 for attachment in attachments:
580 values = {'res_id': opportunity_id,}
581 for attachment_in_first in first_attachments:
582 if attachment.name == attachment_in_first.name:
583 name = "%s (%s)" % (attachment.name, count,),
585 attachment.write(values)
588 def merge_opportunity(self, cr, uid, ids, user_id=False, section_id=False, context=None):
590 Different cases of merge:
591 - merge leads together = 1 new lead
592 - merge at least 1 opp with anything else (lead or opp) = 1 new opp
594 :param list ids: leads/opportunities ids to merge
595 :return int id: id of the resulting lead/opp
601 raise osv.except_osv(_('Warning!'), _('Please select more than one element (lead or opportunity) from the list view.'))
603 opportunities = self.browse(cr, uid, ids, context=context)
605 # Sorting the leads/opps according to the confidence level of its stage, which relates to the probability of winning it
606 # The confidence level increases with the stage sequence, except when the stage probability is 0.0 (Lost cases)
607 # An Opportunity always has higher confidence level than a lead, unless its stage probability is 0.0
608 for opportunity in opportunities:
610 if opportunity.stage_id and not opportunity.stage_id.fold:
611 sequence = opportunity.stage_id.sequence
612 sequenced_opps.append(((int(sequence != -1 and opportunity.type == 'opportunity'), sequence, -opportunity.id), opportunity))
614 sequenced_opps.sort(reverse=True)
615 opportunities = map(itemgetter(1), sequenced_opps)
616 ids = [opportunity.id for opportunity in opportunities]
617 highest = opportunities[0]
618 opportunities_rest = opportunities[1:]
620 tail_opportunities = opportunities_rest
622 fields = list(CRM_LEAD_FIELDS_TO_MERGE)
623 merged_data = self._merge_data(cr, uid, ids, highest, fields, context=context)
626 merged_data['user_id'] = user_id
628 merged_data['section_id'] = section_id
630 # Merge messages and attachements into the first opportunity
631 self._merge_opportunity_history(cr, uid, highest.id, tail_opportunities, context=context)
632 self._merge_opportunity_attachments(cr, uid, highest.id, tail_opportunities, context=context)
634 # Merge notifications about loss of information
635 opportunities = [highest]
636 opportunities.extend(opportunities_rest)
637 self._merge_notify(cr, uid, highest.id, opportunities, context=context)
638 # Check if the stage is in the stages of the sales team. If not, assign the stage with the lowest sequence
639 if merged_data.get('section_id'):
640 section_stage_ids = self.pool.get('crm.case.stage').search(cr, uid, [('section_ids', 'in', merged_data['section_id']), ('type', '=', merged_data.get('type'))], order='sequence', context=context)
641 if merged_data.get('stage_id') not in section_stage_ids:
642 merged_data['stage_id'] = section_stage_ids and section_stage_ids[0] or False
643 # Write merged data into first opportunity
644 self.write(cr, uid, [highest.id], merged_data, context=context)
645 # Delete tail opportunities
646 # We use the SUPERUSER to avoid access rights issues because as the user had the rights to see the records it should be safe to do so
647 self.unlink(cr, SUPERUSER_ID, [x.id for x in tail_opportunities], context=context)
651 def _convert_opportunity_data(self, cr, uid, lead, customer, section_id=False, context=None):
652 crm_stage = self.pool.get('crm.case.stage')
655 contact_id = self.pool.get('res.partner').address_get(cr, uid, [customer.id])['default']
657 section_id = lead.section_id and lead.section_id.id or False
659 'planned_revenue': lead.planned_revenue,
660 'probability': lead.probability,
662 'partner_id': customer and customer.id or False,
663 'user_id': (lead.user_id and lead.user_id.id),
664 'type': 'opportunity',
665 'date_action': fields.datetime.now(),
666 'date_open': fields.datetime.now(),
667 'email_from': customer and customer.email or lead.email_from,
668 'phone': customer and customer.phone or lead.phone,
670 if not lead.stage_id or lead.stage_id.type=='lead':
671 val['stage_id'] = self.stage_find(cr, uid, [lead], section_id, [('type', 'in', ('opportunity', 'both'))], context=context)
674 def convert_opportunity(self, cr, uid, ids, partner_id, user_ids=False, section_id=False, context=None):
677 partner = self.pool.get('res.partner')
678 customer = partner.browse(cr, uid, partner_id, context=context)
679 for lead in self.browse(cr, uid, ids, context=context):
680 # TDE: was if lead.state in ('done', 'cancel'):
681 if lead.probability == 100 or (lead.probability == 0 and lead.stage_id.fold):
683 vals = self._convert_opportunity_data(cr, uid, lead, customer, section_id, context=context)
684 self.write(cr, uid, [lead.id], vals, context=context)
686 if user_ids or section_id:
687 self.allocate_salesman(cr, uid, ids, user_ids, section_id, context=context)
691 def _lead_create_contact(self, cr, uid, lead, name, is_company, parent_id=False, context=None):
692 partner = self.pool.get('res.partner')
693 vals = {'name': name,
694 'user_id': lead.user_id.id,
695 'comment': lead.description,
696 'section_id': lead.section_id.id or False,
697 'parent_id': parent_id,
699 'mobile': lead.mobile,
700 'email': tools.email_split(lead.email_from) and tools.email_split(lead.email_from)[0] or False,
702 'title': lead.title and lead.title.id or False,
703 'function': lead.function,
704 'street': lead.street,
705 'street2': lead.street2,
708 'country_id': lead.country_id and lead.country_id.id or False,
709 'state_id': lead.state_id and lead.state_id.id or False,
710 'is_company': is_company,
713 partner = partner.create(cr, uid, vals, context=context)
716 def _create_lead_partner(self, cr, uid, lead, context=None):
718 if lead.partner_name and lead.contact_name:
719 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
720 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, partner_id, context=context)
721 elif lead.partner_name and not lead.contact_name:
722 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
723 elif not lead.partner_name and lead.contact_name:
724 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, context=context)
725 elif lead.email_from and self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]:
726 contact_name = self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]
727 partner_id = self._lead_create_contact(cr, uid, lead, contact_name, False, context=context)
729 raise osv.except_osv(
731 _('No customer name defined. Please fill one of the following fields: Company Name, Contact Name or Email ("Name <email@address>")')
735 def handle_partner_assignation(self, cr, uid, ids, action='create', partner_id=False, context=None):
737 Handle partner assignation during a lead conversion.
738 if action is 'create', create new partner with contact and assign lead to new partner_id.
739 otherwise assign lead to the specified partner_id
741 :param list ids: leads/opportunities ids to process
742 :param string action: what has to be done regarding partners (create it, assign an existing one, or nothing)
743 :param int partner_id: partner to assign if any
744 :return dict: dictionary organized as followed: {lead_id: partner_assigned_id}
746 #TODO this is a duplication of the handle_partner_assignation method of crm_phonecall
748 for lead in self.browse(cr, uid, ids, context=context):
749 # If the action is set to 'create' and no partner_id is set, create a new one
751 partner_ids[lead.id] = lead.partner_id.id
753 if not partner_id and action == 'create':
754 partner_id = self._create_lead_partner(cr, uid, lead, context)
755 self.pool['res.partner'].write(cr, uid, partner_id, {'section_id': lead.section_id and lead.section_id.id or False})
757 lead.write({'partner_id': partner_id}, context=context)
758 partner_ids[lead.id] = partner_id
761 def allocate_salesman(self, cr, uid, ids, user_ids=None, team_id=False, context=None):
763 Assign salesmen and salesteam to a batch of leads. If there are more
764 leads than salesmen, these salesmen will be assigned in round-robin.
765 E.g.: 4 salesmen (S1, S2, S3, S4) for 6 leads (L1, L2, ... L6). They
766 will be assigned as followed: L1 - S1, L2 - S2, L3 - S3, L4 - S4,
769 :param list ids: leads/opportunities ids to process
770 :param list user_ids: salesmen to assign
771 :param int team_id: salesteam to assign
779 value['section_id'] = team_id
781 value['user_id'] = user_ids[index]
782 # Cycle through user_ids
783 index = (index + 1) % len(user_ids)
785 self.write(cr, uid, [lead_id], value, context=context)
788 def schedule_phonecall(self, cr, uid, ids, schedule_time, call_summary, desc, phone, contact_name, user_id=False, section_id=False, categ_id=False, action='schedule', context=None):
790 :param string action: ('schedule','Schedule a call'), ('log','Log a call')
792 phonecall = self.pool.get('crm.phonecall')
793 model_data = self.pool.get('ir.model.data')
797 res_id = model_data._get_id(cr, uid, 'crm', 'categ_phone2')
798 categ_id = model_data.browse(cr, uid, res_id, context=context).res_id
801 for lead in self.browse(cr, uid, ids, context=context):
803 section_id = lead.section_id and lead.section_id.id or False
805 user_id = lead.user_id and lead.user_id.id or False
807 'name': call_summary,
808 'opportunity_id': lead.id,
809 'user_id': user_id or False,
810 'categ_id': categ_id or False,
811 'description': desc or '',
812 'date': schedule_time,
813 'section_id': section_id or False,
814 'partner_id': lead.partner_id and lead.partner_id.id or False,
815 'partner_phone': phone or lead.phone or (lead.partner_id and lead.partner_id.phone or False),
816 'partner_mobile': lead.partner_id and lead.partner_id.mobile or False,
817 'priority': lead.priority,
819 new_id = phonecall.create(cr, uid, vals, context=context)
820 phonecall.write(cr, uid, [new_id], {'state': 'open'}, context=context)
822 phonecall.write(cr, uid, [new_id], {'state': 'done'}, context=context)
823 phonecall_dict[lead.id] = new_id
824 self.schedule_phonecall_send_note(cr, uid, [lead.id], new_id, action, context=context)
825 return phonecall_dict
827 def redirect_opportunity_view(self, cr, uid, opportunity_id, context=None):
828 models_data = self.pool.get('ir.model.data')
830 # Get opportunity views
831 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
832 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_oppor')
834 'name': _('Opportunity'),
836 'view_mode': 'tree, form',
837 'res_model': 'crm.lead',
838 'domain': [('type', '=', 'opportunity')],
839 'res_id': int(opportunity_id),
841 'views': [(form_view or False, 'form'),
842 (tree_view or False, 'tree'), (False, 'kanban'),
843 (False, 'calendar'), (False, 'graph')],
844 'type': 'ir.actions.act_window',
847 def redirect_lead_view(self, cr, uid, lead_id, context=None):
848 models_data = self.pool.get('ir.model.data')
851 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_leads')
852 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_leads')
856 'view_mode': 'tree, form',
857 'res_model': 'crm.lead',
858 'domain': [('type', '=', 'lead')],
859 'res_id': int(lead_id),
861 'views': [(form_view or False, 'form'),
862 (tree_view or False, 'tree'),
863 (False, 'calendar'), (False, 'graph')],
864 'type': 'ir.actions.act_window',
867 def action_schedule_meeting(self, cr, uid, ids, context=None):
869 Open meeting's calendar view to schedule meeting on current opportunity.
870 :return dict: dictionary value for created Meeting view
872 lead = self.browse(cr, uid, ids[0], context)
873 res = self.pool.get('ir.actions.act_window').for_xml_id(cr, uid, 'calendar', 'action_calendar_event', context)
874 partner_ids = [self.pool['res.users'].browse(cr, uid, uid, context=context).partner_id.id]
876 partner_ids.append(lead.partner_id.id)
878 'default_opportunity_id': lead.type == 'opportunity' and lead.id or False,
879 'default_partner_id': lead.partner_id and lead.partner_id.id or False,
880 'default_partner_ids': partner_ids,
881 'default_section_id': lead.section_id and lead.section_id.id or False,
882 'default_name': lead.name,
886 def create(self, cr, uid, vals, context=None):
887 context = dict(context or {})
888 if vals.get('type') and not context.get('default_type'):
889 context['default_type'] = vals.get('type')
890 if vals.get('section_id') and not context.get('default_section_id'):
891 context['default_section_id'] = vals.get('section_id')
892 if vals.get('user_id'):
893 vals['date_open'] = fields.datetime.now()
895 # context: no_log, because subtype already handle this
896 create_context = dict(context, mail_create_nolog=True)
897 return super(crm_lead, self).create(cr, uid, vals, context=create_context)
899 def write(self, cr, uid, ids, vals, context=None):
900 # stage change: update date_last_stage_update
901 if 'stage_id' in vals:
902 vals['date_last_stage_update'] = fields.datetime.now()
903 if vals.get('user_id'):
904 vals['date_open'] = fields.datetime.now()
905 # stage change with new stage: update probability and date_closed
906 if vals.get('stage_id') and not vals.get('probability'):
907 onchange_stage_values = self.onchange_stage_id(cr, uid, ids, vals.get('stage_id'), context=context)['value']
908 vals.update(onchange_stage_values)
909 return super(crm_lead, self).write(cr, uid, ids, vals, context=context)
911 def copy(self, cr, uid, id, default=None, context=None):
916 lead = self.browse(cr, uid, id, context=context)
917 local_context = dict(context)
918 local_context.setdefault('default_type', lead.type)
919 local_context.setdefault('default_section_id', lead.section_id)
920 if lead.type == 'opportunity':
921 default['date_open'] = fields.datetime.now()
923 default['date_open'] = False
924 return super(crm_lead, self).copy(cr, uid, id, default, context=local_context)
926 def get_empty_list_help(self, cr, uid, help, context=None):
927 context = dict(context or {})
928 context['empty_list_help_model'] = 'crm.case.section'
929 context['empty_list_help_id'] = context.get('default_section_id', None)
930 context['empty_list_help_document_name'] = _("opportunity")
931 if context.get('default_type') == 'lead':
932 context['empty_list_help_document_name'] = _("lead")
933 return super(crm_lead, self).get_empty_list_help(cr, uid, help, context=context)
935 # ----------------------------------------
937 # ----------------------------------------
939 def message_get_reply_to(self, cr, uid, ids, context=None):
940 """ Override to get the reply_to of the parent project. """
941 leads = self.browse(cr, SUPERUSER_ID, ids, context=context)
942 section_ids = set([lead.section_id.id for lead in leads if lead.section_id])
943 aliases = self.pool['crm.case.section'].message_get_reply_to(cr, uid, list(section_ids), context=context)
944 return dict((lead.id, aliases.get(lead.section_id and lead.section_id.id or 0, False)) for lead in leads)
946 def get_formview_id(self, cr, uid, id, context=None):
947 obj = self.browse(cr, uid, id, context=context)
948 if obj.type == 'opportunity':
949 model, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
951 view_id = super(crm_lead, self).get_formview_id(cr, uid, id, context=context)
954 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
955 recipients = super(crm_lead, self).message_get_suggested_recipients(cr, uid, ids, context=context)
957 for lead in self.browse(cr, uid, ids, context=context):
959 self._message_add_suggested_recipient(cr, uid, recipients, lead, partner=lead.partner_id, reason=_('Customer'))
960 elif lead.email_from:
961 self._message_add_suggested_recipient(cr, uid, recipients, lead, email=lead.email_from, reason=_('Customer Email'))
962 except (osv.except_osv, orm.except_orm): # no read access rights -> just ignore suggested recipients because this imply modifying followers
966 def message_new(self, cr, uid, msg, custom_values=None, context=None):
967 """ Overrides mail_thread message_new that is called by the mailgateway
968 through message_process.
969 This override updates the document according to the email.
971 if custom_values is None:
974 'name': msg.get('subject') or _("No Subject"),
975 'email_from': msg.get('from'),
976 'email_cc': msg.get('cc'),
977 'partner_id': msg.get('author_id', False),
980 if msg.get('author_id'):
981 defaults.update(self.on_change_partner_id(cr, uid, None, msg.get('author_id'), context=context)['value'])
982 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
983 defaults['priority'] = msg.get('priority')
984 defaults.update(custom_values)
985 return super(crm_lead, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
987 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
988 """ Overrides mail_thread message_update that is called by the mailgateway
989 through message_process.
990 This method updates the document according to the email.
992 if isinstance(ids, (str, int, long)):
994 if update_vals is None: update_vals = {}
996 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
997 update_vals['priority'] = msg.get('priority')
999 'cost':'planned_cost',
1000 'revenue': 'planned_revenue',
1001 'probability':'probability',
1003 for line in msg.get('body', '').split('\n'):
1005 res = tools.command_re.match(line)
1006 if res and maps.get(res.group(1).lower()):
1007 key = maps.get(res.group(1).lower())
1008 update_vals[key] = res.group(2).lower()
1010 return super(crm_lead, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1012 # ----------------------------------------
1013 # OpenChatter methods and notifications
1014 # ----------------------------------------
1016 def schedule_phonecall_send_note(self, cr, uid, ids, phonecall_id, action, context=None):
1017 phonecall = self.pool.get('crm.phonecall').browse(cr, uid, [phonecall_id], context=context)[0]
1019 message = _('Logged a call for %(date)s. %(description)s')
1021 message = _('Scheduled a call for %(date)s. %(description)s')
1022 phonecall_date = datetime.strptime(phonecall.date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
1023 phonecall_usertime = fields.datetime.context_timestamp(cr, uid, phonecall_date, context=context).strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1024 html_time = "<time datetime='%s+00:00'>%s</time>" % (phonecall.date, phonecall_usertime)
1025 message = message % dict(date=html_time, description=phonecall.description)
1026 return self.message_post(cr, uid, ids, body=message, context=context)
1028 def log_meeting(self, cr, uid, ids, meeting_subject, meeting_date, duration, context=None):
1030 duration = _('unknown')
1032 duration = str(duration)
1033 message = _("Meeting scheduled at '%s'<br> Subject: %s <br> Duration: %s hour(s)") % (meeting_date, meeting_subject, duration)
1034 return self.message_post(cr, uid, ids, body=message, context=context)
1036 def onchange_state(self, cr, uid, ids, state_id, context=None):
1038 country_id=self.pool.get('res.country.state').browse(cr, uid, state_id, context).country_id.id
1039 return {'value':{'country_id':country_id}}
1042 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1043 res = super(crm_lead, self).message_partner_info_from_emails(cr, uid, id, emails, link_mail=link_mail, context=context)
1044 lead = self.browse(cr, uid, id, context=context)
1045 for partner_info in res:
1046 if not partner_info.get('partner_id') and (lead.partner_name or lead.contact_name):
1047 emails = email_re.findall(partner_info['full_name'] or '')
1048 email = emails and emails[0] or ''
1049 if email and lead.email_from and email.lower() == lead.email_from.lower():
1050 partner_info['full_name'] = '%s <%s>' % (lead.partner_name or lead.contact_name, email)
1054 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: