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 ##############################################################################
22 from openerp.addons.base_status.base_stage import base_stage
24 from datetime import datetime
25 from operator import itemgetter
26 from openerp.osv import fields, osv, orm
28 from openerp import SUPERUSER_ID
29 from openerp import tools
30 from openerp.tools.translate import _
31 from openerp.tools import html2plaintext
33 from openerp.addons.base.res.res_partner import format_address
35 CRM_LEAD_FIELDS_TO_MERGE = ['name',
67 class crm_lead(base_stage, format_address, osv.osv):
70 _description = "Lead/Opportunity"
71 _order = "priority,date_action,id desc"
72 _inherit = ['mail.thread', 'ir.needaction_mixin']
76 'crm.mt_lead_create': lambda self, cr, uid, obj, ctx=None: obj.probability == 0 and obj.stage_id and obj.stage_id.sequence == 1,
77 'crm.mt_lead_stage': lambda self, cr, uid, obj, ctx=None: obj.probability > 0 and obj.probability < 100,
78 'crm.mt_lead_won': lambda self, cr, uid, obj, ctx=None: obj.probability == 100,
79 'crm.mt_lead_lost': lambda self, cr, uid, obj, ctx=None: obj.probability == 0 and obj.stage_id and obj.stage_id.sequence != 1,
83 def get_empty_list_help(self, cr, uid, help, context=None):
84 if context.get('default_type') == 'lead':
85 context['empty_list_help_model'] = 'crm.case.section'
86 context['empty_list_help_id'] = context.get('default_section_id')
87 context['empty_list_help_document_name'] = _("leads")
88 return super(crm_lead, self).get_empty_list_help(cr, uid, help, context=context)
90 def create(self, cr, uid, vals, context=None):
93 if not vals.get('stage_id'):
95 if vals.get('section_id'):
96 ctx['default_section_id'] = vals['section_id']
98 ctx['default_type'] = vals['type']
99 vals['stage_id'] = self._get_default_stage_id(cr, uid, context=ctx)
100 # context: no_log, because subtype already handle this
101 create_context = dict(context, mail_create_nolog=True)
102 return super(crm_lead, self).create(cr, uid, vals, context=create_context)
104 def _get_default_section_id(self, cr, uid, context=None):
105 """ Gives default section by checking if present in the context """
106 return self._resolve_section_id_from_context(cr, uid, context=context) or False
108 def _resolve_section_id_from_context(self, cr, uid, context=None):
109 """ Returns ID of section based on the value of 'section_id'
110 context key, or None if it cannot be resolved to a single
115 if type(context.get('default_section_id')) in (int, long):
116 return context.get('default_section_id')
117 if isinstance(context.get('default_section_id'), basestring):
118 section_name = context['default_section_id']
119 section_ids = self.pool.get('crm.case.section').name_search(cr, uid, name=section_name, context=context)
120 if len(section_ids) == 1:
121 return int(section_ids[0][0])
124 def _resolve_type_from_context(self, cr, uid, context=None):
125 """ Returns the type (lead or opportunity) from the type context
126 key. Returns None if it cannot be resolved.
130 return context.get('default_type')
132 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
133 access_rights_uid = access_rights_uid or uid
134 stage_obj = self.pool.get('crm.case.stage')
135 order = stage_obj._order
136 # lame hack to allow reverting search, should just work in the trivial case
137 if read_group_order == 'stage_id desc':
138 order = "%s desc" % order
139 # retrieve section_id from the context and write the domain
140 # - ('id', 'in', 'ids'): add columns that should be present
141 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
142 # - OR ('section_ids', '=', section_id), ('fold', '=', False) if section_id: add section columns that are not folded
144 section_id = self._resolve_section_id_from_context(cr, uid, context=context)
146 search_domain += ['|', ('section_ids', '=', section_id)]
147 search_domain += [('id', 'in', ids)]
149 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
150 # retrieve type from the context (if set: choose 'type' or 'both')
151 type = self._resolve_type_from_context(cr, uid, context=context)
153 search_domain += ['|', ('type', '=', type), ('type', '=', 'both')]
155 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
156 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
157 # restore order of the search
158 result.sort(lambda x, y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
161 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
162 fold[stage.id] = stage.fold or False
165 def fields_view_get(self, cr, user, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
166 res = super(crm_lead, self).fields_view_get(cr, user, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
167 if view_type == 'form':
168 res['arch'] = self.fields_view_get_address(cr, user, res['arch'], context=context)
172 'stage_id': _read_group_stage_ids
175 def _compute_day(self, cr, uid, ids, fields, args, context=None):
177 :return dict: difference between current date and log date
179 cal_obj = self.pool.get('resource.calendar')
180 res_obj = self.pool.get('resource.resource')
183 for lead in self.browse(cr, uid, ids, context=context):
188 if field == 'day_open':
190 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
191 date_open = datetime.strptime(lead.date_open, "%Y-%m-%d %H:%M:%S")
192 ans = date_open - date_create
193 date_until = lead.date_open
194 elif field == 'day_close':
196 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
197 date_close = datetime.strptime(lead.date_closed, "%Y-%m-%d %H:%M:%S")
198 date_until = lead.date_closed
199 ans = date_close - date_create
203 resource_ids = res_obj.search(cr, uid, [('user_id','=',lead.user_id.id)])
204 if len(resource_ids):
205 resource_id = resource_ids[0]
207 duration = float(ans.days)
208 if lead.section_id and lead.section_id.resource_calendar_id:
209 duration = float(ans.days) * 24
210 new_dates = cal_obj.interval_get(cr,
212 lead.section_id.resource_calendar_id and lead.section_id.resource_calendar_id.id or False,
213 datetime.strptime(lead.create_date, '%Y-%m-%d %H:%M:%S'),
218 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
219 for in_time, out_time in new_dates:
220 if in_time.date not in no_days:
221 no_days.append(in_time.date)
222 if out_time > date_until:
224 duration = len(no_days)
225 res[lead.id][field] = abs(int(duration))
229 'partner_id': fields.many2one('res.partner', 'Partner', ondelete='set null', track_visibility='onchange',
230 select=True, help="Linked partner (optional). Usually created when converting the lead."),
232 'id': fields.integer('ID', readonly=True),
233 'name': fields.char('Subject', size=64, required=True, select=1),
234 'active': fields.boolean('Active', required=False),
235 'date_action_last': fields.datetime('Last Action', readonly=1),
236 'date_action_next': fields.datetime('Next Action', readonly=1),
237 'email_from': fields.char('Email', size=128, help="Email address of the contact", select=1),
238 'section_id': fields.many2one('crm.case.section', 'Sales Team',
239 select=True, track_visibility='onchange', help='When sending mails, the default email address is taken from the sales team.'),
240 'create_date': fields.datetime('Creation Date', readonly=True),
241 'email_cc': fields.text('Global CC', size=252, help="These email addresses will be added to the CC field of all inbound and outbound emails for this record before being sent. Separate multiple email addresses with a comma"),
242 'description': fields.text('Notes'),
243 'write_date': fields.datetime('Update Date', readonly=True),
244 'categ_ids': fields.many2many('crm.case.categ', 'crm_lead_category_rel', 'lead_id', 'category_id', 'Categories', \
245 domain="['|',('section_id','=',section_id),('section_id','=',False), ('object_id.model', '=', 'crm.lead')]"),
246 'type_id': fields.many2one('crm.case.resource.type', 'Campaign', \
247 domain="['|',('section_id','=',section_id),('section_id','=',False)]", help="From which campaign (seminar, marketing campaign, mass mailing, ...) did this contact come from?"),
248 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel (mail, direct, phone, ...)"),
249 'contact_name': fields.char('Contact Name', size=64),
250 '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),
251 'opt_out': fields.boolean('Opt-Out', oldname='optout',
252 help="If opt-out is checked, this contact has refused to receive emails for mass mailing and marketing campaign. "
253 "Filter 'Available for Mass Mailing' allows users to filter the leads when performing mass mailing."),
254 'type': fields.selection([('lead', 'Lead'), ('opportunity', 'Opportunity'), ], 'Type', help="Type is used to separate Leads and Opportunities"),
255 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
256 'date_closed': fields.datetime('Closed', readonly=True),
257 'stage_id': fields.many2one('crm.case.stage', 'Stage', track_visibility='onchange',
258 domain="['&', ('section_ids', '=', section_id), '|', ('type', '=', type), ('type', '=', 'both')]"),
259 'user_id': fields.many2one('res.users', 'Salesperson', select=True, track_visibility='onchange'),
260 'referred': fields.char('Referred By', size=64),
261 'date_open': fields.datetime('Opened', readonly=True),
262 'day_open': fields.function(_compute_day, string='Days to Open', \
263 multi='day_open', type="float", store=True),
264 'day_close': fields.function(_compute_day, string='Days to Close', \
265 multi='day_close', type="float", store=True),
267 # Only used for type opportunity
268 'probability': fields.float('Success Rate (%)', group_operator="avg"),
269 'planned_revenue': fields.float('Expected Revenue', track_visibility='always'),
270 'ref': fields.reference('Reference', selection=crm._links_get, size=128),
271 'ref2': fields.reference('Reference 2', selection=crm._links_get, size=128),
272 'phone': fields.char("Phone", size=64),
273 'date_deadline': fields.date('Expected Closing', help="Estimate of the date on which the opportunity will be won."),
274 'date_action': fields.date('Next Action Date', select=True),
275 'title_action': fields.char('Next Action', size=64),
276 'color': fields.integer('Color Index'),
277 'partner_address_name': fields.related('partner_id', 'name', type='char', string='Partner Contact Name', readonly=True),
278 'partner_address_email': fields.related('partner_id', 'email', type='char', string='Partner Contact Email', readonly=True),
279 'company_currency': fields.related('company_id', 'currency_id', type='many2one', string='Currency', readonly=True, relation="res.currency"),
280 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
281 'user_login': fields.related('user_id', 'login', type='char', string='User Login', readonly=True),
283 # Fields for address, due to separation from crm and res.partner
284 'street': fields.char('Street', size=128),
285 'street2': fields.char('Street2', size=128),
286 'zip': fields.char('Zip', change_default=True, size=24),
287 'city': fields.char('City', size=128),
288 'state_id': fields.many2one("res.country.state", 'State'),
289 'country_id': fields.many2one('res.country', 'Country'),
290 'phone': fields.char('Phone', size=64),
291 'fax': fields.char('Fax', size=64),
292 'mobile': fields.char('Mobile', size=64),
293 'function': fields.char('Function', size=128),
294 'title': fields.many2one('res.partner.title', 'Title'),
295 'company_id': fields.many2one('res.company', 'Company', select=1),
296 'payment_mode': fields.many2one('crm.payment.mode', 'Payment Mode', \
297 domain="[('section_id','=',section_id)]"),
298 'planned_cost': fields.float('Planned Costs'),
304 'user_id': lambda s, cr, uid, c: s._get_default_user(cr, uid, c),
305 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
306 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
307 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
308 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.lead', context=c),
309 'priority': lambda *a: crm.AVAILABLE_PRIORITIES[2][0],
314 ('check_probability', 'check(probability >= 0 and probability <= 100)', 'The probability of closing the deal should be between 0% and 100%!')
317 def onchange_stage_id(self, cr, uid, ids, stage_id, context=None):
320 stage = self.pool.get('crm.case.stage').browse(cr, uid, stage_id, context=context)
321 if not stage.on_change:
323 return {'value': {'probability': stage.probability}}
325 def on_change_user(self, cr, uid, ids, user_id, context=None):
326 """ When changing the user, also set a section_id or restrict section id
327 to the ones user_id is member of. """
328 section_id = self._get_default_section_id(cr, uid, context=context) or False
329 if user_id and not section_id:
330 section_ids = self.pool.get('crm.case.section').search(cr, uid, ['|', ('user_id', '=', user_id), ('member_ids', '=', user_id)], context=context)
332 section_id = section_ids[0]
333 return {'value': {'section_id': section_id}}
335 def _check(self, cr, uid, ids=False, context=None):
336 """ Override of the base.stage method.
337 Function called by the scheduler to process cases for date actions
338 Only works on not done and cancelled cases
340 cr.execute('select * from crm_case \
341 where (date_action_last<%s or date_action_last is null) \
342 and (date_action_next<=%s or date_action_next is null) \
343 and state not in (\'cancel\',\'done\')',
344 (time.strftime("%Y-%m-%d %H:%M:%S"),
345 time.strftime('%Y-%m-%d %H:%M:%S')))
347 ids2 = map(lambda x: x[0], cr.fetchall() or [])
348 cases = self.browse(cr, uid, ids2, context=context)
349 return self._action(cr, uid, cases, False, context=context)
351 def stage_find(self, cr, uid, cases, section_id, domain=None, order='sequence', context=None):
352 """ Override of the base.stage method
353 Parameter of the stage search taken from the lead:
354 - type: stage type must be the same or 'both'
355 - section_id: if set, stages must belong to this section or
356 be a default stage; if not set, stages must be default
359 if isinstance(cases, (int, long)):
360 cases = self.browse(cr, uid, cases, context=context)
361 # collect all section_ids
365 ctx_type = context.get('default_type')
368 section_ids.add(section_id)
371 section_ids.add(lead.section_id.id)
372 if lead.type not in types:
373 types.append(lead.type)
374 # OR all section_ids and OR with case_default
377 search_domain += [('|')] * len(section_ids)
378 for section_id in section_ids:
379 search_domain.append(('section_ids', '=', section_id))
380 search_domain.append(('case_default', '=', True))
381 # AND with cases types
382 search_domain.append(('type', 'in', types))
383 # AND with the domain in parameter
384 search_domain += list(domain)
385 # perform search, return the first found
386 stage_ids = self.pool.get('crm.case.stage').search(cr, uid, search_domain, order=order, context=context)
391 def case_mark_lost(self, cr, uid, ids, context=None):
392 """ Mark the case as lost: stage with probability=0, on_change=True """
393 for lead in self.browse(cr, uid, ids):
394 stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 0.0), ('on_change', '=', True), ('sequence', '>', 1)], context=context)
396 self.case_set(cr, uid, [lead.id], new_stage_id=stage_id, context=context)
398 raise self.pool.get('res.config.settings').get_config_warning(cr,
399 _("To relieve your sales pipe and group all Lost opportunities, configure one of your sales stage as follow:\n"
400 "probability = 0, sequence != 1 and on_change = True.\n"
401 "You can create a specific column or edit an existing one from the menu %(menu:crm.menu_crm_case_section_act)s"), context=context)
404 def case_mark_won(self, cr, uid, ids, context=None):
405 """ Mark the case as won: stage with probability=100, , on_change=True """
406 for lead in self.browse(cr, uid, ids):
407 stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 100.0), ('on_change', '=', True)], context=context)
409 self.case_set(cr, uid, [lead.id], new_stage_id=stage_id, context=context)
411 raise self.pool.get('res.config.settings').get_config_warning(cr,
412 _("To relieve your sales pipe and group all Won opportunities, configure one of your sales stage as follow:\n"
413 "probability = 100 and on_change = True.\n"
414 "You can create a specific column or edit an existing one from the menu %(menu:crm.menu_crm_case_section_act)s"), context=context)
417 def set_priority(self, cr, uid, ids, priority):
418 """ Set lead priority
420 return self.write(cr, uid, ids, {'priority': priority})
422 def set_high_priority(self, cr, uid, ids, context=None):
423 """ Set lead priority to high
425 return self.set_priority(cr, uid, ids, '1')
427 def set_normal_priority(self, cr, uid, ids, context=None):
428 """ Set lead priority to normal
430 return self.set_priority(cr, uid, ids, '3')
432 def _merge_get_result_type(self, cr, uid, opps, context=None):
434 Define the type of the result of the merge. If at least one of the
435 element to merge is an opp, the resulting new element will be an opp.
436 Otherwise it will be a lead.
438 We'll directly use a list of browse records instead of a list of ids
439 for performances' sake: it will spare a second browse of the
442 :param list opps: list of browse records containing the leads/opps to process
443 :return string type: the type of the final element
446 if (opp.type == 'opportunity'):
451 def _merge_data(self, cr, uid, ids, oldest, fields, context=None):
453 Prepare lead/opp data into a dictionary for merging. Different types
454 of fields are processed in different ways:
455 - text: all the values are concatenated
456 - m2m and o2m: those fields aren't processed
457 - m2o: the first not null value prevails (the other are dropped)
458 - any other type of field: same as m2o
460 :param list ids: list of ids of the leads to process
461 :param list fields: list of leads' fields to process
462 :return dict data: contains the merged values
464 opportunities = self.browse(cr, uid, ids, context=context)
466 def _get_first_not_null(attr):
467 for opp in opportunities:
468 if hasattr(opp, attr) and bool(getattr(opp, attr)):
469 return getattr(opp, attr)
472 def _get_first_not_null_id(attr):
473 res = _get_first_not_null(attr)
474 return res and res.id or False
476 def _concat_all(attr):
477 return '\n\n'.join(filter(lambda x: x, [getattr(opp, attr) or '' for opp in opportunities if hasattr(opp, attr)]))
479 # Process the fields' values
481 for field_name in fields:
482 field_info = self._all_columns.get(field_name)
483 if field_info is None:
485 field = field_info.column
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_info = self._all_columns.get(field_name)
506 if field_info is None:
508 field = field_info.column
511 if field._type == 'selection':
512 if hasattr(field.selection, '__call__'):
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 name = "%s (%s)" % (attachment.name, count,),
584 attachment.write(values)
587 def merge_opportunity(self, cr, uid, ids, context=None):
589 Different cases of merge:
590 - merge leads together = 1 new lead
591 - merge at least 1 opp with anything else (lead or opp) = 1 new opp
593 :param list ids: leads/opportunities ids to merge
594 :return int id: id of the resulting lead/opp
600 raise osv.except_osv(_('Warning!'), _('Please select more than one element (lead or opportunity) from the list view.'))
602 opportunities = self.browse(cr, uid, ids, context=context)
604 for opportunity in opportunities:
606 if opportunity.stage_id:
607 sequence = opportunity.stage_id.sequence
608 sequenced_opps.append(((int(sequence != -1 and opportunity.type == 'opportunity'), sequence, -opportunity.id), opportunity))
610 sequenced_opps.sort(reverse=True)
611 opportunities = map(itemgetter(1), sequenced_opps)
612 ids = [opportunity.id for opportunity in opportunities]
613 highest = opportunities[0]
614 opportunities_rest = opportunities[1:]
616 tail_opportunities = opportunities_rest
618 fields = list(CRM_LEAD_FIELDS_TO_MERGE)
619 merged_data = self._merge_data(cr, uid, ids, highest, fields, context=context)
621 # Merge messages and attachements into the first opportunity
622 self._merge_opportunity_history(cr, uid, highest.id, tail_opportunities, context=context)
623 self._merge_opportunity_attachments(cr, uid, highest.id, tail_opportunities, context=context)
625 # Merge notifications about loss of information
626 opportunities = [highest]
627 opportunities.extend(opportunities_rest)
628 self._merge_notify(cr, uid, highest, opportunities, context=context)
629 # Check if the stage is in the stages of the sales team. If not, assign the stage with the lowest sequence
630 if merged_data.get('section_id'):
631 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)
632 if merged_data.get('stage_id') not in section_stage_ids:
633 merged_data['stage_id'] = section_stage_ids and section_stage_ids[0] or False
634 # Write merged data into first opportunity
635 self.write(cr, uid, [highest.id], merged_data, context=context)
636 # Delete tail opportunities
637 # 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
638 self.unlink(cr, SUPERUSER_ID, [x.id for x in tail_opportunities], context=context)
642 def _convert_opportunity_data(self, cr, uid, lead, customer, section_id=False, context=None):
643 crm_stage = self.pool.get('crm.case.stage')
646 contact_id = self.pool.get('res.partner').address_get(cr, uid, [customer.id])['default']
648 section_id = lead.section_id and lead.section_id.id or False
650 'planned_revenue': lead.planned_revenue,
651 'probability': lead.probability,
653 'partner_id': customer and customer.id or False,
654 'user_id': (lead.user_id and lead.user_id.id),
655 'type': 'opportunity',
656 'date_action': fields.datetime.now(),
657 'date_open': fields.datetime.now(),
658 'email_from': customer and customer.email or lead.email_from,
659 'phone': customer and customer.phone or lead.phone,
661 if not lead.stage_id or lead.stage_id.type == 'lead':
662 val['stage_id'] = self.stage_find(cr, uid, [lead], section_id, ['&', ('sequence', '=', '1'), ('type', 'in', ('opportunity', 'both'))], context=context)
665 def convert_opportunity(self, cr, uid, ids, partner_id, user_ids=False, section_id=False, context=None):
668 partner = self.pool.get('res.partner')
669 customer = partner.browse(cr, uid, partner_id, context=context)
670 for lead in self.browse(cr, uid, ids, context=context):
671 # avoid done / cancelled leads
672 if lead.probability == 100 or (lead.probability == 0 and lead.stage_id and lead.stage_id.sequence == 1):
674 vals = self._convert_opportunity_data(cr, uid, lead, customer, section_id, context=context)
675 self.write(cr, uid, [lead.id], vals, context=context)
676 self.message_post(cr, uid, ids, body=_("Lead <b>converted into an Opportunity</b>"), subtype="crm.mt_lead_convert_to_opportunity", context=context)
678 if user_ids or section_id:
679 self.allocate_salesman(cr, uid, ids, user_ids, section_id, context=context)
683 def _lead_create_contact(self, cr, uid, lead, name, is_company, parent_id=False, context=None):
684 partner = self.pool.get('res.partner')
685 vals = {'name': name,
686 'user_id': lead.user_id.id,
687 'comment': lead.description,
688 'section_id': lead.section_id.id or False,
689 'parent_id': parent_id,
691 'mobile': lead.mobile,
692 'email': tools.email_split(lead.email_from) and tools.email_split(lead.email_from)[0] or False,
694 'title': lead.title and lead.title.id or False,
695 'function': lead.function,
696 'street': lead.street,
697 'street2': lead.street2,
700 'country_id': lead.country_id and lead.country_id.id or False,
701 'state_id': lead.state_id and lead.state_id.id or False,
702 'is_company': is_company,
705 partner = partner.create(cr, uid, vals, context=context)
708 def _create_lead_partner(self, cr, uid, lead, context=None):
710 if lead.partner_name and lead.contact_name:
711 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
712 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, partner_id, context=context)
713 elif lead.partner_name and not lead.contact_name:
714 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
715 elif not lead.partner_name and lead.contact_name:
716 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, context=context)
717 elif lead.email_from and self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]:
718 contact_name = self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]
719 partner_id = self._lead_create_contact(cr, uid, lead, contact_name, False, context=context)
721 raise osv.except_osv(
723 _('No customer name defined. Please fill one of the following fields: Company Name, Contact Name or Email ("Name <email@address>")')
727 def _lead_set_partner(self, cr, uid, lead, partner_id, context=None):
729 Assign a partner to a lead.
731 :param object lead: browse record of the lead to process
732 :param int partner_id: identifier of the partner to assign
733 :return bool: True if the partner has properly been assigned
736 res_partner = self.pool.get('res.partner')
738 res_partner.write(cr, uid, partner_id, {'section_id': lead.section_id and lead.section_id.id or False})
739 contact_id = res_partner.address_get(cr, uid, [partner_id])['default']
740 res = lead.write({'partner_id': partner_id}, context=context)
741 message = _("<b>Partner</b> set to <em>%s</em>." % (lead.partner_id.name))
742 self.message_post(cr, uid, [lead.id], body=message, context=context)
745 def handle_partner_assignation(self, cr, uid, ids, action='create', partner_id=False, context=None):
747 Handle partner assignation during a lead conversion.
748 if action is 'create', create new partner with contact and assign lead to new partner_id.
749 otherwise assign lead to the specified partner_id
751 :param list ids: leads/opportunities ids to process
752 :param string action: what has to be done regarding partners (create it, assign an existing one, or nothing)
753 :param int partner_id: partner to assign if any
754 :return dict: dictionary organized as followed: {lead_id: partner_assigned_id}
756 #TODO this is a duplication of the handle_partner_assignation method of crm_phonecall
758 # If a partner_id is given, force this partner for all elements
759 force_partner_id = partner_id
760 for lead in self.browse(cr, uid, ids, context=context):
761 # If the action is set to 'create' and no partner_id is set, create a new one
762 if action == 'create':
763 partner_id = force_partner_id or self._create_lead_partner(cr, uid, lead, context)
764 self._lead_set_partner(cr, uid, lead, partner_id, context=context)
765 partner_ids[lead.id] = partner_id
768 def allocate_salesman(self, cr, uid, ids, user_ids=None, team_id=False, context=None):
770 Assign salesmen and salesteam to a batch of leads. If there are more
771 leads than salesmen, these salesmen will be assigned in round-robin.
772 E.g.: 4 salesmen (S1, S2, S3, S4) for 6 leads (L1, L2, ... L6). They
773 will be assigned as followed: L1 - S1, L2 - S2, L3 - S3, L4 - S4,
776 :param list ids: leads/opportunities ids to process
777 :param list user_ids: salesmen to assign
778 :param int team_id: salesteam to assign
786 value['section_id'] = team_id
788 value['user_id'] = user_ids[index]
789 # Cycle through user_ids
790 index = (index + 1) % len(user_ids)
792 self.write(cr, uid, [lead_id], value, context=context)
795 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):
797 :param string action: ('schedule','Schedule a call'), ('log','Log a call')
799 phonecall = self.pool.get('crm.phonecall')
800 model_data = self.pool.get('ir.model.data')
803 res_id = model_data._get_id(cr, uid, 'crm', 'categ_phone2')
805 categ_id = model_data.browse(cr, uid, res_id, context=context).res_id
806 for lead in self.browse(cr, uid, ids, context=context):
808 section_id = lead.section_id and lead.section_id.id or False
810 user_id = lead.user_id and lead.user_id.id or False
812 'name': call_summary,
813 'opportunity_id': lead.id,
814 'user_id': user_id or False,
815 'categ_id': categ_id or False,
816 'description': desc or '',
817 'date': schedule_time,
818 'section_id': section_id or False,
819 'partner_id': lead.partner_id and lead.partner_id.id or False,
820 'partner_phone': phone or lead.phone or (lead.partner_id and lead.partner_id.phone or False),
821 'partner_mobile': lead.partner_id and lead.partner_id.mobile or False,
822 'priority': lead.priority,
824 new_id = phonecall.create(cr, uid, vals, context=context)
825 phonecall.case_open(cr, uid, [new_id], context=context)
827 phonecall.case_close(cr, uid, [new_id], context=context)
828 phonecall_dict[lead.id] = new_id
829 self.schedule_phonecall_send_note(cr, uid, [lead.id], new_id, action, context=context)
830 return phonecall_dict
832 def redirect_opportunity_view(self, cr, uid, opportunity_id, context=None):
833 models_data = self.pool.get('ir.model.data')
835 # Get opportunity views
836 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
837 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_oppor')
839 'name': _('Opportunity'),
841 'view_mode': 'tree, form',
842 'res_model': 'crm.lead',
843 'domain': [('type', '=', 'opportunity')],
844 'res_id': int(opportunity_id),
846 'views': [(form_view or False, 'form'),
847 (tree_view or False, 'tree'),
848 (False, 'calendar'), (False, 'graph')],
849 'type': 'ir.actions.act_window',
852 def redirect_lead_view(self, cr, uid, lead_id, context=None):
853 models_data = self.pool.get('ir.model.data')
856 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_leads')
857 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_leads')
861 'view_mode': 'tree, form',
862 'res_model': 'crm.lead',
863 'domain': [('type', '=', 'lead')],
864 'res_id': int(lead_id),
866 'views': [(form_view or False, 'form'),
867 (tree_view or False, 'tree'),
868 (False, 'calendar'), (False, 'graph')],
869 'type': 'ir.actions.act_window',
872 def action_makeMeeting(self, cr, uid, ids, context=None):
874 Open meeting's calendar view to schedule meeting on current opportunity.
875 :return dict: dictionary value for created Meeting view
877 opportunity = self.browse(cr, uid, ids[0], context)
878 res = self.pool.get('ir.actions.act_window').for_xml_id(cr, uid, 'base_calendar', 'action_crm_meeting', context)
880 'default_opportunity_id': opportunity.id,
881 'default_partner_id': opportunity.partner_id and opportunity.partner_id.id or False,
882 'default_partner_ids' : opportunity.partner_id and [opportunity.partner_id.id] or False,
883 'default_user_id': uid,
884 'default_section_id': opportunity.section_id and opportunity.section_id.id or False,
885 'default_email_from': opportunity.email_from,
886 'default_name': opportunity.name,
890 def write(self, cr, uid, ids, vals, context=None):
891 if vals.get('stage_id') and not vals.get('probability'):
892 # change probability of lead(s) if required by stage
893 stage = self.pool.get('crm.case.stage').browse(cr, uid, vals['stage_id'], context=context)
895 vals['probability'] = stage.probability
896 return super(crm_lead, self).write(cr, uid, ids, vals, context=context)
898 def new_mail_send(self, cr, uid, ids, context=None):
900 This function opens a window to compose an email, with the edi sale template message loaded by default
902 assert len(ids) == 1, 'This option should only be used for a single id at a time.'
903 ir_model_data = self.pool.get('ir.model.data')
905 template_id = ir_model_data.get_object_reference(cr, uid, 'crm', 'email_template_opportunity_mail')[1]
909 compose_form_id = ir_model_data.get_object_reference(cr, uid, 'mail', 'email_compose_message_wizard_form')[1]
911 compose_form_id = False
916 'default_model': 'crm.lead',
917 'default_res_id': ids[0],
918 'default_use_template': bool(template_id),
919 'default_template_id': template_id,
920 'default_composition_mode': 'comment',
923 'name': _('Compose Email'),
924 'type': 'ir.actions.act_window',
927 'res_model': 'mail.compose.message',
928 'views': [(compose_form_id, 'form')],
929 'view_id': compose_form_id,
934 # ----------------------------------------
936 # ----------------------------------------
938 def message_get_reply_to(self, cr, uid, ids, context=None):
939 """ Override to get the reply_to of the parent project. """
940 return [lead.section_id.message_get_reply_to()[0] if lead.section_id else False
941 for lead in self.browse(cr, SUPERUSER_ID, ids, context=context)]
943 def _get_formview_action(self, cr, uid, id, context=None):
944 action = super(crm_lead, self)._get_formview_action(cr, uid, id, context=context)
945 obj = self.browse(cr, uid, id, context=context)
946 if obj.type == 'opportunity':
947 model, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
949 'views': [(view_id, 'form')],
953 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
954 recipients = super(crm_lead, self).message_get_suggested_recipients(cr, uid, ids, context=context)
956 for lead in self.browse(cr, uid, ids, context=context):
958 self._message_add_suggested_recipient(cr, uid, recipients, lead, partner=lead.partner_id, reason=_('Customer'))
959 elif lead.email_from:
960 self._message_add_suggested_recipient(cr, uid, recipients, lead, email=lead.email_from, reason=_('Customer Email'))
961 except (osv.except_osv, orm.except_orm): # no read access rights -> just ignore suggested recipients because this imply modifying followers
965 def message_new(self, cr, uid, msg, custom_values=None, context=None):
966 """ Overrides mail_thread message_new that is called by the mailgateway
967 through message_process.
968 This override updates the document according to the email.
970 if custom_values is None:
972 desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
974 'name': msg.get('subject') or _("No Subject"),
976 'email_from': msg.get('from'),
977 'email_cc': msg.get('cc'),
978 'partner_id': msg.get('author_id', False),
981 if msg.get('author_id'):
982 defaults.update(self.onchange_partner_id(cr, uid, None, msg.get('author_id'), context=context)['value'])
983 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
984 defaults['priority'] = msg.get('priority')
985 defaults.update(custom_values)
986 return super(crm_lead, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
988 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
989 """ Overrides mail_thread message_update that is called by the mailgateway
990 through message_process.
991 This method updates the document according to the email.
993 if isinstance(ids, (str, int, long)):
995 if update_vals is None:
997 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
998 update_vals['priority'] = msg.get('priority')
1000 'cost': 'planned_cost',
1001 'revenue': 'planned_revenue',
1002 'probability': 'probability',
1004 for line in msg.get('body', '').split('\n'):
1006 res = tools.command_re.match(line)
1007 if res and maps.get(res.group(1).lower()):
1008 key = maps.get(res.group(1).lower())
1009 update_vals[key] = res.group(2).lower()
1011 return super(crm_lead, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1013 # ----------------------------------------
1014 # OpenChatter methods and notifications
1015 # ----------------------------------------
1017 def schedule_phonecall_send_note(self, cr, uid, ids, phonecall_id, action, context=None):
1018 phonecall = self.pool.get('crm.phonecall').browse(cr, uid, [phonecall_id], context=context)[0]
1022 prefix = 'Scheduled'
1023 suffix = ' %s' % phonecall.description
1024 message = _("%s a call for %s.%s") % (prefix, phonecall.date, suffix)
1025 return self.message_post(cr, uid, ids, body=message, context=context)
1027 def log_meeting(self, cr, uid, ids, meeting_subject, meeting_date, duration, context=None):
1029 duration = _('unknown')
1031 duration = str(duration)
1032 message = _("Meeting scheduled at '%s'<br> Subject: %s <br> Duration: %s hour(s)") % (meeting_date, meeting_subject, duration)
1033 return self.message_post(cr, uid, ids, body=message, context=context)
1035 def onchange_state(self, cr, uid, ids, state_id, context=None):
1037 country_id=self.pool.get('res.country.state').browse(cr, uid, state_id, context).country_id.id
1038 return {'value':{'country_id':country_id}}
1041 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: