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'),
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 'team_id': lambda s, cr, uid, c: s._get_default_team_id(cr, uid, context=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[0][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.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 team_id or restrict team id
326 to the ones user_id is member of. """
327 team_id = self._get_default_team_id(cr, uid, context=context)
328 if user_id and not team_id:
329 team_ids = self.pool.get('crm.team').search(cr, uid, ['|', ('user_id', '=', user_id), ('member_ids', '=', user_id)], context=context)
331 team_id = team_ids[0]
332 return {'value': {'team_id': team_id}}
334 def stage_find(self, cr, uid, cases, team_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 - team_id: if set, stages must belong to this team 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 team_ids
351 if not cases and context.get('default_type'):
352 ctx_type = context.get('default_type')
355 team_ids.add(team_id)
358 team_ids.add(lead.team_id.id)
359 if lead.type not in types:
360 types.append(lead.type)
361 # OR all team_ids and OR with case_default
364 search_domain += [('|')] * len(team_ids)
365 for team_id in team_ids:
366 search_domain.append(('team_ids', '=', team_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.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.team_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.team_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.team_id.parent_id:
424 data['team_id'] = case.team_id.parent_id.id
425 if case.team_id.parent_id.change_responsible:
426 if case.team_id.parent_id.user_id:
427 data['user_id'] = case.team_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 = self._fields.get(field_name)
486 if field.type in ('many2many', 'one2many'):
488 elif field.type == 'many2one':
489 data[field_name] = _get_first_not_null_id(field_name) # !!
490 elif field.type == 'text':
491 data[field_name] = _concat_all(field_name) #not lost
493 data[field_name] = _get_first_not_null(field_name) #not lost
495 # Define the resulting type ('lead' or 'opportunity')
496 data['type'] = self._merge_get_result_type(cr, uid, opportunities, context)
499 def _mail_body(self, cr, uid, lead, fields, title=False, context=None):
502 body.append("%s\n" % (title))
504 for field_name in fields:
505 field = self._fields.get(field_name)
510 if field.type == 'selection':
511 if callable(field.selection):
512 key = field.selection(self, cr, uid, context=context)
514 key = field.selection
515 value = dict(key).get(lead[field_name], lead[field_name])
516 elif field.type == 'many2one':
518 value = lead[field_name].name_get()[0][1]
519 elif field.type == 'many2many':
521 for val in lead[field_name]:
522 field_value = val.name_get()[0][1]
523 value += field_value + ","
525 value = lead[field_name]
527 body.append("%s: %s" % (field.string, value or ''))
528 return "<br/>".join(body + ['<br/>'])
530 def _merge_notify(self, cr, uid, opportunity_id, opportunities, context=None):
532 Create a message gathering merged leads/opps information.
534 #TOFIX: mail template should be used instead of fix body, subject text
536 result_type = self._merge_get_result_type(cr, uid, opportunities, context)
537 if result_type == 'lead':
538 merge_message = _('Merged leads')
540 merge_message = _('Merged opportunities')
541 subject = [merge_message]
542 for opportunity in opportunities:
543 subject.append(opportunity.name)
544 title = "%s : %s" % (opportunity.type == 'opportunity' and _('Merged opportunity') or _('Merged lead'), opportunity.name)
545 fields = list(CRM_LEAD_FIELDS_TO_MERGE)
546 details.append(self._mail_body(cr, uid, opportunity, fields, title=title, context=context))
548 # Chatter message's subject
549 subject = subject[0] + ": " + ", ".join(subject[1:])
550 details = "\n\n".join(details)
551 return self.message_post(cr, uid, [opportunity_id], body=details, subject=subject, context=context)
553 def _merge_opportunity_history(self, cr, uid, opportunity_id, opportunities, context=None):
554 message = self.pool.get('mail.message')
555 for opportunity in opportunities:
556 for history in opportunity.message_ids:
557 message.write(cr, uid, history.id, {
558 'res_id': opportunity_id,
559 'subject' : _("From %s : %s") % (opportunity.name, history.subject)
564 def _merge_opportunity_attachments(self, cr, uid, opportunity_id, opportunities, context=None):
565 attach_obj = self.pool.get('ir.attachment')
567 # return attachments of opportunity
568 def _get_attachments(opportunity_id):
569 attachment_ids = attach_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', opportunity_id)], context=context)
570 return attach_obj.browse(cr, uid, attachment_ids, context=context)
572 first_attachments = _get_attachments(opportunity_id)
573 #counter of all attachments to move. Used to make sure the name is different for all attachments
575 for opportunity in opportunities:
576 attachments = _get_attachments(opportunity.id)
577 for attachment in attachments:
578 values = {'res_id': opportunity_id,}
579 for attachment_in_first in first_attachments:
580 if attachment.name == attachment_in_first.name:
581 values['name'] = "%s (%s)" % (attachment.name, count,),
583 attachment.write(values)
586 def _merge_opportunity_phonecalls(self, cr, uid, opportunity_id, opportunities, context=None):
587 phonecall_obj = self.pool['crm.phonecall']
588 for opportunity in opportunities:
589 for phonecall_id in phonecall_obj.search(cr, uid, [('opportunity_id', '=', opportunity.id)], context=context):
590 phonecall_obj.write(cr, uid, phonecall_id, {'opportunity_id': opportunity_id}, context=context)
593 def get_duplicated_leads(self, cr, uid, ids, partner_id, include_lost=False, context=None):
595 Search for opportunities that have the same partner and that arent done or cancelled
597 lead = self.browse(cr, uid, ids[0], context=context)
598 email = lead.partner_id and lead.partner_id.email or lead.email_from
599 return self.pool['crm.lead']._get_duplicated_leads_by_emails(cr, uid, partner_id, email, include_lost=include_lost, context=context)
601 def _get_duplicated_leads_by_emails(self, cr, uid, partner_id, email, include_lost=False, context=None):
603 Search for opportunities that have the same partner and that arent done or cancelled
605 final_stage_domain = [('stage_id.probability', '<', 100), '|', ('stage_id.probability', '>', 0), ('stage_id.sequence', '<=', 1)]
606 partner_match_domain = []
607 for email in set(email_split(email) + [email]):
608 partner_match_domain.append(('email_from', '=ilike', email))
610 partner_match_domain.append(('partner_id', '=', partner_id))
611 partner_match_domain = ['|'] * (len(partner_match_domain) - 1) + partner_match_domain
612 if not partner_match_domain:
614 domain = partner_match_domain
616 domain += final_stage_domain
617 return self.search(cr, uid, domain, context=context)
619 def merge_dependences(self, cr, uid, highest, opportunities, context=None):
620 self._merge_notify(cr, uid, highest, opportunities, context=context)
621 self._merge_opportunity_history(cr, uid, highest, opportunities, context=context)
622 self._merge_opportunity_attachments(cr, uid, highest, opportunities, context=context)
623 self._merge_opportunity_phonecalls(cr, uid, highest, opportunities, context=context)
625 def merge_opportunity(self, cr, uid, ids, user_id=False, team_id=False, context=None):
627 Different cases of merge:
628 - merge leads together = 1 new lead
629 - merge at least 1 opp with anything else (lead or opp) = 1 new opp
631 :param list ids: leads/opportunities ids to merge
632 :return int id: id of the resulting lead/opp
638 raise osv.except_osv(_('Warning!'), _('Please select more than one element (lead or opportunity) from the list view.'))
640 opportunities = self.browse(cr, uid, ids, context=context)
642 # Sorting the leads/opps according to the confidence level of its stage, which relates to the probability of winning it
643 # The confidence level increases with the stage sequence, except when the stage probability is 0.0 (Lost cases)
644 # An Opportunity always has higher confidence level than a lead, unless its stage probability is 0.0
645 for opportunity in opportunities:
647 if opportunity.stage_id and not opportunity.stage_id.fold:
648 sequence = opportunity.stage_id.sequence
649 sequenced_opps.append(((int(sequence != -1 and opportunity.type == 'opportunity'), sequence, -opportunity.id), opportunity))
651 sequenced_opps.sort(reverse=True)
652 opportunities = map(itemgetter(1), sequenced_opps)
653 ids = [opportunity.id for opportunity in opportunities]
654 highest = opportunities[0]
655 opportunities_rest = opportunities[1:]
657 tail_opportunities = opportunities_rest
659 fields = list(CRM_LEAD_FIELDS_TO_MERGE)
660 merged_data = self._merge_data(cr, uid, ids, highest, fields, context=context)
663 merged_data['user_id'] = user_id
665 merged_data['team_id'] = team_id
667 # Merge notifications about loss of information
668 opportunities = [highest]
669 opportunities.extend(opportunities_rest)
671 self.merge_dependences(cr, uid, highest.id, tail_opportunities, context=context)
673 # Check if the stage is in the stages of the sales team. If not, assign the stage with the lowest sequence
674 if merged_data.get('team_id'):
675 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)
676 if merged_data.get('stage_id') not in team_stage_ids:
677 merged_data['stage_id'] = team_stage_ids and team_stage_ids[0] or False
678 # Write merged data into first opportunity
679 self.write(cr, uid, [highest.id], merged_data, context=context)
680 # Delete tail opportunities
681 # 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
682 self.unlink(cr, SUPERUSER_ID, [x.id for x in tail_opportunities], context=context)
686 def _convert_opportunity_data(self, cr, uid, lead, customer, team_id=False, context=None):
687 crm_stage = self.pool.get('crm.stage')
690 contact_id = self.pool.get('res.partner').address_get(cr, uid, [customer.id])['default']
692 team_id = lead.team_id and lead.team_id.id or False
694 'planned_revenue': lead.planned_revenue,
695 'probability': lead.probability,
697 'partner_id': customer and customer.id or False,
698 'type': 'opportunity',
699 'date_action': fields.datetime.now(),
700 'date_open': fields.datetime.now(),
701 'email_from': customer and customer.email or lead.email_from,
702 'phone': customer and customer.phone or lead.phone,
704 if not lead.stage_id or lead.stage_id.type=='lead':
705 val['stage_id'] = self.stage_find(cr, uid, [lead], team_id, [('type', 'in', ('opportunity', 'both'))], context=context)
708 def convert_opportunity(self, cr, uid, ids, partner_id, user_ids=False, team_id=False, context=None):
711 partner = self.pool.get('res.partner')
712 customer = partner.browse(cr, uid, partner_id, context=context)
713 for lead in self.browse(cr, uid, ids, context=context):
714 # TDE: was if lead.state in ('done', 'cancel'):
715 if lead.probability == 100 or (lead.probability == 0 and lead.stage_id.fold):
717 vals = self._convert_opportunity_data(cr, uid, lead, customer, team_id, context=context)
718 self.write(cr, uid, [lead.id], vals, context=context)
720 if user_ids or team_id:
721 self.allocate_salesman(cr, uid, ids, user_ids, team_id, context=context)
725 def _lead_create_contact(self, cr, uid, lead, name, is_company, parent_id=False, context=None):
726 partner = self.pool.get('res.partner')
727 vals = {'name': name,
728 'user_id': lead.user_id.id,
729 'comment': lead.description,
730 'team_id': lead.team_id.id or False,
731 'parent_id': parent_id,
733 'mobile': lead.mobile,
734 'email': tools.email_split(lead.email_from) and tools.email_split(lead.email_from)[0] or False,
736 'title': lead.title and lead.title.id or False,
737 'function': lead.function,
738 'street': lead.street,
739 'street2': lead.street2,
742 'country_id': lead.country_id and lead.country_id.id or False,
743 'state_id': lead.state_id and lead.state_id.id or False,
744 'is_company': is_company,
747 partner = partner.create(cr, uid, vals, context=context)
750 def _create_lead_partner(self, cr, uid, lead, context=None):
752 if lead.partner_name and lead.contact_name:
753 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
754 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, partner_id, context=context)
755 elif lead.partner_name and not lead.contact_name:
756 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
757 elif not lead.partner_name and lead.contact_name:
758 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, context=context)
759 elif lead.email_from and self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]:
760 contact_name = self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]
761 partner_id = self._lead_create_contact(cr, uid, lead, contact_name, False, context=context)
763 raise osv.except_osv(
765 _('No customer name defined. Please fill one of the following fields: Company Name, Contact Name or Email ("Name <email@address>")')
769 def handle_partner_assignation(self, cr, uid, ids, action='create', partner_id=False, context=None):
771 Handle partner assignation during a lead conversion.
772 if action is 'create', create new partner with contact and assign lead to new partner_id.
773 otherwise assign lead to the specified partner_id
775 :param list ids: leads/opportunities ids to process
776 :param string action: what has to be done regarding partners (create it, assign an existing one, or nothing)
777 :param int partner_id: partner to assign if any
778 :return dict: dictionary organized as followed: {lead_id: partner_assigned_id}
780 #TODO this is a duplication of the handle_partner_assignation method of crm_phonecall
782 for lead in self.browse(cr, uid, ids, context=context):
783 # If the action is set to 'create' and no partner_id is set, create a new one
785 partner_ids[lead.id] = lead.partner_id.id
787 if not partner_id and action == 'create':
788 partner_id = self._create_lead_partner(cr, uid, lead, context)
789 self.pool['res.partner'].write(cr, uid, partner_id, {'team_id': lead.team_id and lead.team_id.id or False})
791 lead.write({'partner_id': partner_id}, context=context)
792 partner_ids[lead.id] = partner_id
795 def allocate_salesman(self, cr, uid, ids, user_ids=None, team_id=False, context=None):
797 Assign salesmen and salesteam to a batch of leads. If there are more
798 leads than salesmen, these salesmen will be assigned in round-robin.
799 E.g.: 4 salesmen (S1, S2, S3, S4) for 6 leads (L1, L2, ... L6). They
800 will be assigned as followed: L1 - S1, L2 - S2, L3 - S3, L4 - S4,
803 :param list ids: leads/opportunities ids to process
804 :param list user_ids: salesmen to assign
805 :param int team_id: salesteam to assign
813 value['team_id'] = team_id
815 value['user_id'] = user_ids[index]
816 # Cycle through user_ids
817 index = (index + 1) % len(user_ids)
819 self.write(cr, uid, [lead_id], value, context=context)
822 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):
824 :param string action: ('schedule','Schedule a call'), ('log','Log a call')
826 phonecall = self.pool.get('crm.phonecall')
827 model_data = self.pool.get('ir.model.data')
831 res_id = model_data._get_id(cr, uid, 'crm', 'categ_phone2')
832 categ_id = model_data.browse(cr, uid, res_id, context=context).res_id
835 for lead in self.browse(cr, uid, ids, context=context):
837 team_id = lead.team_id and lead.team_id.id or False
839 user_id = lead.user_id and lead.user_id.id or False
841 'name': call_summary,
842 'opportunity_id': lead.id,
843 'user_id': user_id or False,
844 'categ_id': categ_id or False,
845 'description': desc or '',
846 'date': schedule_time,
847 'team_id': team_id or False,
848 'partner_id': lead.partner_id and lead.partner_id.id or False,
849 'partner_phone': phone or lead.phone or (lead.partner_id and lead.partner_id.phone or False),
850 'partner_mobile': lead.partner_id and lead.partner_id.mobile or False,
851 'priority': lead.priority,
853 new_id = phonecall.create(cr, uid, vals, context=context)
854 phonecall.write(cr, uid, [new_id], {'state': 'open'}, context=context)
856 phonecall.write(cr, uid, [new_id], {'state': 'done'}, context=context)
857 phonecall_dict[lead.id] = new_id
858 self.schedule_phonecall_send_note(cr, uid, [lead.id], new_id, action, context=context)
859 return phonecall_dict
861 def redirect_opportunity_view(self, cr, uid, opportunity_id, context=None):
862 models_data = self.pool.get('ir.model.data')
864 # Get opportunity views
865 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
866 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_oppor')
868 'name': _('Opportunity'),
870 'view_mode': 'tree, form',
871 'res_model': 'crm.lead',
872 'domain': [('type', '=', 'opportunity')],
873 'res_id': int(opportunity_id),
875 'views': [(form_view or False, 'form'),
876 (tree_view or False, 'tree'), (False, 'kanban'),
877 (False, 'calendar'), (False, 'graph')],
878 'type': 'ir.actions.act_window',
881 def redirect_lead_view(self, cr, uid, lead_id, context=None):
882 models_data = self.pool.get('ir.model.data')
885 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_leads')
886 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_leads')
890 'view_mode': 'tree, form',
891 'res_model': 'crm.lead',
892 'domain': [('type', '=', 'lead')],
893 'res_id': int(lead_id),
895 'views': [(form_view or False, 'form'),
896 (tree_view or False, 'tree'),
897 (False, 'calendar'), (False, 'graph')],
898 'type': 'ir.actions.act_window',
901 def action_schedule_meeting(self, cr, uid, ids, context=None):
903 Open meeting's calendar view to schedule meeting on current opportunity.
904 :return dict: dictionary value for created Meeting view
906 lead = self.browse(cr, uid, ids[0], context)
907 res = self.pool.get('ir.actions.act_window').for_xml_id(cr, uid, 'calendar', 'action_calendar_event', context)
908 partner_ids = [self.pool['res.users'].browse(cr, uid, uid, context=context).partner_id.id]
910 partner_ids.append(lead.partner_id.id)
912 'default_opportunity_id': lead.type == 'opportunity' and lead.id or False,
913 'default_partner_id': lead.partner_id and lead.partner_id.id or False,
914 'default_partner_ids': partner_ids,
915 'default_team_id': lead.team_id and lead.team_id.id or False,
916 'default_name': lead.name,
920 def create(self, cr, uid, vals, context=None):
921 context = dict(context or {})
922 if vals.get('type') and not context.get('default_type'):
923 context['default_type'] = vals.get('type')
924 if vals.get('team_id') and not context.get('default_team_id'):
925 context['default_team_id'] = vals.get('team_id')
926 if vals.get('user_id'):
927 vals['date_open'] = fields.datetime.now()
929 # context: no_log, because subtype already handle this
930 create_context = dict(context, mail_create_nolog=True)
931 return super(crm_lead, self).create(cr, uid, vals, context=create_context)
933 def write(self, cr, uid, ids, vals, context=None):
934 # stage change: update date_last_stage_update
935 if 'stage_id' in vals:
936 vals['date_last_stage_update'] = fields.datetime.now()
937 if vals.get('user_id'):
938 vals['date_open'] = fields.datetime.now()
939 # stage change with new stage: update probability and date_closed
940 if vals.get('stage_id') and not vals.get('probability'):
941 onchange_stage_values = self.onchange_stage_id(cr, uid, ids, vals.get('stage_id'), context=context)['value']
942 vals.update(onchange_stage_values)
943 return super(crm_lead, self).write(cr, uid, ids, vals, context=context)
945 def copy(self, cr, uid, id, default=None, context=None):
950 lead = self.browse(cr, uid, id, context=context)
951 local_context = dict(context)
952 local_context.setdefault('default_type', lead.type)
953 local_context.setdefault('default_team_id', lead.team_id.id)
954 if lead.type == 'opportunity':
955 default['date_open'] = fields.datetime.now()
957 default['date_open'] = False
958 return super(crm_lead, self).copy(cr, uid, id, default, context=local_context)
960 def get_empty_list_help(self, cr, uid, help, context=None):
961 context = dict(context or {})
962 context['empty_list_help_model'] = 'crm.team'
963 context['empty_list_help_id'] = context.get('default_team_id', None)
964 context['empty_list_help_document_name'] = _("opportunity")
965 if context.get('default_type') == 'lead':
966 context['empty_list_help_document_name'] = _("lead")
967 return super(crm_lead, self).get_empty_list_help(cr, uid, help, context=context)
969 # ----------------------------------------
971 # ----------------------------------------
973 def message_get_reply_to(self, cr, uid, ids, context=None):
974 """ Override to get the reply_to of the parent project. """
975 leads = self.browse(cr, SUPERUSER_ID, ids, context=context)
976 team_ids = set([lead.team_id.id for lead in leads if lead.team_id])
977 aliases = self.pool['crm.team'].message_get_reply_to(cr, uid, list(team_ids), context=context)
978 return dict((lead.id, aliases.get(lead.team_id and lead.team_id.id or 0, False)) for lead in leads)
980 def get_formview_id(self, cr, uid, id, context=None):
981 obj = self.browse(cr, uid, id, context=context)
982 if obj.type == 'opportunity':
983 model, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
985 view_id = super(crm_lead, self).get_formview_id(cr, uid, id, context=context)
988 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
989 recipients = super(crm_lead, self).message_get_suggested_recipients(cr, uid, ids, context=context)
991 for lead in self.browse(cr, uid, ids, context=context):
993 self._message_add_suggested_recipient(cr, uid, recipients, lead, partner=lead.partner_id, reason=_('Customer'))
994 elif lead.email_from:
995 self._message_add_suggested_recipient(cr, uid, recipients, lead, email=lead.email_from, reason=_('Customer Email'))
996 except (osv.except_osv, orm.except_orm): # no read access rights -> just ignore suggested recipients because this imply modifying followers
1000 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1001 """ Overrides mail_thread message_new that is called by the mailgateway
1002 through message_process.
1003 This override updates the document according to the email.
1005 if custom_values is None:
1008 'name': msg.get('subject') or _("No Subject"),
1009 'email_from': msg.get('from'),
1010 'email_cc': msg.get('cc'),
1011 'partner_id': msg.get('author_id', False),
1014 if msg.get('author_id'):
1015 defaults.update(self.on_change_partner_id(cr, uid, None, msg.get('author_id'), context=context)['value'])
1016 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
1017 defaults['priority'] = msg.get('priority')
1018 defaults.update(custom_values)
1019 return super(crm_lead, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1021 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1022 """ Overrides mail_thread message_update that is called by the mailgateway
1023 through message_process.
1024 This method updates the document according to the email.
1026 if isinstance(ids, (str, int, long)):
1028 if update_vals is None: update_vals = {}
1030 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
1031 update_vals['priority'] = msg.get('priority')
1033 'cost':'planned_cost',
1034 'revenue': 'planned_revenue',
1035 'probability':'probability',
1037 for line in msg.get('body', '').split('\n'):
1039 res = tools.command_re.match(line)
1040 if res and maps.get(res.group(1).lower()):
1041 key = maps.get(res.group(1).lower())
1042 update_vals[key] = res.group(2).lower()
1044 return super(crm_lead, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1046 # ----------------------------------------
1047 # OpenChatter methods and notifications
1048 # ----------------------------------------
1050 def schedule_phonecall_send_note(self, cr, uid, ids, phonecall_id, action, context=None):
1051 phonecall = self.pool.get('crm.phonecall').browse(cr, uid, [phonecall_id], context=context)[0]
1053 message = _('Logged a call for %(date)s. %(description)s')
1055 message = _('Scheduled a call for %(date)s. %(description)s')
1056 phonecall_date = datetime.strptime(phonecall.date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
1057 phonecall_usertime = fields.datetime.context_timestamp(cr, uid, phonecall_date, context=context).strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1058 html_time = "<time datetime='%s+00:00'>%s</time>" % (phonecall.date, phonecall_usertime)
1059 message = message % dict(date=html_time, description=phonecall.description)
1060 return self.message_post(cr, uid, ids, body=message, context=context)
1062 def log_meeting(self, cr, uid, ids, meeting_subject, meeting_date, duration, context=None):
1064 duration = _('unknown')
1066 duration = str(duration)
1067 message = _("Meeting scheduled at '%s'<br> Subject: %s <br> Duration: %s hour(s)") % (meeting_date, meeting_subject, duration)
1068 return self.message_post(cr, uid, ids, body=message, context=context)
1070 def onchange_state(self, cr, uid, ids, state_id, context=None):
1072 country_id=self.pool.get('res.country.state').browse(cr, uid, state_id, context).country_id.id
1073 return {'value':{'country_id':country_id}}
1076 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1077 res = super(crm_lead, self).message_partner_info_from_emails(cr, uid, id, emails, link_mail=link_mail, context=context)
1078 lead = self.browse(cr, uid, id, context=context)
1079 for partner_info in res:
1080 if not partner_info.get('partner_id') and (lead.partner_name or lead.contact_name):
1081 emails = email_re.findall(partner_info['full_name'] or '')
1082 email = emails and emails[0] or ''
1083 if email and lead.email_from and email.lower() == lead.email_from.lower():
1084 partner_info['full_name'] = '%s <%s>' % (lead.partner_name or lead.contact_name, email)
1088 class crm_lead_tag(osv.Model):
1089 _name = "crm.lead.tag"
1090 _description = "Category of lead"
1092 'name': fields.char('Name', required=True, translate=True),
1093 'team_id': fields.many2one('crm.team', 'Sales Team'),
1096 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: