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.case.section'
90 context['empty_list_help_id'] = context.get('default_section_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_section_id(self, cr, uid, user_id=False, context=None):
95 """ Gives default section by checking if present in the context """
96 section_id = self._resolve_section_id_from_context(cr, uid, context=context) or False
98 section_id = self.pool.get('res.users').browse(cr, uid, user_id or uid, context).default_section_id.id or False
101 def _get_default_stage_id(self, cr, uid, context=None):
102 """ Gives default stage_id """
103 section_id = self._get_default_section_id(cr, uid, context=context)
104 return self.stage_find(cr, uid, [], section_id, [('fold', '=', False)], context=context)
106 def _resolve_section_id_from_context(self, cr, uid, context=None):
107 """ Returns ID of section based on the value of 'section_id'
108 context key, or None if it cannot be resolved to a single
113 if type(context.get('default_section_id')) in (int, long):
114 return context.get('default_section_id')
115 if isinstance(context.get('default_section_id'), basestring):
116 section_ids = self.pool.get('crm.case.section').name_search(cr, uid, name=context['default_section_id'], context=context)
117 if len(section_ids) == 1:
118 return int(section_ids[0][0])
121 def _resolve_type_from_context(self, cr, uid, context=None):
122 """ Returns the type (lead or opportunity) from the type context
123 key. Returns None if it cannot be resolved.
127 return context.get('default_type')
129 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
130 access_rights_uid = access_rights_uid or uid
131 stage_obj = self.pool.get('crm.case.stage')
132 order = stage_obj._order
133 # lame hack to allow reverting search, should just work in the trivial case
134 if read_group_order == 'stage_id desc':
135 order = "%s desc" % order
136 # retrieve section_id from the context and write the domain
137 # - ('id', 'in', 'ids'): add columns that should be present
138 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
139 # - OR ('section_ids', '=', section_id), ('fold', '=', False) if section_id: add section columns that are not folded
141 section_id = self._resolve_section_id_from_context(cr, uid, context=context)
143 search_domain += ['|', ('section_ids', '=', section_id)]
144 search_domain += [('id', 'in', ids)]
146 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
147 # retrieve type from the context (if set: choose 'type' or 'both')
148 type = self._resolve_type_from_context(cr, uid, context=context)
150 search_domain += ['|', ('type', '=', type), ('type', '=', 'both')]
152 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
153 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
154 # restore order of the search
155 result.sort(lambda x, y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
158 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
159 fold[stage.id] = stage.fold or False
162 def fields_view_get(self, cr, user, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
163 if context and context.get('opportunity_id'):
164 action = self._get_formview_action(cr, user, context['opportunity_id'], context=context)
165 if action.get('views') and any(view_id for view_id in action['views'] if view_id[1] == view_type):
166 view_id = next(view_id[0] for view_id in action['views'] if view_id[1] == view_type)
167 res = super(crm_lead, self).fields_view_get(cr, user, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
168 if view_type == 'form':
169 res['arch'] = self.fields_view_get_address(cr, user, res['arch'], context=context)
173 'stage_id': _read_group_stage_ids
176 def _compute_day(self, cr, uid, ids, fields, args, context=None):
178 :return dict: difference between current date and log date
181 for lead in self.browse(cr, uid, ids, context=context):
186 if field == 'day_open':
188 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
189 date_open = datetime.strptime(lead.date_open, "%Y-%m-%d %H:%M:%S")
190 ans = date_open - date_create
191 elif field == 'day_close':
193 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
194 date_close = datetime.strptime(lead.date_closed, "%Y-%m-%d %H:%M:%S")
195 ans = date_close - date_create
197 duration = abs(int(ans.days))
198 res[lead.id][field] = duration
200 def _meeting_count(self, cr, uid, ids, field_name, arg, context=None):
201 Event = self.pool['calendar.event']
203 opp_id: Event.search_count(cr,uid, [('opportunity_id', '=', opp_id)], context=context)
207 'partner_id': fields.many2one('res.partner', 'Partner', ondelete='set null', track_visibility='onchange',
208 select=True, help="Linked partner (optional). Usually created when converting the lead."),
210 'id': fields.integer('ID', readonly=True),
211 'name': fields.char('Subject', required=True, select=1),
212 'active': fields.boolean('Active', required=False),
213 'date_action_last': fields.datetime('Last Action', readonly=1),
214 'date_action_next': fields.datetime('Next Action', readonly=1),
215 'email_from': fields.char('Email', size=128, help="Email address of the contact", select=1),
216 'section_id': fields.many2one('crm.case.section', 'Sales Team',
217 select=True, track_visibility='onchange', help='When sending mails, the default email address is taken from the sales team.'),
218 'create_date': fields.datetime('Creation Date', readonly=True),
219 '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"),
220 'description': fields.text('Notes'),
221 'write_date': fields.datetime('Update Date', readonly=True),
222 'categ_ids': fields.many2many('crm.case.categ', 'crm_lead_category_rel', 'lead_id', 'category_id', 'Tags', \
223 domain="['|', ('section_id', '=', section_id), ('section_id', '=', False), ('object_id.model', '=', 'crm.lead')]", help="Classify and analyze your lead/opportunity categories like: Training, Service"),
224 'contact_name': fields.char('Contact Name', size=64),
225 '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),
226 'opt_out': fields.boolean('Opt-Out', oldname='optout',
227 help="If opt-out is checked, this contact has refused to receive emails for mass mailing and marketing campaign. "
228 "Filter 'Available for Mass Mailing' allows users to filter the leads when performing mass mailing."),
229 'type': fields.selection([ ('lead','Lead'), ('opportunity','Opportunity'), ],'Type', select=True, help="Type is used to separate Leads and Opportunities"),
230 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
231 'date_closed': fields.datetime('Closed', readonly=True, copy=False),
232 'stage_id': fields.many2one('crm.case.stage', 'Stage', track_visibility='onchange', select=True,
233 domain="['&', ('section_ids', '=', section_id), '|', ('type', '=', type), ('type', '=', 'both')]"),
234 'user_id': fields.many2one('res.users', 'Salesperson', select=True, track_visibility='onchange'),
235 'referred': fields.char('Referred By'),
236 'date_open': fields.datetime('Assigned', readonly=True),
237 'day_open': fields.function(_compute_day, string='Days to Assign',
238 multi='day_open', type="float",
239 store={'crm.lead': (lambda self, cr, uid, ids, c={}: ids, ['date_open'], 10)}),
240 'day_close': fields.function(_compute_day, string='Days to Close',
241 multi='day_open', type="float",
242 store={'crm.lead': (lambda self, cr, uid, ids, c={}: ids, ['date_closed'], 10)}),
243 'date_last_stage_update': fields.datetime('Last Stage Update', select=True),
245 # Messaging and marketing
246 'message_bounce': fields.integer('Bounce'),
247 # Only used for type opportunity
248 'probability': fields.float('Success Rate (%)', group_operator="avg"),
249 'planned_revenue': fields.float('Expected Revenue', track_visibility='always'),
250 'ref': fields.reference('Reference', selection=openerp.addons.base.res.res_request.referencable_models),
251 'ref2': fields.reference('Reference 2', selection=openerp.addons.base.res.res_request.referencable_models),
252 'phone': fields.char("Phone", size=64),
253 'date_deadline': fields.date('Expected Closing', help="Estimate of the date on which the opportunity will be won."),
254 'date_action': fields.date('Next Action Date', select=True),
255 'title_action': fields.char('Next Action'),
256 'color': fields.integer('Color Index'),
257 'partner_address_name': fields.related('partner_id', 'name', type='char', string='Partner Contact Name', readonly=True),
258 'partner_address_email': fields.related('partner_id', 'email', type='char', string='Partner Contact Email', readonly=True),
259 'company_currency': fields.related('company_id', 'currency_id', type='many2one', string='Currency', readonly=True, relation="res.currency"),
260 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
261 'user_login': fields.related('user_id', 'login', type='char', string='User Login', readonly=True),
263 # Fields for address, due to separation from crm and res.partner
264 'street': fields.char('Street'),
265 'street2': fields.char('Street2'),
266 'zip': fields.char('Zip', change_default=True, size=24),
267 'city': fields.char('City'),
268 'state_id': fields.many2one("res.country.state", 'State'),
269 'country_id': fields.many2one('res.country', 'Country'),
270 'phone': fields.char('Phone'),
271 'fax': fields.char('Fax'),
272 'mobile': fields.char('Mobile'),
273 'function': fields.char('Function'),
274 'title': fields.many2one('res.partner.title', 'Title'),
275 'company_id': fields.many2one('res.company', 'Company', select=1),
276 'payment_mode': fields.many2one('crm.payment.mode', 'Payment Mode', \
277 domain="[('section_id','=',section_id)]"),
278 'planned_cost': fields.float('Planned Costs'),
279 'meeting_count': fields.function(_meeting_count, string='# Meetings', type='integer'),
285 'user_id': lambda s, cr, uid, c: uid,
286 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
287 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, context=c),
288 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.lead', context=c),
289 'priority': lambda *a: crm.AVAILABLE_PRIORITIES[2][0],
291 'date_last_stage_update': fields.datetime.now,
295 ('check_probability', 'check(probability >= 0 and probability <= 100)', 'The probability of closing the deal should be between 0% and 100%!')
298 def onchange_stage_id(self, cr, uid, ids, stage_id, context=None):
301 stage = self.pool.get('crm.case.stage').browse(cr, uid, stage_id, context=context)
302 if not stage.on_change:
304 vals = {'probability': stage.probability}
305 if stage.probability >= 100 or (stage.probability == 0 and stage.sequence > 1):
306 vals['date_closed'] = fields.datetime.now()
307 return {'value': vals}
309 def on_change_partner_id(self, cr, uid, ids, partner_id, context=None):
312 partner = self.pool.get('res.partner').browse(cr, uid, partner_id, context=context)
314 'partner_name': partner.parent_id.name if partner.parent_id else partner.name,
315 'contact_name': partner.name if partner.parent_id else False,
316 'street': partner.street,
317 'street2': partner.street2,
318 'city': partner.city,
319 'state_id': partner.state_id and partner.state_id.id or False,
320 'country_id': partner.country_id and partner.country_id.id or False,
321 'email_from': partner.email,
322 'phone': partner.phone,
323 'mobile': partner.mobile,
327 return {'value': values}
329 def on_change_user(self, cr, uid, ids, user_id, context=None):
330 """ When changing the user, also set a section_id or restrict section id
331 to the ones user_id is member of. """
332 section_id = self._get_default_section_id(cr, uid, user_id=user_id, context=context) or False
333 if user_id and not section_id:
334 section_ids = self.pool.get('crm.case.section').search(cr, uid, ['|', ('user_id', '=', user_id), ('member_ids', '=', user_id)], context=context)
336 section_id = section_ids[0]
337 return {'value': {'section_id': section_id}}
339 def stage_find(self, cr, uid, cases, section_id, domain=None, order='sequence', context=None):
340 """ Override of the base.stage method
341 Parameter of the stage search taken from the lead:
342 - type: stage type must be the same or 'both'
343 - section_id: if set, stages must belong to this section or
344 be a default stage; if not set, stages must be default
347 if isinstance(cases, (int, long)):
348 cases = self.browse(cr, uid, cases, context=context)
351 # check whether we should try to add a condition on type
352 avoid_add_type_term = any([term for term in domain if len(term) == 3 if term[0] == 'type'])
353 # collect all section_ids
356 if not cases and context.get('default_type'):
357 ctx_type = context.get('default_type')
360 section_ids.add(section_id)
363 section_ids.add(lead.section_id.id)
364 if lead.type not in types:
365 types.append(lead.type)
366 # OR all section_ids and OR with case_default
369 search_domain += [('|')] * len(section_ids)
370 for section_id in section_ids:
371 search_domain.append(('section_ids', '=', section_id))
372 search_domain.append(('case_default', '=', True))
373 # AND with cases types
374 if not avoid_add_type_term:
375 search_domain.append(('type', 'in', types))
376 # AND with the domain in parameter
377 search_domain += list(domain)
378 # perform search, return the first found
379 stage_ids = self.pool.get('crm.case.stage').search(cr, uid, search_domain, order=order, limit=1, context=context)
384 def case_mark_lost(self, cr, uid, ids, context=None):
385 """ Mark the case as lost: state=cancel and probability=0
388 for lead in self.browse(cr, uid, ids, context=context):
389 stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 0.0), ('fold', '=', True), ('sequence', '>', 1)], context=context)
391 if stages_leads.get(stage_id):
392 stages_leads[stage_id].append(lead.id)
394 stages_leads[stage_id] = [lead.id]
396 raise osv.except_osv(_('Warning!'),
397 _('To relieve your sales pipe and group all Lost opportunities, configure one of your sales stage as follow:\n'
398 'probability = 0 %, select "Change Probability Automatically".\n'
399 'Create a specific stage or edit an existing one by editing columns of your opportunity pipe.'))
400 for stage_id, lead_ids in stages_leads.items():
401 self.write(cr, uid, lead_ids, {'stage_id': stage_id}, context=context)
404 def case_mark_won(self, cr, uid, ids, context=None):
405 """ Mark the case as won: state=done and probability=100
408 for lead in self.browse(cr, uid, ids, context=context):
409 stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 100.0), ('fold', '=', True)], context=context)
411 if stages_leads.get(stage_id):
412 stages_leads[stage_id].append(lead.id)
414 stages_leads[stage_id] = [lead.id]
416 raise osv.except_osv(_('Warning!'),
417 _('To relieve your sales pipe and group all Won opportunities, configure one of your sales stage as follow:\n'
418 'probability = 100 % and select "Change Probability Automatically".\n'
419 'Create a specific stage or edit an existing one by editing columns of your opportunity pipe.'))
420 for stage_id, lead_ids in stages_leads.items():
421 self.write(cr, uid, lead_ids, {'stage_id': stage_id}, context=context)
424 def case_escalate(self, cr, uid, ids, context=None):
425 """ Escalates case to parent level """
426 for case in self.browse(cr, uid, ids, context=context):
427 data = {'active': True}
428 if case.section_id.parent_id:
429 data['section_id'] = case.section_id.parent_id.id
430 if case.section_id.parent_id.change_responsible:
431 if case.section_id.parent_id.user_id:
432 data['user_id'] = case.section_id.parent_id.user_id.id
434 raise osv.except_osv(_('Error!'), _("You are already at the top level of your sales-team category.\nTherefore you cannot escalate furthermore."))
435 self.write(cr, uid, [case.id], data, context=context)
438 def _merge_get_result_type(self, cr, uid, opps, context=None):
440 Define the type of the result of the merge. If at least one of the
441 element to merge is an opp, the resulting new element will be an opp.
442 Otherwise it will be a lead.
444 We'll directly use a list of browse records instead of a list of ids
445 for performances' sake: it will spare a second browse of the
448 :param list opps: list of browse records containing the leads/opps to process
449 :return string type: the type of the final element
452 if (opp.type == 'opportunity'):
457 def _merge_data(self, cr, uid, ids, oldest, fields, context=None):
459 Prepare lead/opp data into a dictionary for merging. Different types
460 of fields are processed in different ways:
461 - text: all the values are concatenated
462 - m2m and o2m: those fields aren't processed
463 - m2o: the first not null value prevails (the other are dropped)
464 - any other type of field: same as m2o
466 :param list ids: list of ids of the leads to process
467 :param list fields: list of leads' fields to process
468 :return dict data: contains the merged values
470 opportunities = self.browse(cr, uid, ids, context=context)
472 def _get_first_not_null(attr):
473 for opp in opportunities:
474 if hasattr(opp, attr) and bool(getattr(opp, attr)):
475 return getattr(opp, attr)
478 def _get_first_not_null_id(attr):
479 res = _get_first_not_null(attr)
480 return res and res.id or False
482 def _concat_all(attr):
483 return '\n\n'.join(filter(lambda x: x, [getattr(opp, attr) or '' for opp in opportunities if hasattr(opp, attr)]))
485 # Process the fields' values
487 for field_name in fields:
488 field = self._fields.get(field_name)
491 if field.type in ('many2many', 'one2many'):
493 elif field.type == 'many2one':
494 data[field_name] = _get_first_not_null_id(field_name) # !!
495 elif field.type == 'text':
496 data[field_name] = _concat_all(field_name) #not lost
498 data[field_name] = _get_first_not_null(field_name) #not lost
500 # Define the resulting type ('lead' or 'opportunity')
501 data['type'] = self._merge_get_result_type(cr, uid, opportunities, context)
504 def _mail_body(self, cr, uid, lead, fields, title=False, context=None):
507 body.append("%s\n" % (title))
509 for field_name in fields:
510 field = self._fields.get(field_name)
515 if field.type == 'selection':
516 if callable(field.selection):
517 key = field.selection(self, cr, uid, context=context)
519 key = field.selection
520 value = dict(key).get(lead[field_name], lead[field_name])
521 elif field.type == 'many2one':
523 value = lead[field_name].name_get()[0][1]
524 elif field.type == 'many2many':
526 for val in lead[field_name]:
527 field_value = val.name_get()[0][1]
528 value += field_value + ","
530 value = lead[field_name]
532 body.append("%s: %s" % (field.string, value or ''))
533 return "<br/>".join(body + ['<br/>'])
535 def _merge_notify(self, cr, uid, opportunity_id, opportunities, context=None):
537 Create a message gathering merged leads/opps information.
539 #TOFIX: mail template should be used instead of fix body, subject text
541 result_type = self._merge_get_result_type(cr, uid, opportunities, context)
542 if result_type == 'lead':
543 merge_message = _('Merged leads')
545 merge_message = _('Merged opportunities')
546 subject = [merge_message]
547 for opportunity in opportunities:
548 subject.append(opportunity.name)
549 title = "%s : %s" % (opportunity.type == 'opportunity' and _('Merged opportunity') or _('Merged lead'), opportunity.name)
550 fields = list(CRM_LEAD_FIELDS_TO_MERGE)
551 details.append(self._mail_body(cr, uid, opportunity, fields, title=title, context=context))
553 # Chatter message's subject
554 subject = subject[0] + ": " + ", ".join(subject[1:])
555 details = "\n\n".join(details)
556 return self.message_post(cr, uid, [opportunity_id], body=details, subject=subject, context=context)
558 def _merge_opportunity_history(self, cr, uid, opportunity_id, opportunities, context=None):
559 message = self.pool.get('mail.message')
560 for opportunity in opportunities:
561 for history in opportunity.message_ids:
562 message.write(cr, uid, history.id, {
563 'res_id': opportunity_id,
564 'subject' : _("From %s : %s") % (opportunity.name, history.subject)
569 def _merge_opportunity_attachments(self, cr, uid, opportunity_id, opportunities, context=None):
570 attach_obj = self.pool.get('ir.attachment')
572 # return attachments of opportunity
573 def _get_attachments(opportunity_id):
574 attachment_ids = attach_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', opportunity_id)], context=context)
575 return attach_obj.browse(cr, uid, attachment_ids, context=context)
577 first_attachments = _get_attachments(opportunity_id)
578 #counter of all attachments to move. Used to make sure the name is different for all attachments
580 for opportunity in opportunities:
581 attachments = _get_attachments(opportunity.id)
582 for attachment in attachments:
583 values = {'res_id': opportunity_id,}
584 for attachment_in_first in first_attachments:
585 if attachment.name == attachment_in_first.name:
586 values['name'] = "%s (%s)" % (attachment.name, count,),
588 attachment.write(values)
591 def _merge_opportunity_phonecalls(self, cr, uid, opportunity_id, opportunities, context=None):
592 phonecall_obj = self.pool['crm.phonecall']
593 for opportunity in opportunities:
594 for phonecall_id in phonecall_obj.search(cr, uid, [('opportunity_id', '=', opportunity.id)], context=context):
595 phonecall_obj.write(cr, uid, phonecall_id, {'opportunity_id': opportunity_id}, context=context)
598 def get_duplicated_leads(self, cr, uid, ids, partner_id, include_lost=False, context=None):
600 Search for opportunities that have the same partner and that arent done or cancelled
602 lead = self.browse(cr, uid, ids[0], context=context)
603 email = lead.partner_id and lead.partner_id.email or lead.email_from
604 return self.pool['crm.lead']._get_duplicated_leads_by_emails(cr, uid, partner_id, email, include_lost=include_lost, context=context)
606 def _get_duplicated_leads_by_emails(self, cr, uid, partner_id, email, include_lost=False, context=None):
608 Search for opportunities that have the same partner and that arent done or cancelled
610 final_stage_domain = [('stage_id.probability', '<', 100), '|', ('stage_id.probability', '>', 0), ('stage_id.sequence', '<=', 1)]
611 partner_match_domain = []
612 for email in set(email_split(email) + [email]):
613 partner_match_domain.append(('email_from', '=ilike', email))
615 partner_match_domain.append(('partner_id', '=', partner_id))
616 partner_match_domain = ['|'] * (len(partner_match_domain) - 1) + partner_match_domain
617 if not partner_match_domain:
619 domain = partner_match_domain
621 domain += final_stage_domain
622 return self.search(cr, uid, domain, context=context)
624 def merge_dependences(self, cr, uid, highest, opportunities, context=None):
625 self._merge_notify(cr, uid, highest, opportunities, context=context)
626 self._merge_opportunity_history(cr, uid, highest, opportunities, context=context)
627 self._merge_opportunity_attachments(cr, uid, highest, opportunities, context=context)
628 self._merge_opportunity_phonecalls(cr, uid, highest, opportunities, context=context)
630 def merge_opportunity(self, cr, uid, ids, user_id=False, section_id=False, context=None):
632 Different cases of merge:
633 - merge leads together = 1 new lead
634 - merge at least 1 opp with anything else (lead or opp) = 1 new opp
636 :param list ids: leads/opportunities ids to merge
637 :return int id: id of the resulting lead/opp
643 raise osv.except_osv(_('Warning!'), _('Please select more than one element (lead or opportunity) from the list view.'))
645 opportunities = self.browse(cr, uid, ids, context=context)
647 # Sorting the leads/opps according to the confidence level of its stage, which relates to the probability of winning it
648 # The confidence level increases with the stage sequence, except when the stage probability is 0.0 (Lost cases)
649 # An Opportunity always has higher confidence level than a lead, unless its stage probability is 0.0
650 for opportunity in opportunities:
652 if opportunity.stage_id and not opportunity.stage_id.fold:
653 sequence = opportunity.stage_id.sequence
654 sequenced_opps.append(((int(sequence != -1 and opportunity.type == 'opportunity'), sequence, -opportunity.id), opportunity))
656 sequenced_opps.sort(reverse=True)
657 opportunities = map(itemgetter(1), sequenced_opps)
658 ids = [opportunity.id for opportunity in opportunities]
659 highest = opportunities[0]
660 opportunities_rest = opportunities[1:]
662 tail_opportunities = opportunities_rest
664 fields = list(CRM_LEAD_FIELDS_TO_MERGE)
665 merged_data = self._merge_data(cr, uid, ids, highest, fields, context=context)
668 merged_data['user_id'] = user_id
670 merged_data['section_id'] = section_id
672 # Merge notifications about loss of information
673 opportunities = [highest]
674 opportunities.extend(opportunities_rest)
676 self.merge_dependences(cr, uid, highest.id, tail_opportunities, context=context)
678 # Check if the stage is in the stages of the sales team. If not, assign the stage with the lowest sequence
679 if merged_data.get('section_id'):
680 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)
681 if merged_data.get('stage_id') not in section_stage_ids:
682 merged_data['stage_id'] = section_stage_ids and section_stage_ids[0] or False
683 # Write merged data into first opportunity
684 self.write(cr, uid, [highest.id], merged_data, context=context)
685 # Delete tail opportunities
686 # 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
687 self.unlink(cr, SUPERUSER_ID, [x.id for x in tail_opportunities], context=context)
691 def _convert_opportunity_data(self, cr, uid, lead, customer, section_id=False, context=None):
692 crm_stage = self.pool.get('crm.case.stage')
695 contact_id = self.pool.get('res.partner').address_get(cr, uid, [customer.id])['default']
697 section_id = lead.section_id and lead.section_id.id or False
699 'planned_revenue': lead.planned_revenue,
700 'probability': lead.probability,
702 'partner_id': customer and customer.id or False,
703 'type': 'opportunity',
704 'date_action': fields.datetime.now(),
705 'date_open': fields.datetime.now(),
706 'email_from': customer and customer.email or lead.email_from,
707 'phone': customer and customer.phone or lead.phone,
709 if not lead.stage_id or lead.stage_id.type=='lead':
710 val['stage_id'] = self.stage_find(cr, uid, [lead], section_id, [('type', 'in', ('opportunity', 'both'))], context=context)
713 def convert_opportunity(self, cr, uid, ids, partner_id, user_ids=False, section_id=False, context=None):
716 partner = self.pool.get('res.partner')
717 customer = partner.browse(cr, uid, partner_id, context=context)
718 for lead in self.browse(cr, uid, ids, context=context):
719 # TDE: was if lead.state in ('done', 'cancel'):
720 if lead.probability == 100 or (lead.probability == 0 and lead.stage_id.fold):
722 vals = self._convert_opportunity_data(cr, uid, lead, customer, section_id, context=context)
723 self.write(cr, uid, [lead.id], vals, context=context)
725 if user_ids or section_id:
726 self.allocate_salesman(cr, uid, ids, user_ids, section_id, context=context)
730 def _lead_create_contact(self, cr, uid, lead, name, is_company, parent_id=False, context=None):
731 partner = self.pool.get('res.partner')
732 vals = {'name': name,
733 'user_id': lead.user_id.id,
734 'comment': lead.description,
735 'section_id': lead.section_id.id or False,
736 'parent_id': parent_id,
738 'mobile': lead.mobile,
739 'email': tools.email_split(lead.email_from) and tools.email_split(lead.email_from)[0] or False,
741 'title': lead.title and lead.title.id or False,
742 'function': lead.function,
743 'street': lead.street,
744 'street2': lead.street2,
747 'country_id': lead.country_id and lead.country_id.id or False,
748 'state_id': lead.state_id and lead.state_id.id or False,
749 'is_company': is_company,
752 partner = partner.create(cr, uid, vals, context=context)
755 def _create_lead_partner(self, cr, uid, lead, context=None):
757 if lead.partner_name and lead.contact_name:
758 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
759 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, partner_id, context=context)
760 elif lead.partner_name and not lead.contact_name:
761 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
762 elif not lead.partner_name and lead.contact_name:
763 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, context=context)
764 elif lead.email_from and self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]:
765 contact_name = self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]
766 partner_id = self._lead_create_contact(cr, uid, lead, contact_name, False, context=context)
768 raise osv.except_osv(
770 _('No customer name defined. Please fill one of the following fields: Company Name, Contact Name or Email ("Name <email@address>")')
774 def handle_partner_assignation(self, cr, uid, ids, action='create', partner_id=False, context=None):
776 Handle partner assignation during a lead conversion.
777 if action is 'create', create new partner with contact and assign lead to new partner_id.
778 otherwise assign lead to the specified partner_id
780 :param list ids: leads/opportunities ids to process
781 :param string action: what has to be done regarding partners (create it, assign an existing one, or nothing)
782 :param int partner_id: partner to assign if any
783 :return dict: dictionary organized as followed: {lead_id: partner_assigned_id}
785 #TODO this is a duplication of the handle_partner_assignation method of crm_phonecall
787 for lead in self.browse(cr, uid, ids, context=context):
788 # If the action is set to 'create' and no partner_id is set, create a new one
790 partner_ids[lead.id] = lead.partner_id.id
792 if not partner_id and action == 'create':
793 partner_id = self._create_lead_partner(cr, uid, lead, context)
794 self.pool['res.partner'].write(cr, uid, partner_id, {'section_id': lead.section_id and lead.section_id.id or False})
796 lead.write({'partner_id': partner_id}, context=context)
797 partner_ids[lead.id] = partner_id
800 def allocate_salesman(self, cr, uid, ids, user_ids=None, team_id=False, context=None):
802 Assign salesmen and salesteam to a batch of leads. If there are more
803 leads than salesmen, these salesmen will be assigned in round-robin.
804 E.g.: 4 salesmen (S1, S2, S3, S4) for 6 leads (L1, L2, ... L6). They
805 will be assigned as followed: L1 - S1, L2 - S2, L3 - S3, L4 - S4,
808 :param list ids: leads/opportunities ids to process
809 :param list user_ids: salesmen to assign
810 :param int team_id: salesteam to assign
818 value['section_id'] = team_id
820 value['user_id'] = user_ids[index]
821 # Cycle through user_ids
822 index = (index + 1) % len(user_ids)
824 self.write(cr, uid, [lead_id], value, context=context)
827 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):
829 :param string action: ('schedule','Schedule a call'), ('log','Log a call')
831 phonecall = self.pool.get('crm.phonecall')
832 model_data = self.pool.get('ir.model.data')
836 res_id = model_data._get_id(cr, uid, 'crm', 'categ_phone2')
837 categ_id = model_data.browse(cr, uid, res_id, context=context).res_id
840 for lead in self.browse(cr, uid, ids, context=context):
842 section_id = lead.section_id and lead.section_id.id or False
844 user_id = lead.user_id and lead.user_id.id or False
846 'name': call_summary,
847 'opportunity_id': lead.id,
848 'user_id': user_id or False,
849 'categ_id': categ_id or False,
850 'description': desc or '',
851 'date': schedule_time,
852 'section_id': section_id or False,
853 'partner_id': lead.partner_id and lead.partner_id.id or False,
854 'partner_phone': phone or lead.phone or (lead.partner_id and lead.partner_id.phone or False),
855 'partner_mobile': lead.partner_id and lead.partner_id.mobile or False,
856 'priority': lead.priority,
858 new_id = phonecall.create(cr, uid, vals, context=context)
859 phonecall.write(cr, uid, [new_id], {'state': 'open'}, context=context)
861 phonecall.write(cr, uid, [new_id], {'state': 'done'}, context=context)
862 phonecall_dict[lead.id] = new_id
863 self.schedule_phonecall_send_note(cr, uid, [lead.id], new_id, action, context=context)
864 return phonecall_dict
866 def redirect_opportunity_view(self, cr, uid, opportunity_id, context=None):
867 models_data = self.pool.get('ir.model.data')
869 # Get opportunity views
870 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
871 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_oppor')
873 'name': _('Opportunity'),
875 'view_mode': 'tree, form',
876 'res_model': 'crm.lead',
877 'domain': [('type', '=', 'opportunity')],
878 'res_id': int(opportunity_id),
880 'views': [(form_view or False, 'form'),
881 (tree_view or False, 'tree'), (False, 'kanban'),
882 (False, 'calendar'), (False, 'graph')],
883 'type': 'ir.actions.act_window',
886 def redirect_lead_view(self, cr, uid, lead_id, context=None):
887 models_data = self.pool.get('ir.model.data')
890 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_leads')
891 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_leads')
895 'view_mode': 'tree, form',
896 'res_model': 'crm.lead',
897 'domain': [('type', '=', 'lead')],
898 'res_id': int(lead_id),
900 'views': [(form_view or False, 'form'),
901 (tree_view or False, 'tree'),
902 (False, 'calendar'), (False, 'graph')],
903 'type': 'ir.actions.act_window',
906 def action_schedule_meeting(self, cr, uid, ids, context=None):
908 Open meeting's calendar view to schedule meeting on current opportunity.
909 :return dict: dictionary value for created Meeting view
911 lead = self.browse(cr, uid, ids[0], context)
912 res = self.pool.get('ir.actions.act_window').for_xml_id(cr, uid, 'calendar', 'action_calendar_event', context)
913 partner_ids = [self.pool['res.users'].browse(cr, uid, uid, context=context).partner_id.id]
915 partner_ids.append(lead.partner_id.id)
917 'default_opportunity_id': lead.type == 'opportunity' and lead.id or False,
918 'default_partner_id': lead.partner_id and lead.partner_id.id or False,
919 'default_partner_ids': partner_ids,
920 'default_section_id': lead.section_id and lead.section_id.id or False,
921 'default_name': lead.name,
925 def create(self, cr, uid, vals, context=None):
926 context = dict(context or {})
927 if vals.get('type') and not context.get('default_type'):
928 context['default_type'] = vals.get('type')
929 if vals.get('section_id') and not context.get('default_section_id'):
930 context['default_section_id'] = vals.get('section_id')
931 if vals.get('user_id'):
932 vals['date_open'] = fields.datetime.now()
934 # context: no_log, because subtype already handle this
935 create_context = dict(context, mail_create_nolog=True)
936 return super(crm_lead, self).create(cr, uid, vals, context=create_context)
938 def write(self, cr, uid, ids, vals, context=None):
939 # stage change: update date_last_stage_update
940 if 'stage_id' in vals:
941 vals['date_last_stage_update'] = fields.datetime.now()
942 if vals.get('user_id'):
943 vals['date_open'] = fields.datetime.now()
944 # stage change with new stage: update probability and date_closed
945 if vals.get('stage_id') and not vals.get('probability'):
946 onchange_stage_values = self.onchange_stage_id(cr, uid, ids, vals.get('stage_id'), context=context)['value']
947 vals.update(onchange_stage_values)
948 return super(crm_lead, self).write(cr, uid, ids, vals, context=context)
950 def copy(self, cr, uid, id, default=None, context=None):
955 lead = self.browse(cr, uid, id, context=context)
956 local_context = dict(context)
957 local_context.setdefault('default_type', lead.type)
958 local_context.setdefault('default_section_id', lead.section_id.id)
959 if lead.type == 'opportunity':
960 default['date_open'] = fields.datetime.now()
962 default['date_open'] = False
963 return super(crm_lead, self).copy(cr, uid, id, default, context=local_context)
965 def get_empty_list_help(self, cr, uid, help, context=None):
966 context = dict(context or {})
967 context['empty_list_help_model'] = 'crm.case.section'
968 context['empty_list_help_id'] = context.get('default_section_id', None)
969 context['empty_list_help_document_name'] = _("opportunity")
970 if context.get('default_type') == 'lead':
971 context['empty_list_help_document_name'] = _("lead")
972 return super(crm_lead, self).get_empty_list_help(cr, uid, help, context=context)
974 # ----------------------------------------
976 # ----------------------------------------
978 def message_get_reply_to(self, cr, uid, ids, context=None):
979 """ Override to get the reply_to of the parent project. """
980 leads = self.browse(cr, SUPERUSER_ID, ids, context=context)
981 section_ids = set([lead.section_id.id for lead in leads if lead.section_id])
982 aliases = self.pool['crm.case.section'].message_get_reply_to(cr, uid, list(section_ids), context=context)
983 return dict((lead.id, aliases.get(lead.section_id and lead.section_id.id or 0, False)) for lead in leads)
985 def get_formview_id(self, cr, uid, id, context=None):
986 obj = self.browse(cr, uid, id, context=context)
987 if obj.type == 'opportunity':
988 model, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
990 view_id = super(crm_lead, self).get_formview_id(cr, uid, id, context=context)
993 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
994 recipients = super(crm_lead, self).message_get_suggested_recipients(cr, uid, ids, context=context)
996 for lead in self.browse(cr, uid, ids, context=context):
998 self._message_add_suggested_recipient(cr, uid, recipients, lead, partner=lead.partner_id, reason=_('Customer'))
999 elif lead.email_from:
1000 self._message_add_suggested_recipient(cr, uid, recipients, lead, email=lead.email_from, reason=_('Customer Email'))
1001 except (osv.except_osv, orm.except_orm): # no read access rights -> just ignore suggested recipients because this imply modifying followers
1005 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1006 """ Overrides mail_thread message_new that is called by the mailgateway
1007 through message_process.
1008 This override updates the document according to the email.
1010 if custom_values is None:
1013 'name': msg.get('subject') or _("No Subject"),
1014 'email_from': msg.get('from'),
1015 'email_cc': msg.get('cc'),
1016 'partner_id': msg.get('author_id', False),
1019 if msg.get('author_id'):
1020 defaults.update(self.on_change_partner_id(cr, uid, None, msg.get('author_id'), context=context)['value'])
1021 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
1022 defaults['priority'] = msg.get('priority')
1023 defaults.update(custom_values)
1024 return super(crm_lead, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1026 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1027 """ Overrides mail_thread message_update that is called by the mailgateway
1028 through message_process.
1029 This method updates the document according to the email.
1031 if isinstance(ids, (str, int, long)):
1033 if update_vals is None: update_vals = {}
1035 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
1036 update_vals['priority'] = msg.get('priority')
1038 'cost':'planned_cost',
1039 'revenue': 'planned_revenue',
1040 'probability':'probability',
1042 for line in msg.get('body', '').split('\n'):
1044 res = tools.command_re.match(line)
1045 if res and maps.get(res.group(1).lower()):
1046 key = maps.get(res.group(1).lower())
1047 update_vals[key] = res.group(2).lower()
1049 return super(crm_lead, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1051 # ----------------------------------------
1052 # OpenChatter methods and notifications
1053 # ----------------------------------------
1055 def schedule_phonecall_send_note(self, cr, uid, ids, phonecall_id, action, context=None):
1056 phonecall = self.pool.get('crm.phonecall').browse(cr, uid, [phonecall_id], context=context)[0]
1058 message = _('Logged a call for %(date)s. %(description)s')
1060 message = _('Scheduled a call for %(date)s. %(description)s')
1061 phonecall_date = datetime.strptime(phonecall.date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
1062 phonecall_usertime = fields.datetime.context_timestamp(cr, uid, phonecall_date, context=context).strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1063 html_time = "<time datetime='%s+00:00'>%s</time>" % (phonecall.date, phonecall_usertime)
1064 message = message % dict(date=html_time, description=phonecall.description)
1065 return self.message_post(cr, uid, ids, body=message, context=context)
1067 def log_meeting(self, cr, uid, ids, meeting_subject, meeting_date, duration, context=None):
1069 duration = _('unknown')
1071 duration = str(duration)
1072 message = _("Meeting scheduled at '%s'<br> Subject: %s <br> Duration: %s hour(s)") % (meeting_date, meeting_subject, duration)
1073 return self.message_post(cr, uid, ids, body=message, context=context)
1075 def onchange_state(self, cr, uid, ids, state_id, context=None):
1077 country_id=self.pool.get('res.country.state').browse(cr, uid, state_id, context).country_id.id
1078 return {'value':{'country_id':country_id}}
1081 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1082 res = super(crm_lead, self).message_partner_info_from_emails(cr, uid, id, emails, link_mail=link_mail, context=context)
1083 lead = self.browse(cr, uid, id, context=context)
1084 for partner_info in res:
1085 if not partner_info.get('partner_id') and (lead.partner_name or lead.contact_name):
1086 emails = email_re.findall(partner_info['full_name'] or '')
1087 email = emails and emails[0] or ''
1088 if email and lead.email_from and email.lower() == lead.email_from.lower():
1089 partner_info['full_name'] = '%s <%s>' % (lead.partner_name or lead.contact_name, email)
1093 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: