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, email_split
35 CRM_LEAD_FIELDS_TO_MERGE = ['name',
68 class crm_lead(format_address, osv.osv):
71 _description = "Lead/Opportunity"
72 _order = "priority desc,date_action,id desc"
73 _inherit = ['mail.thread', 'ir.needaction_mixin', 'crm.tracking.mixin']
77 # this is only an heuristics; depending on your particular stage configuration it may not match all 'new' stages
78 'crm.mt_lead_create': lambda self, cr, uid, obj, ctx=None: obj.probability == 0 and obj.stage_id and obj.stage_id.sequence <= 1,
79 'crm.mt_lead_stage': lambda self, cr, uid, obj, ctx=None: (obj.stage_id and obj.stage_id.sequence > 1) and obj.probability < 100,
80 'crm.mt_lead_won': lambda self, cr, uid, obj, ctx=None: obj.probability == 100 and obj.stage_id and obj.stage_id.fold,
81 '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,
84 _mail_mass_mailing = _('Leads / Opportunities')
86 def get_empty_list_help(self, cr, uid, help, context=None):
87 context = dict(context or {})
88 if context.get('default_type') == 'lead':
89 context['empty_list_help_model'] = 'crm.team'
90 context['empty_list_help_id'] = context.get('default_team_id')
91 context['empty_list_help_document_name'] = _("leads")
92 return super(crm_lead, self).get_empty_list_help(cr, uid, help, context=context)
94 def _get_default_team_id(self, cr, uid, user_id=False, context=None):
95 """ Gives default team by checking if present in the context """
96 team_id = self._resolve_team_id_from_context(cr, uid, context=context) or False
99 def _get_default_stage_id(self, cr, uid, context=None):
100 """ Gives default stage_id """
101 team_id = self._get_default_team_id(cr, uid, context=context)
102 return self.stage_find(cr, uid, [], team_id, [('fold', '=', False)], context=context)
104 def _resolve_team_id_from_context(self, cr, uid, context=None):
105 """ Returns ID of team based on the value of 'team_id'
106 context key, or None if it cannot be resolved to a single
111 if type(context.get('default_team_id')) in (int, long):
112 return context.get('default_team_id')
113 if isinstance(context.get('default_team_id'), basestring):
114 team_ids = self.pool.get('crm.team').name_search(cr, uid, name=context['default_team_id'], context=context)
115 if len(team_ids) == 1:
116 return int(team_ids[0][0])
119 def _resolve_type_from_context(self, cr, uid, context=None):
120 """ Returns the type (lead or opportunity) from the type context
121 key. Returns None if it cannot be resolved.
125 return context.get('default_type')
127 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
128 access_rights_uid = access_rights_uid or uid
129 stage_obj = self.pool.get('crm.stage')
130 order = stage_obj._order
131 # lame hack to allow reverting search, should just work in the trivial case
132 if read_group_order == 'stage_id desc':
133 order = "%s desc" % order
134 # retrieve team_id from the context and write the domain
135 # - ('id', 'in', 'ids'): add columns that should be present
136 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
137 # - OR ('team_ids', '=', team_id), ('fold', '=', False) if team_id: add team columns that are not folded
139 team_id = self._resolve_team_id_from_context(cr, uid, context=context)
141 search_domain += ['|', ('team_ids', '=', team_id)]
142 search_domain += [('id', 'in', ids)]
144 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
145 # retrieve type from the context (if set: choose 'type' or 'both')
146 type = self._resolve_type_from_context(cr, uid, context=context)
148 search_domain += ['|', ('type', '=', type), ('type', '=', 'both')]
150 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
151 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
152 # restore order of the search
153 result.sort(lambda x, y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
156 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
157 fold[stage.id] = stage.fold or False
160 def fields_view_get(self, cr, user, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
161 if context and context.get('opportunity_id'):
162 action = self._get_formview_action(cr, user, context['opportunity_id'], context=context)
163 if action.get('views') and any(view_id for view_id in action['views'] if view_id[1] == view_type):
164 view_id = next(view_id[0] for view_id in action['views'] if view_id[1] == view_type)
165 res = super(crm_lead, self).fields_view_get(cr, user, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
166 if view_type == 'form':
167 res['arch'] = self.fields_view_get_address(cr, user, res['arch'], context=context)
171 'stage_id': _read_group_stage_ids
174 def _compute_day(self, cr, uid, ids, fields, args, context=None):
176 :return dict: difference between current date and log date
179 for lead in self.browse(cr, uid, ids, context=context):
184 if field == 'day_open':
186 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
187 date_open = datetime.strptime(lead.date_open, "%Y-%m-%d %H:%M:%S")
188 ans = date_open - date_create
189 elif field == 'day_close':
191 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
192 date_close = datetime.strptime(lead.date_closed, "%Y-%m-%d %H:%M:%S")
193 ans = date_close - date_create
195 duration = abs(int(ans.days))
196 res[lead.id][field] = duration
198 def _meeting_count(self, cr, uid, ids, field_name, arg, context=None):
199 Event = self.pool['calendar.event']
201 opp_id: Event.search_count(cr,uid, [('opportunity_id', '=', opp_id)], context=context)
205 'partner_id': fields.many2one('res.partner', 'Partner', ondelete='set null', track_visibility='onchange',
206 select=True, help="Linked partner (optional). Usually created when converting the lead."),
208 'id': fields.integer('ID', readonly=True),
209 'name': fields.char('Opportunity', required=True, select=1),
210 'active': fields.boolean('Active', required=False),
211 'date_action_last': fields.datetime('Last Action', readonly=1),
212 'date_action_next': fields.datetime('Next Action', readonly=1),
213 'email_from': fields.char('Email', size=128, help="Email address of the contact", select=1),
214 'team_id': fields.many2one('crm.team', 'Sales Team', oldname='section_id',
215 select=True, track_visibility='onchange', help='When sending mails, the default email address is taken from the sales team.'),
216 'create_date': fields.datetime('Creation Date', readonly=True),
217 '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"),
218 'description': fields.text('Notes'),
219 'write_date': fields.datetime('Update Date', readonly=True),
220 'tag_ids': fields.many2many('crm.lead.tag', 'crm_lead_tag_rel', 'lead_id', 'tag_id', 'Tags', help="Classify and analyze your lead/opportunity categories like: Training, Service"),
221 'contact_name': fields.char('Contact Name', size=64),
222 '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),
223 'opt_out': fields.boolean('Opt-Out', oldname='optout',
224 help="If opt-out is checked, this contact has refused to receive emails for mass mailing and marketing campaign. "
225 "Filter 'Available for Mass Mailing' allows users to filter the leads when performing mass mailing."),
226 'type': fields.selection([ ('lead','Lead'), ('opportunity','Opportunity'), ],'Type', select=True, help="Type is used to separate Leads and Opportunities"),
227 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
228 'date_closed': fields.datetime('Closed', readonly=True, copy=False),
229 'stage_id': fields.many2one('crm.stage', 'Stage', track_visibility='onchange', select=True,
230 domain="['&', ('team_ids', '=', team_id), '|', ('type', '=', type), ('type', '=', 'both')]"),
231 'user_id': fields.many2one('res.users', 'Salesperson', select=True, track_visibility='onchange'),
232 'referred': fields.char('Referred By'),
233 'date_open': fields.datetime('Assigned', readonly=True),
234 'day_open': fields.function(_compute_day, string='Days to Assign',
235 multi='day_open', type="float",
236 store={'crm.lead': (lambda self, cr, uid, ids, c={}: ids, ['date_open'], 10)}),
237 'day_close': fields.function(_compute_day, string='Days to Close',
238 multi='day_open', type="float",
239 store={'crm.lead': (lambda self, cr, uid, ids, c={}: ids, ['date_closed'], 10)}),
240 'date_last_stage_update': fields.datetime('Last Stage Update', select=True),
242 # Messaging and marketing
243 'message_bounce': fields.integer('Bounce'),
244 # Only used for type opportunity
245 'probability': fields.float('Success Rate (%)', group_operator="avg"),
246 'planned_revenue': fields.float('Expected Revenue', track_visibility='always'),
247 'ref': fields.reference('Reference', selection=openerp.addons.base.res.res_request.referencable_models),
248 'ref2': fields.reference('Reference 2', selection=openerp.addons.base.res.res_request.referencable_models),
249 'phone': fields.char("Phone", size=64),
250 'date_deadline': fields.date('Expected Closing', help="Estimate of the date on which the opportunity will be won."),
251 'date_action': fields.date('Next Action Date', select=True),
252 'title_action': fields.char('Next Action'),
253 'color': fields.integer('Color Index'),
254 'partner_address_name': fields.related('partner_id', 'name', type='char', string='Partner Contact Name', readonly=True),
255 'partner_address_email': fields.related('partner_id', 'email', type='char', string='Partner Contact Email', readonly=True),
256 'company_currency': fields.related('company_id', 'currency_id', type='many2one', string='Currency', readonly=True, relation="res.currency"),
257 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
258 'user_login': fields.related('user_id', 'login', type='char', string='User Login', readonly=True),
260 # Fields for address, due to separation from crm and res.partner
261 'street': fields.char('Street'),
262 'street2': fields.char('Street2'),
263 'zip': fields.char('Zip', change_default=True, size=24),
264 'city': fields.char('City'),
265 'state_id': fields.many2one("res.country.state", 'State'),
266 'country_id': fields.many2one('res.country', 'Country'),
267 'phone': fields.char('Phone'),
268 'fax': fields.char('Fax'),
269 'mobile': fields.char('Mobile'),
270 'function': fields.char('Function'),
271 'title': fields.many2one('res.partner.title', 'Title'),
272 'company_id': fields.many2one('res.company', 'Company', select=1),
273 'planned_cost': fields.float('Planned Costs'),
274 'meeting_count': fields.function(_meeting_count, string='# Meetings', type='integer'),
275 'lost_reason': fields.many2one('crm.lost.reason', 'Lost Reason', select=True, track_visibility='onchange')
281 'user_id': lambda s, cr, uid, c: uid,
282 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
283 'team_id': lambda s, cr, uid, c: s._get_default_team_id(cr, uid, context=c),
284 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.lead', context=c),
285 'priority': lambda *a: crm.AVAILABLE_PRIORITIES[0][0],
287 'date_last_stage_update': fields.datetime.now,
291 ('check_probability', 'check(probability >= 0 and probability <= 100)', 'The probability of closing the deal should be between 0% and 100%!')
294 def onchange_stage_id(self, cr, uid, ids, stage_id, context=None):
297 stage = self.pool.get('crm.stage').browse(cr, uid, stage_id, context=context)
298 if not stage.on_change:
300 vals = {'probability': stage.probability}
301 if stage.probability >= 100 or (stage.probability == 0 and stage.sequence > 1):
302 vals['date_closed'] = fields.datetime.now()
303 return {'value': vals}
305 def on_change_partner_id(self, cr, uid, ids, partner_id, context=None):
308 partner = self.pool.get('res.partner').browse(cr, uid, partner_id, context=context)
310 'partner_name': partner.parent_id.name if partner.parent_id else partner.name,
311 'contact_name': partner.name if partner.parent_id else False,
312 'street': partner.street,
313 'street2': partner.street2,
314 'city': partner.city,
315 'state_id': partner.state_id and partner.state_id.id or False,
316 'country_id': partner.country_id and partner.country_id.id or False,
317 'email_from': partner.email,
318 'phone': partner.phone,
319 'mobile': partner.mobile,
323 return {'value': values}
325 def on_change_user(self, cr, uid, ids, user_id, context=None):
326 """ When changing the user, also set a team_id or restrict team id
327 to the ones user_id is member of. """
328 team_id = self._get_default_team_id(cr, uid, context=context)
329 if user_id and not team_id:
330 team_ids = self.pool.get('crm.team').search(cr, uid, ['|', ('user_id', '=', user_id), ('member_ids', '=', user_id)], context=context)
332 team_id = team_ids[0]
333 return {'value': {'team_id': team_id}}
335 def stage_find(self, cr, uid, cases, team_id, domain=None, order='sequence', context=None):
336 """ Override of the base.stage method
337 Parameter of the stage search taken from the lead:
338 - type: stage type must be the same or 'both'
339 - team_id: if set, stages must belong to this team or
340 be a default stage; if not set, stages must be default
343 if isinstance(cases, (int, long)):
344 cases = self.browse(cr, uid, cases, context=context)
347 # check whether we should try to add a condition on type
348 avoid_add_type_term = any([term for term in domain if len(term) == 3 if term[0] == 'type'])
349 # collect all team_ids
352 if not cases and context.get('default_type'):
353 ctx_type = context.get('default_type')
356 team_ids.add(team_id)
359 team_ids.add(lead.team_id.id)
360 if lead.type not in types:
361 types.append(lead.type)
362 # OR all team_ids and OR with case_default
365 search_domain += [('|')] * len(team_ids)
366 for team_id in team_ids:
367 search_domain.append(('team_ids', '=', team_id))
368 search_domain.append(('case_default', '=', True))
369 # AND with cases types
370 if not avoid_add_type_term:
371 search_domain.append(('type', 'in', types))
372 # AND with the domain in parameter
373 search_domain += list(domain)
374 # perform search, return the first found
375 stage_ids = self.pool.get('crm.stage').search(cr, uid, search_domain, order=order, limit=1, context=context)
380 def case_mark_lost(self, cr, uid, ids, context=None):
381 """ Mark the case as lost: state=cancel and probability=0
384 for lead in self.browse(cr, uid, ids, context=context):
385 stage_id = self.stage_find(cr, uid, [lead], lead.team_id.id or False, [('probability', '=', 0.0), ('fold', '=', True), ('sequence', '>', 1)], context=context)
387 if stages_leads.get(stage_id):
388 stages_leads[stage_id].append(lead.id)
390 stages_leads[stage_id] = [lead.id]
392 raise osv.except_osv(_('Warning!'),
393 _('To relieve your sales pipe and group all Lost opportunities, configure one of your sales stage as follow:\n'
394 'probability = 0 %, select "Change Probability Automatically".\n'
395 'Create a specific stage or edit an existing one by editing columns of your opportunity pipe.'))
396 for stage_id, lead_ids in stages_leads.items():
397 self.write(cr, uid, lead_ids, {'stage_id': stage_id}, context=context)
400 def case_mark_won(self, cr, uid, ids, context=None):
401 """ Mark the case as won: state=done and probability=100
404 for lead in self.browse(cr, uid, ids, context=context):
405 stage_id = self.stage_find(cr, uid, [lead], lead.team_id.id or False, [('probability', '=', 100.0), ('fold', '=', True)], context=context)
407 if stages_leads.get(stage_id):
408 stages_leads[stage_id].append(lead.id)
410 stages_leads[stage_id] = [lead.id]
412 raise osv.except_osv(_('Warning!'),
413 _('To relieve your sales pipe and group all Won opportunities, configure one of your sales stage as follow:\n'
414 'probability = 100 % and select "Change Probability Automatically".\n'
415 'Create a specific stage or edit an existing one by editing columns of your opportunity pipe.'))
416 for stage_id, lead_ids in stages_leads.items():
417 self.write(cr, uid, lead_ids, {'stage_id': stage_id}, context=context)
420 def case_escalate(self, cr, uid, ids, context=None):
421 """ Escalates case to parent level """
422 for case in self.browse(cr, uid, ids, context=context):
423 data = {'active': True}
424 if case.team_id.parent_id:
425 data['team_id'] = case.team_id.parent_id.id
426 if case.team_id.parent_id.change_responsible:
427 if case.team_id.parent_id.user_id:
428 data['user_id'] = case.team_id.parent_id.user_id.id
430 raise osv.except_osv(_('Error!'), _("You are already at the top level of your sales-team category.\nTherefore you cannot escalate furthermore."))
431 self.write(cr, uid, [case.id], data, context=context)
434 def _merge_get_result_type(self, cr, uid, opps, context=None):
436 Define the type of the result of the merge. If at least one of the
437 element to merge is an opp, the resulting new element will be an opp.
438 Otherwise it will be a lead.
440 We'll directly use a list of browse records instead of a list of ids
441 for performances' sake: it will spare a second browse of the
444 :param list opps: list of browse records containing the leads/opps to process
445 :return string type: the type of the final element
448 if (opp.type == 'opportunity'):
453 def _merge_data(self, cr, uid, ids, oldest, fields, context=None):
455 Prepare lead/opp data into a dictionary for merging. Different types
456 of fields are processed in different ways:
457 - text: all the values are concatenated
458 - m2m and o2m: those fields aren't processed
459 - m2o: the first not null value prevails (the other are dropped)
460 - any other type of field: same as m2o
462 :param list ids: list of ids of the leads to process
463 :param list fields: list of leads' fields to process
464 :return dict data: contains the merged values
466 opportunities = self.browse(cr, uid, ids, context=context)
468 def _get_first_not_null(attr):
469 for opp in opportunities:
470 if hasattr(opp, attr) and bool(getattr(opp, attr)):
471 return getattr(opp, attr)
474 def _get_first_not_null_id(attr):
475 res = _get_first_not_null(attr)
476 return res and res.id or False
478 def _concat_all(attr):
479 return '\n\n'.join(filter(lambda x: x, [getattr(opp, attr) or '' for opp in opportunities if hasattr(opp, attr)]))
481 # Process the fields' values
483 for field_name in fields:
484 field = self._fields.get(field_name)
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 = self._fields.get(field_name)
511 if field.type == 'selection':
512 if callable(field.selection):
513 key = field.selection(self, cr, uid, context=context)
515 key = field.selection
516 value = dict(key).get(lead[field_name], lead[field_name])
517 elif field.type == 'many2one':
519 value = lead[field_name].name_get()[0][1]
520 elif field.type == 'many2many':
522 for val in lead[field_name]:
523 field_value = val.name_get()[0][1]
524 value += field_value + ","
526 value = lead[field_name]
528 body.append("%s: %s" % (field.string, value or ''))
529 return "<br/>".join(body + ['<br/>'])
531 def _merge_notify(self, cr, uid, opportunity_id, opportunities, context=None):
533 Create a message gathering merged leads/opps information.
535 #TOFIX: mail template should be used instead of fix body, subject text
537 result_type = self._merge_get_result_type(cr, uid, opportunities, context)
538 if result_type == 'lead':
539 merge_message = _('Merged leads')
541 merge_message = _('Merged opportunities')
542 subject = [merge_message]
543 for opportunity in opportunities:
544 subject.append(opportunity.name)
545 title = "%s : %s" % (opportunity.type == 'opportunity' and _('Merged opportunity') or _('Merged lead'), opportunity.name)
546 fields = list(CRM_LEAD_FIELDS_TO_MERGE)
547 details.append(self._mail_body(cr, uid, opportunity, fields, title=title, context=context))
549 # Chatter message's subject
550 subject = subject[0] + ": " + ", ".join(subject[1:])
551 details = "\n\n".join(details)
552 return self.message_post(cr, uid, [opportunity_id], body=details, subject=subject, context=context)
554 def _merge_opportunity_history(self, cr, uid, opportunity_id, opportunities, context=None):
555 message = self.pool.get('mail.message')
556 for opportunity in opportunities:
557 for history in opportunity.message_ids:
558 message.write(cr, uid, history.id, {
559 'res_id': opportunity_id,
560 'subject' : _("From %s : %s") % (opportunity.name, history.subject)
565 def _merge_opportunity_attachments(self, cr, uid, opportunity_id, opportunities, context=None):
566 attach_obj = self.pool.get('ir.attachment')
568 # return attachments of opportunity
569 def _get_attachments(opportunity_id):
570 attachment_ids = attach_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', opportunity_id)], context=context)
571 return attach_obj.browse(cr, uid, attachment_ids, context=context)
573 first_attachments = _get_attachments(opportunity_id)
574 #counter of all attachments to move. Used to make sure the name is different for all attachments
576 for opportunity in opportunities:
577 attachments = _get_attachments(opportunity.id)
578 for attachment in attachments:
579 values = {'res_id': opportunity_id,}
580 for attachment_in_first in first_attachments:
581 if attachment.name == attachment_in_first.name:
582 values['name'] = "%s (%s)" % (attachment.name, count,),
584 attachment.write(values)
587 def _merge_opportunity_phonecalls(self, cr, uid, opportunity_id, opportunities, context=None):
588 phonecall_obj = self.pool['crm.phonecall']
589 for opportunity in opportunities:
590 for phonecall_id in phonecall_obj.search(cr, uid, [('opportunity_id', '=', opportunity.id)], context=context):
591 phonecall_obj.write(cr, uid, phonecall_id, {'opportunity_id': opportunity_id}, context=context)
594 def get_duplicated_leads(self, cr, uid, ids, partner_id, include_lost=False, context=None):
596 Search for opportunities that have the same partner and that arent done or cancelled
598 lead = self.browse(cr, uid, ids[0], context=context)
599 email = lead.partner_id and lead.partner_id.email or lead.email_from
600 return self.pool['crm.lead']._get_duplicated_leads_by_emails(cr, uid, partner_id, email, include_lost=include_lost, context=context)
602 def _get_duplicated_leads_by_emails(self, cr, uid, partner_id, email, include_lost=False, context=None):
604 Search for opportunities that have the same partner and that arent done or cancelled
606 final_stage_domain = [('stage_id.probability', '<', 100), '|', ('stage_id.probability', '>', 0), ('stage_id.sequence', '<=', 1)]
607 partner_match_domain = []
608 for email in set(email_split(email) + [email]):
609 partner_match_domain.append(('email_from', '=ilike', email))
611 partner_match_domain.append(('partner_id', '=', partner_id))
612 partner_match_domain = ['|'] * (len(partner_match_domain) - 1) + partner_match_domain
613 if not partner_match_domain:
615 domain = partner_match_domain
617 domain += final_stage_domain
618 return self.search(cr, uid, domain, context=context)
620 def merge_dependences(self, cr, uid, highest, opportunities, context=None):
621 self._merge_notify(cr, uid, highest, opportunities, context=context)
622 self._merge_opportunity_history(cr, uid, highest, opportunities, context=context)
623 self._merge_opportunity_attachments(cr, uid, highest, opportunities, context=context)
624 self._merge_opportunity_phonecalls(cr, uid, highest, opportunities, context=context)
626 def merge_opportunity(self, cr, uid, ids, user_id=False, team_id=False, context=None):
628 Different cases of merge:
629 - merge leads together = 1 new lead
630 - merge at least 1 opp with anything else (lead or opp) = 1 new opp
632 :param list ids: leads/opportunities ids to merge
633 :return int id: id of the resulting lead/opp
639 raise osv.except_osv(_('Warning!'), _('Please select more than one element (lead or opportunity) from the list view.'))
641 opportunities = self.browse(cr, uid, ids, context=context)
643 # Sorting the leads/opps according to the confidence level of its stage, which relates to the probability of winning it
644 # The confidence level increases with the stage sequence, except when the stage probability is 0.0 (Lost cases)
645 # An Opportunity always has higher confidence level than a lead, unless its stage probability is 0.0
646 for opportunity in opportunities:
648 if opportunity.stage_id and not opportunity.stage_id.fold:
649 sequence = opportunity.stage_id.sequence
650 sequenced_opps.append(((int(sequence != -1 and opportunity.type == 'opportunity'), sequence, -opportunity.id), opportunity))
652 sequenced_opps.sort(reverse=True)
653 opportunities = map(itemgetter(1), sequenced_opps)
654 ids = [opportunity.id for opportunity in opportunities]
655 highest = opportunities[0]
656 opportunities_rest = opportunities[1:]
658 tail_opportunities = opportunities_rest
660 fields = list(CRM_LEAD_FIELDS_TO_MERGE)
661 merged_data = self._merge_data(cr, uid, ids, highest, fields, context=context)
664 merged_data['user_id'] = user_id
666 merged_data['team_id'] = team_id
668 # Merge notifications about loss of information
669 opportunities = [highest]
670 opportunities.extend(opportunities_rest)
672 self.merge_dependences(cr, uid, highest.id, tail_opportunities, context=context)
674 # Check if the stage is in the stages of the sales team. If not, assign the stage with the lowest sequence
675 if merged_data.get('team_id'):
676 team_stage_ids = self.pool.get('crm.stage').search(cr, uid, [('team_ids', 'in', merged_data['team_id']), ('type', '=', merged_data.get('type'))], order='sequence', context=context)
677 if merged_data.get('stage_id') not in team_stage_ids:
678 merged_data['stage_id'] = team_stage_ids and team_stage_ids[0] or False
679 # Write merged data into first opportunity
680 self.write(cr, uid, [highest.id], merged_data, context=context)
681 # Delete tail opportunities
682 # 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
683 self.unlink(cr, SUPERUSER_ID, [x.id for x in tail_opportunities], context=context)
687 def _convert_opportunity_data(self, cr, uid, lead, customer, team_id=False, context=None):
688 crm_stage = self.pool.get('crm.stage')
691 contact_id = self.pool.get('res.partner').address_get(cr, uid, [customer.id])['default']
693 team_id = lead.team_id and lead.team_id.id or False
695 'planned_revenue': lead.planned_revenue,
696 'probability': lead.probability,
698 'partner_id': customer and customer.id or False,
699 'type': 'opportunity',
700 'date_action': fields.datetime.now(),
701 'date_open': fields.datetime.now(),
702 'email_from': customer and customer.email or lead.email_from,
703 'phone': customer and customer.phone or lead.phone,
705 if not lead.stage_id or lead.stage_id.type=='lead':
706 val['stage_id'] = self.stage_find(cr, uid, [lead], team_id, [('type', 'in', ('opportunity', 'both'))], context=context)
709 def convert_opportunity(self, cr, uid, ids, partner_id, user_ids=False, team_id=False, context=None):
712 partner = self.pool.get('res.partner')
713 customer = partner.browse(cr, uid, partner_id, context=context)
714 for lead in self.browse(cr, uid, ids, context=context):
715 # TDE: was if lead.state in ('done', 'cancel'):
716 if lead.probability == 100 or (lead.probability == 0 and lead.stage_id.fold):
718 vals = self._convert_opportunity_data(cr, uid, lead, customer, team_id, context=context)
719 self.write(cr, uid, [lead.id], vals, context=context)
721 if user_ids or team_id:
722 self.allocate_salesman(cr, uid, ids, user_ids, team_id, context=context)
726 def _lead_create_contact(self, cr, uid, lead, name, is_company, parent_id=False, context=None):
727 partner = self.pool.get('res.partner')
728 vals = {'name': name,
729 'user_id': lead.user_id.id,
730 'comment': lead.description,
731 'team_id': lead.team_id.id or False,
732 'parent_id': parent_id,
734 'mobile': lead.mobile,
735 'email': tools.email_split(lead.email_from) and tools.email_split(lead.email_from)[0] or False,
737 'title': lead.title and lead.title.id or False,
738 'function': lead.function,
739 'street': lead.street,
740 'street2': lead.street2,
743 'country_id': lead.country_id and lead.country_id.id or False,
744 'state_id': lead.state_id and lead.state_id.id or False,
745 'is_company': is_company,
748 partner = partner.create(cr, uid, vals, context=context)
751 def _create_lead_partner(self, cr, uid, lead, context=None):
753 if lead.partner_name and lead.contact_name:
754 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
755 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, partner_id, context=context)
756 elif lead.partner_name and not lead.contact_name:
757 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
758 elif not lead.partner_name and lead.contact_name:
759 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, context=context)
760 elif lead.email_from and self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]:
761 contact_name = self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]
762 partner_id = self._lead_create_contact(cr, uid, lead, contact_name, False, context=context)
764 raise osv.except_osv(
766 _('No customer name defined. Please fill one of the following fields: Company Name, Contact Name or Email ("Name <email@address>")')
770 def handle_partner_assignation(self, cr, uid, ids, action='create', partner_id=False, context=None):
772 Handle partner assignation during a lead conversion.
773 if action is 'create', create new partner with contact and assign lead to new partner_id.
774 otherwise assign lead to the specified partner_id
776 :param list ids: leads/opportunities ids to process
777 :param string action: what has to be done regarding partners (create it, assign an existing one, or nothing)
778 :param int partner_id: partner to assign if any
779 :return dict: dictionary organized as followed: {lead_id: partner_assigned_id}
781 #TODO this is a duplication of the handle_partner_assignation method of crm_phonecall
783 for lead in self.browse(cr, uid, ids, context=context):
784 # If the action is set to 'create' and no partner_id is set, create a new one
786 partner_ids[lead.id] = lead.partner_id.id
788 if not partner_id and action == 'create':
789 partner_id = self._create_lead_partner(cr, uid, lead, context)
790 self.pool['res.partner'].write(cr, uid, partner_id, {'team_id': lead.team_id and lead.team_id.id or False})
792 lead.write({'partner_id': partner_id}, context=context)
793 partner_ids[lead.id] = partner_id
796 def allocate_salesman(self, cr, uid, ids, user_ids=None, team_id=False, context=None):
798 Assign salesmen and salesteam to a batch of leads. If there are more
799 leads than salesmen, these salesmen will be assigned in round-robin.
800 E.g.: 4 salesmen (S1, S2, S3, S4) for 6 leads (L1, L2, ... L6). They
801 will be assigned as followed: L1 - S1, L2 - S2, L3 - S3, L4 - S4,
804 :param list ids: leads/opportunities ids to process
805 :param list user_ids: salesmen to assign
806 :param int team_id: salesteam to assign
814 value['team_id'] = team_id
816 value['user_id'] = user_ids[index]
817 # Cycle through user_ids
818 index = (index + 1) % len(user_ids)
820 self.write(cr, uid, [lead_id], value, context=context)
823 def schedule_phonecall(self, cr, uid, ids, schedule_time, call_summary, desc, phone, contact_name, user_id=False, team_id=False, categ_id=False, action='schedule', context=None):
825 :param string action: ('schedule','Schedule a call'), ('log','Log a call')
827 phonecall = self.pool.get('crm.phonecall')
828 model_data = self.pool.get('ir.model.data')
832 res_id = model_data._get_id(cr, uid, 'crm', 'categ_phone2')
833 categ_id = model_data.browse(cr, uid, res_id, context=context).res_id
836 for lead in self.browse(cr, uid, ids, context=context):
838 team_id = lead.team_id and lead.team_id.id or False
840 user_id = lead.user_id and lead.user_id.id or False
842 'name': call_summary,
843 'opportunity_id': lead.id,
844 'user_id': user_id or False,
845 'categ_id': categ_id or False,
846 'description': desc or '',
847 'date': schedule_time,
848 'team_id': team_id or False,
849 'partner_id': lead.partner_id and lead.partner_id.id or False,
850 'partner_phone': phone or lead.phone or (lead.partner_id and lead.partner_id.phone or False),
851 'partner_mobile': lead.partner_id and lead.partner_id.mobile or False,
852 'priority': lead.priority,
854 new_id = phonecall.create(cr, uid, vals, context=context)
855 phonecall.write(cr, uid, [new_id], {'state': 'open'}, context=context)
857 phonecall.write(cr, uid, [new_id], {'state': 'done'}, context=context)
858 phonecall_dict[lead.id] = new_id
859 self.schedule_phonecall_send_note(cr, uid, [lead.id], new_id, action, context=context)
860 return phonecall_dict
862 def redirect_opportunity_view(self, cr, uid, opportunity_id, context=None):
863 models_data = self.pool.get('ir.model.data')
865 # Get opportunity views
866 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
867 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_oppor')
869 'name': _('Opportunity'),
871 'view_mode': 'tree, form',
872 'res_model': 'crm.lead',
873 'domain': [('type', '=', 'opportunity')],
874 'res_id': int(opportunity_id),
876 'views': [(form_view or False, 'form'),
877 (tree_view or False, 'tree'), (False, 'kanban'),
878 (False, 'calendar'), (False, 'graph')],
879 'type': 'ir.actions.act_window',
882 def redirect_lead_view(self, cr, uid, lead_id, context=None):
883 models_data = self.pool.get('ir.model.data')
886 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_leads')
887 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_leads')
891 'view_mode': 'tree, form',
892 'res_model': 'crm.lead',
893 'domain': [('type', '=', 'lead')],
894 'res_id': int(lead_id),
896 'views': [(form_view or False, 'form'),
897 (tree_view or False, 'tree'),
898 (False, 'calendar'), (False, 'graph')],
899 'type': 'ir.actions.act_window',
902 def action_schedule_meeting(self, cr, uid, ids, context=None):
904 Open meeting's calendar view to schedule meeting on current opportunity.
905 :return dict: dictionary value for created Meeting view
907 lead = self.browse(cr, uid, ids[0], context)
908 res = self.pool.get('ir.actions.act_window').for_xml_id(cr, uid, 'calendar', 'action_calendar_event', context)
909 partner_ids = [self.pool['res.users'].browse(cr, uid, uid, context=context).partner_id.id]
911 partner_ids.append(lead.partner_id.id)
913 'default_opportunity_id': lead.type == 'opportunity' and lead.id or False,
914 'default_partner_id': lead.partner_id and lead.partner_id.id or False,
915 'default_partner_ids': partner_ids,
916 'default_team_id': lead.team_id and lead.team_id.id or False,
917 'default_name': lead.name,
921 def create(self, cr, uid, vals, context=None):
922 context = dict(context or {})
923 if vals.get('type') and not context.get('default_type'):
924 context['default_type'] = vals.get('type')
925 if vals.get('team_id') and not context.get('default_team_id'):
926 context['default_team_id'] = vals.get('team_id')
927 if vals.get('user_id'):
928 vals['date_open'] = fields.datetime.now()
930 # context: no_log, because subtype already handle this
931 create_context = dict(context, mail_create_nolog=True)
932 return super(crm_lead, self).create(cr, uid, vals, context=create_context)
934 def write(self, cr, uid, ids, vals, context=None):
935 # stage change: update date_last_stage_update
936 if 'stage_id' in vals:
937 vals['date_last_stage_update'] = fields.datetime.now()
938 if vals.get('user_id'):
939 vals['date_open'] = fields.datetime.now()
940 # stage change with new stage: update probability and date_closed
941 if vals.get('stage_id') and not vals.get('probability'):
942 onchange_stage_values = self.onchange_stage_id(cr, uid, ids, vals.get('stage_id'), context=context)['value']
943 vals.update(onchange_stage_values)
944 return super(crm_lead, self).write(cr, uid, ids, vals, context=context)
946 def copy(self, cr, uid, id, default=None, context=None):
951 lead = self.browse(cr, uid, id, context=context)
952 local_context = dict(context)
953 local_context.setdefault('default_type', lead.type)
954 local_context.setdefault('default_team_id', lead.team_id.id)
955 if lead.type == 'opportunity':
956 default['date_open'] = fields.datetime.now()
958 default['date_open'] = False
959 return super(crm_lead, self).copy(cr, uid, id, default, context=local_context)
961 def get_empty_list_help(self, cr, uid, help, context=None):
962 context = dict(context or {})
963 context['empty_list_help_model'] = 'crm.team'
964 context['empty_list_help_id'] = context.get('default_team_id', None)
965 context['empty_list_help_document_name'] = _("opportunity")
966 if context.get('default_type') == 'lead':
967 context['empty_list_help_document_name'] = _("lead")
968 return super(crm_lead, self).get_empty_list_help(cr, uid, help, context=context)
970 # ----------------------------------------
972 # ----------------------------------------
974 def message_get_reply_to(self, cr, uid, ids, context=None):
975 """ Override to get the reply_to of the parent project. """
976 leads = self.browse(cr, SUPERUSER_ID, ids, context=context)
977 team_ids = set([lead.team_id.id for lead in leads if lead.team_id])
978 aliases = self.pool['crm.team'].message_get_reply_to(cr, uid, list(team_ids), context=context)
979 return dict((lead.id, aliases.get(lead.team_id and lead.team_id.id or 0, False)) for lead in leads)
981 def get_formview_id(self, cr, uid, id, context=None):
982 obj = self.browse(cr, uid, id, context=context)
983 if obj.type == 'opportunity':
984 model, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
986 view_id = super(crm_lead, self).get_formview_id(cr, uid, id, context=context)
989 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
990 recipients = super(crm_lead, self).message_get_suggested_recipients(cr, uid, ids, context=context)
992 for lead in self.browse(cr, uid, ids, context=context):
994 self._message_add_suggested_recipient(cr, uid, recipients, lead, partner=lead.partner_id, reason=_('Customer'))
995 elif lead.email_from:
996 self._message_add_suggested_recipient(cr, uid, recipients, lead, email=lead.email_from, reason=_('Customer Email'))
997 except (osv.except_osv, orm.except_orm): # no read access rights -> just ignore suggested recipients because this imply modifying followers
1001 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1002 """ Overrides mail_thread message_new that is called by the mailgateway
1003 through message_process.
1004 This override updates the document according to the email.
1006 if custom_values is None:
1009 'name': msg.get('subject') or _("No Subject"),
1010 'email_from': msg.get('from'),
1011 'email_cc': msg.get('cc'),
1012 'partner_id': msg.get('author_id', False),
1015 if msg.get('author_id'):
1016 defaults.update(self.on_change_partner_id(cr, uid, None, msg.get('author_id'), context=context)['value'])
1017 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
1018 defaults['priority'] = msg.get('priority')
1019 defaults.update(custom_values)
1020 return super(crm_lead, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1022 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1023 """ Overrides mail_thread message_update that is called by the mailgateway
1024 through message_process.
1025 This method updates the document according to the email.
1027 if isinstance(ids, (str, int, long)):
1029 if update_vals is None: update_vals = {}
1031 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
1032 update_vals['priority'] = msg.get('priority')
1034 'cost':'planned_cost',
1035 'revenue': 'planned_revenue',
1036 'probability':'probability',
1038 for line in msg.get('body', '').split('\n'):
1040 res = tools.command_re.match(line)
1041 if res and maps.get(res.group(1).lower()):
1042 key = maps.get(res.group(1).lower())
1043 update_vals[key] = res.group(2).lower()
1045 return super(crm_lead, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1047 # ----------------------------------------
1048 # OpenChatter methods and notifications
1049 # ----------------------------------------
1051 def schedule_phonecall_send_note(self, cr, uid, ids, phonecall_id, action, context=None):
1052 phonecall = self.pool.get('crm.phonecall').browse(cr, uid, [phonecall_id], context=context)[0]
1054 message = _('Logged a call for %(date)s. %(description)s')
1056 message = _('Scheduled a call for %(date)s. %(description)s')
1057 phonecall_date = datetime.strptime(phonecall.date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
1058 phonecall_usertime = fields.datetime.context_timestamp(cr, uid, phonecall_date, context=context).strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1059 html_time = "<time datetime='%s+00:00'>%s</time>" % (phonecall.date, phonecall_usertime)
1060 message = message % dict(date=html_time, description=phonecall.description)
1061 return self.message_post(cr, uid, ids, body=message, context=context)
1063 def log_meeting(self, cr, uid, ids, meeting_subject, meeting_date, duration, context=None):
1065 duration = _('unknown')
1067 duration = str(duration)
1068 message = _("Meeting scheduled at '%s'<br> Subject: %s <br> Duration: %s hour(s)") % (meeting_date, meeting_subject, duration)
1069 return self.message_post(cr, uid, ids, body=message, context=context)
1071 def onchange_state(self, cr, uid, ids, state_id, context=None):
1073 country_id=self.pool.get('res.country.state').browse(cr, uid, state_id, context).country_id.id
1074 return {'value':{'country_id':country_id}}
1077 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1078 res = super(crm_lead, self).message_partner_info_from_emails(cr, uid, id, emails, link_mail=link_mail, context=context)
1079 lead = self.browse(cr, uid, id, context=context)
1080 for partner_info in res:
1081 if not partner_info.get('partner_id') and (lead.partner_name or lead.contact_name):
1082 emails = email_re.findall(partner_info['full_name'] or '')
1083 email = emails and emails[0] or ''
1084 if email and lead.email_from and email.lower() == lead.email_from.lower():
1085 partner_info['full_name'] = '%s <%s>' % (lead.partner_name or lead.contact_name, email)
1090 class crm_lead_tag(osv.Model):
1091 _name = "crm.lead.tag"
1092 _description = "Category of lead"
1094 'name': fields.char('Name', required=True, translate=True),
1095 'team_id': fields.many2one('crm.team', 'Sales Team'),
1099 class crm_lost_reason(osv.Model):
1100 _name = "crm.lost.reason"
1101 _description = 'Reason for loosing leads'
1104 'name': fields.char('Name', required=True),