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 openerp.osv import fields, osv
27 from openerp import tools
28 from openerp.tools.translate import _
29 from openerp.tools import html2plaintext
31 from base.res.res_partner import format_address
33 CRM_LEAD_FIELDS_TO_MERGE = ['name',
63 CRM_LEAD_PENDING_STATES = (
64 crm.AVAILABLE_STATES[2][0], # Cancelled
65 crm.AVAILABLE_STATES[3][0], # Done
66 crm.AVAILABLE_STATES[4][0], # Pending
69 class crm_lead(base_stage, format_address, osv.osv):
72 _description = "Lead/Opportunity"
73 _order = "priority,date_action,id desc"
74 _inherit = ['mail.thread', 'ir.needaction_mixin']
78 'crm.mt_lead_won': lambda self, cr, uid, obj, ctx=None: obj.stage_id and obj.stage_id.state == 'done' and obj.probability == 100.0,
79 'crm.mt_lead_lost': lambda self, cr, uid, obj, ctx=None: obj.stage_id and obj.stage_id.state == 'cancel' and obj.probability == 0.0,
80 'crm.mt_lead_stage': lambda self, cr, uid, obj, ctx=None: obj.stage_id and obj.stage_id.state not in ['cancel', 'done'],
84 def _get_default_section_id(self, cr, uid, context=None):
85 """ Gives default section by checking if present in the context """
86 return (self._resolve_section_id_from_context(cr, uid, context=context) or False)
88 def _get_default_stage_id(self, cr, uid, context=None):
89 """ Gives default stage_id """
90 section_id = self._get_default_section_id(cr, uid, context=context)
91 return self.stage_find(cr, uid, [], section_id, [('state', '=', 'draft')], context=context)
93 def _resolve_section_id_from_context(self, cr, uid, context=None):
94 """ Returns ID of section based on the value of 'section_id'
95 context key, or None if it cannot be resolved to a single
100 if type(context.get('default_section_id')) in (int, long):
101 return context.get('default_section_id')
102 if isinstance(context.get('default_section_id'), basestring):
103 section_name = context['default_section_id']
104 section_ids = self.pool.get('crm.case.section').name_search(cr, uid, name=section_name, context=context)
105 if len(section_ids) == 1:
106 return int(section_ids[0][0])
109 def _resolve_type_from_context(self, cr, uid, context=None):
110 """ Returns the type (lead or opportunity) from the type context
111 key. Returns None if it cannot be resolved.
115 return context.get('default_type')
117 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
118 access_rights_uid = access_rights_uid or uid
119 stage_obj = self.pool.get('crm.case.stage')
120 order = stage_obj._order
121 # lame hack to allow reverting search, should just work in the trivial case
122 if read_group_order == 'stage_id desc':
123 order = "%s desc" % order
124 # retrieve section_id from the context and write the domain
125 # - ('id', 'in', 'ids'): add columns that should be present
126 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
127 # - OR ('section_ids', '=', section_id), ('fold', '=', False) if section_id: add section columns that are not folded
129 section_id = self._resolve_section_id_from_context(cr, uid, context=context)
131 search_domain += ['|', ('section_ids', '=', section_id)]
132 search_domain += [('id', 'in', ids)]
134 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
135 # retrieve type from the context (if set: choose 'type' or 'both')
136 type = self._resolve_type_from_context(cr, uid, context=context)
138 search_domain += ['|', ('type', '=', type), ('type', '=', 'both')]
140 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
141 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
142 # restore order of the search
143 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
146 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
147 fold[stage.id] = stage.fold or False
150 def fields_view_get(self, cr, user, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
151 res = super(crm_lead,self).fields_view_get(cr, user, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
152 if view_type == 'form':
153 res['arch'] = self.fields_view_get_address(cr, user, res['arch'], context=context)
157 'stage_id': _read_group_stage_ids
160 def _compute_day(self, cr, uid, ids, fields, args, context=None):
162 :return dict: difference between current date and log date
164 cal_obj = self.pool.get('resource.calendar')
165 res_obj = self.pool.get('resource.resource')
168 for lead in self.browse(cr, uid, ids, context=context):
173 if field == 'day_open':
175 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
176 date_open = datetime.strptime(lead.date_open, "%Y-%m-%d %H:%M:%S")
177 ans = date_open - date_create
178 date_until = lead.date_open
179 elif field == 'day_close':
181 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
182 date_close = datetime.strptime(lead.date_closed, "%Y-%m-%d %H:%M:%S")
183 date_until = lead.date_closed
184 ans = date_close - date_create
188 resource_ids = res_obj.search(cr, uid, [('user_id','=',lead.user_id.id)])
189 if len(resource_ids):
190 resource_id = resource_ids[0]
192 duration = float(ans.days)
193 if lead.section_id and lead.section_id.resource_calendar_id:
194 duration = float(ans.days) * 24
195 new_dates = cal_obj.interval_get(cr,
197 lead.section_id.resource_calendar_id and lead.section_id.resource_calendar_id.id or False,
198 datetime.strptime(lead.create_date, '%Y-%m-%d %H:%M:%S'),
203 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
204 for in_time, out_time in new_dates:
205 if in_time.date not in no_days:
206 no_days.append(in_time.date)
207 if out_time > date_until:
209 duration = len(no_days)
210 res[lead.id][field] = abs(int(duration))
213 def _history_search(self, cr, uid, obj, name, args, context=None):
215 msg_obj = self.pool.get('mail.message')
216 message_ids = msg_obj.search(cr, uid, [('email_from','!=',False), ('subject', args[0][1], args[0][2])], context=context)
217 lead_ids = self.search(cr, uid, [('message_ids', 'in', message_ids)], context=context)
220 return [('id', 'in', lead_ids)]
222 return [('id', '=', '0')]
225 'partner_id': fields.many2one('res.partner', 'Partner', ondelete='set null',
226 select=True, help="Linked partner (optional). Usually created when converting the lead."),
228 'id': fields.integer('ID', readonly=True),
229 'name': fields.char('Subject', size=64, required=True, select=1),
230 'active': fields.boolean('Active', required=False),
231 'date_action_last': fields.datetime('Last Action', readonly=1),
232 'date_action_next': fields.datetime('Next Action', readonly=1),
233 'email_from': fields.char('Email', size=128, help="Email address of the contact", select=1),
234 'section_id': fields.many2one('crm.case.section', 'Sales Team',
235 select=True, track_visibility=1, help='When sending mails, the default email address is taken from the sales team.'),
236 'create_date': fields.datetime('Creation Date' , readonly=True),
237 '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"),
238 'description': fields.text('Notes'),
239 'write_date': fields.datetime('Update Date' , readonly=True),
240 'categ_ids': fields.many2many('crm.case.categ', 'crm_lead_category_rel', 'lead_id', 'category_id', 'Categories', \
241 domain="['|',('section_id','=',section_id),('section_id','=',False), ('object_id.model', '=', 'crm.lead')]"),
242 'type_id': fields.many2one('crm.case.resource.type', 'Campaign', \
243 domain="['|',('section_id','=',section_id),('section_id','=',False)]", help="From which campaign (seminar, marketing campaign, mass mailing, ...) did this contact come from?"),
244 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel (mail, direct, phone, ...)"),
245 'contact_name': fields.char('Contact Name', size=64),
246 '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),
247 'opt_out': fields.boolean('Opt-Out', oldname='optout', help="If opt-out is checked, this contact has refused to receive emails or unsubscribed to a campaign."),
248 'type':fields.selection([ ('lead','Lead'), ('opportunity','Opportunity'), ],'Type', help="Type is used to separate Leads and Opportunities"),
249 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
250 'date_closed': fields.datetime('Closed', readonly=True),
251 'stage_id': fields.many2one('crm.case.stage', 'Stage', track_visibility=1,
252 domain="[('fold', '=', False), ('section_ids', '=', section_id), '|', ('type', '=', type), ('type', '=', 'both')]"),
253 'user_id': fields.many2one('res.users', 'Salesperson', track_visibility=1),
254 'referred': fields.char('Referred By', size=64),
255 'date_open': fields.datetime('Opened', readonly=True),
256 'day_open': fields.function(_compute_day, string='Days to Open', \
257 multi='day_open', type="float", store=True),
258 'day_close': fields.function(_compute_day, string='Days to Close', \
259 multi='day_close', type="float", store=True),
260 'state': fields.related('stage_id', 'state', type="selection", store=True,
261 selection=crm.AVAILABLE_STATES, string="Status", readonly=True,
262 help='The Status is set to \'Draft\', when a case is created. If the case is in progress the Status is set to \'Open\'. When the case is over, the Status is set to \'Done\'. If the case needs to be reviewed then the Status is set to \'Pending\'.'),
264 # Only used for type opportunity
265 'probability': fields.float('Success Rate (%)',group_operator="avg"),
266 'planned_revenue': fields.float('Expected Revenue', track_visibility=2),
267 'ref': fields.reference('Reference', selection=crm._links_get, size=128),
268 'ref2': fields.reference('Reference 2', selection=crm._links_get, size=128),
269 'phone': fields.char("Phone", size=64),
270 'date_deadline': fields.date('Expected Closing', help="Estimate of the date on which the opportunity will be won."),
271 'date_action': fields.date('Next Action Date', select=True),
272 'title_action': fields.char('Next Action', size=64),
273 'color': fields.integer('Color Index'),
274 'partner_address_name': fields.related('partner_id', 'name', type='char', string='Partner Contact Name', readonly=True),
275 'partner_address_email': fields.related('partner_id', 'email', type='char', string='Partner Contact Email', readonly=True),
276 'company_currency': fields.related('company_id', 'currency_id', type='many2one', string='Currency', readonly=True, relation="res.currency"),
277 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
278 'user_login': fields.related('user_id', 'login', type='char', string='User Login', readonly=True),
280 # Fields for address, due to separation from crm and res.partner
281 'street': fields.char('Street', size=128),
282 'street2': fields.char('Street2', size=128),
283 'zip': fields.char('Zip', change_default=True, size=24),
284 'city': fields.char('City', size=128),
285 'state_id': fields.many2one("res.country.state", 'State'),
286 'country_id': fields.many2one('res.country', 'Country'),
287 'phone': fields.char('Phone', size=64),
288 'fax': fields.char('Fax', size=64),
289 'mobile': fields.char('Mobile', size=64),
290 'function': fields.char('Function', size=128),
291 'title': fields.many2one('res.partner.title', 'Title'),
292 'company_id': fields.many2one('res.company', 'Company', select=1),
293 'payment_mode': fields.many2one('crm.payment.mode', 'Payment Mode', \
294 domain="[('section_id','=',section_id)]"),
295 'planned_cost': fields.float('Planned Costs'),
301 'user_id': lambda s, cr, uid, c: s._get_default_user(cr, uid, c),
302 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
303 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
304 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
305 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.lead', context=c),
306 'priority': lambda *a: crm.AVAILABLE_PRIORITIES[2][0],
311 ('check_probability', 'check(probability >= 0 and probability <= 100)', 'The probability of closing the deal should be between 0% and 100%!')
314 def create(self, cr, uid, vals, context=None):
315 obj_id = super(crm_lead, self).create(cr, uid, vals, context)
316 section_id = self.browse(cr, uid, obj_id, context=context).section_id
318 # subscribe salesteam followers & subtypes to the lead
319 self._subscribe_followers_subtype(cr, uid, [obj_id], section_id, 'crm.case.section', context=context)
322 def onchange_stage_id(self, cr, uid, ids, stage_id, context=None):
325 stage = self.pool.get('crm.case.stage').browse(cr, uid, stage_id, context)
326 if not stage.on_change:
328 return {'value':{'probability': stage.probability}}
330 def on_change_partner(self, cr, uid, ids, partner_id, context=None):
334 partner = self.pool.get('res.partner').browse(cr, uid, partner_id, context=context)
336 'partner_name' : partner.name,
337 'street' : partner.street,
338 'street2' : partner.street2,
339 'city' : partner.city,
340 'state_id' : partner.state_id and partner.state_id.id or False,
341 'country_id' : partner.country_id and partner.country_id.id or False,
342 'email_from' : partner.email,
343 'phone' : partner.phone,
344 'mobile' : partner.mobile,
347 return {'value' : values}
349 def _check(self, cr, uid, ids=False, context=None):
350 """ Override of the base.stage method.
351 Function called by the scheduler to process cases for date actions
352 Only works on not done and cancelled cases
354 cr.execute('select * from crm_case \
355 where (date_action_last<%s or date_action_last is null) \
356 and (date_action_next<=%s or date_action_next is null) \
357 and state not in (\'cancel\',\'done\')',
358 (time.strftime("%Y-%m-%d %H:%M:%S"),
359 time.strftime('%Y-%m-%d %H:%M:%S')))
361 ids2 = map(lambda x: x[0], cr.fetchall() or [])
362 cases = self.browse(cr, uid, ids2, context=context)
363 return self._action(cr, uid, cases, False, context=context)
365 def stage_find(self, cr, uid, cases, section_id, domain=None, order='sequence', context=None):
366 """ Override of the base.stage method
367 Parameter of the stage search taken from the lead:
368 - type: stage type must be the same or 'both'
369 - section_id: if set, stages must belong to this section or
370 be a default stage; if not set, stages must be default
373 if isinstance(cases, (int, long)):
374 cases = self.browse(cr, uid, cases, context=context)
375 # collect all section_ids
379 type = context.get('default_type')
382 section_ids.append(section_id)
385 section_ids.append(lead.section_id.id)
386 if lead.type not in types:
387 types.append(lead.type)
388 # OR all section_ids and OR with case_default
391 search_domain += [('|')] * len(section_ids)
392 for section_id in section_ids:
393 search_domain.append(('section_ids', '=', section_id))
394 search_domain.append(('case_default', '=', True))
395 # AND with cases types
396 search_domain.append(('type', 'in', types))
397 # AND with the domain in parameter
398 search_domain += list(domain)
399 # perform search, return the first found
400 stage_ids = self.pool.get('crm.case.stage').search(cr, uid, search_domain, order=order, context=context)
405 def case_cancel(self, cr, uid, ids, context=None):
406 """ Overrides case_cancel from base_stage to set probability """
407 res = super(crm_lead, self).case_cancel(cr, uid, ids, context=context)
408 self.write(cr, uid, ids, {'probability' : 0.0}, context=context)
411 def case_reset(self, cr, uid, ids, context=None):
412 """ Overrides case_reset from base_stage to set probability """
413 res = super(crm_lead, self).case_reset(cr, uid, ids, context=context)
414 self.write(cr, uid, ids, {'probability': 0.0}, context=context)
417 def case_mark_lost(self, cr, uid, ids, context=None):
418 """ Mark the case as lost: state=cancel and probability=0 """
419 for lead in self.browse(cr, uid, ids):
420 stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 0.0)], context=context)
422 self.case_set(cr, uid, [lead.id], values_to_update={'probability': 0.0}, new_stage_id=stage_id, context=context)
425 def case_mark_won(self, cr, uid, ids, context=None):
426 """ Mark the case as lost: state=done and probability=100 """
427 for lead in self.browse(cr, uid, ids):
428 stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 100.0)], context=context)
430 self.case_set(cr, uid, [lead.id], values_to_update={'probability': 100.0}, new_stage_id=stage_id, context=context)
433 def set_priority(self, cr, uid, ids, priority):
434 """ Set lead priority
436 return self.write(cr, uid, ids, {'priority' : priority})
438 def set_high_priority(self, cr, uid, ids, context=None):
439 """ Set lead priority to high
441 return self.set_priority(cr, uid, ids, '1')
443 def set_normal_priority(self, cr, uid, ids, context=None):
444 """ Set lead priority to normal
446 return self.set_priority(cr, uid, ids, '3')
448 def _merge_get_result_type(self, cr, uid, opps, context=None):
450 Define the type of the result of the merge. If at least one of the
451 element to merge is an opp, the resulting new element will be an opp.
452 Otherwise it will be a lead.
454 We'll directly use a list of browse records instead of a list of ids
455 for performances' sake: it will spare a second browse of the
458 :param list opps: list of browse records containing the leads/opps to process
459 :return string type: the type of the final element
462 if (opp.type == 'opportunity'):
467 def _merge_data(self, cr, uid, ids, oldest, fields, context=None):
469 Prepare lead/opp data into a dictionary for merging. Different types
470 of fields are processed in different ways:
471 - text: all the values are concatenated
472 - m2m and o2m: those fields aren't processed
473 - m2o: the first not null value prevails (the other are dropped)
474 - any other type of field: same as m2o
476 :param list ids: list of ids of the leads to process
477 :param list fields: list of leads' fields to process
478 :return dict data: contains the merged values
480 opportunities = self.browse(cr, uid, ids, context=context)
482 def _get_first_not_null(attr):
483 if hasattr(oldest, attr):
484 return getattr(oldest, attr)
485 for opp in opportunities:
486 if hasattr(opp, attr):
487 return getattr(opp, attr)
490 def _get_first_not_null_id(attr):
491 res = _get_first_not_null(attr)
492 return res and res.id or False
494 def _concat_all(attr):
495 return ', '.join(filter(lambda x: x, [getattr(opp, attr) or '' for opp in opportunities if hasattr(opp, attr)]))
497 # Process the fields' values
499 for field_name in fields:
500 field_info = self._all_columns.get(field_name)
501 if field_info is None:
503 field = field_info.column
504 if field._type in ('many2many', 'one2many'):
506 elif field._type == 'many2one':
507 data[field_name] = _get_first_not_null_id(field_name) # !!
508 elif field._type == 'text':
509 data[field_name] = _concat_all(field_name) #not lost
511 data[field_name] = _get_first_not_null(field_name) #not lost
513 # Define the resulting type ('lead' or 'opportunity')
514 data['type'] = self._merge_get_result_type(cr, uid, opportunities, context)
518 def _merge_find_oldest(self, cr, uid, ids, context=None):
520 Return the oldest lead found among ids.
522 :param list ids: list of ids of the leads to inspect
523 :return object: browse record of the oldest of the leads
528 if context.get('convert'):
529 ids = list(set(ids) - set(context.get('lead_ids', [])))
531 # Search opportunities order by create date
532 opportunity_ids = self.search(cr, uid, [('id', 'in', ids)], order='create_date', context=context)
533 oldest_opp_id = opportunity_ids[0]
534 return self.browse(cr, uid, oldest_opp_id, context=context)
536 def _mail_body(self, cr, uid, lead, fields, title=False, context=None):
539 body.append("%s\n" % (title))
541 for field_name in fields:
542 field_info = self._all_columns.get(field_name)
543 if field_info is None:
545 field = field_info.column
548 if field._type == 'selection':
549 if hasattr(field.selection, '__call__'):
550 key = field.selection(self, cr, uid, context=context)
552 key = field.selection
553 value = dict(key).get(lead[field_name], lead[field_name])
554 elif field._type == 'many2one':
556 value = lead[field_name].name_get()[0][1]
557 elif field._type == 'many2many':
559 for val in lead[field_name]:
560 field_value = val.name_get()[0][1]
561 value += field_value + ","
563 value = lead[field_name]
565 body.append("%s: %s" % (field.string, value or ''))
566 return "<br/>".join(body + ['<br/>'])
568 def _merge_notify(self, cr, uid, opportunity_id, opportunities, context=None):
570 Create a message gathering merged leads/opps information.
572 #TOFIX: mail template should be used instead of fix body, subject text
574 result_type = self._merge_get_result_type(cr, uid, opportunities, context)
575 if result_type == 'lead':
576 merge_message = _('Merged leads')
578 merge_message = _('Merged opportunities')
579 subject = [merge_message]
580 for opportunity in opportunities:
581 subject.append(opportunity.name)
582 title = "%s : %s" % (merge_message, opportunity.name)
583 details.append(self._mail_body(cr, uid, opportunity, CRM_LEAD_FIELDS_TO_MERGE, title=title, context=context))
585 # Chatter message's subject
586 subject = subject[0] + ": " + ", ".join(subject[1:])
587 details = "\n\n".join(details)
588 return self.message_post(cr, uid, [opportunity_id], body=details, subject=subject, context=context)
590 def _merge_opportunity_history(self, cr, uid, opportunity_id, opportunities, context=None):
591 message = self.pool.get('mail.message')
592 for opportunity in opportunities:
593 for history in opportunity.message_ids:
594 message.write(cr, uid, history.id, {
595 'res_id': opportunity_id,
596 'subject' : _("From %s : %s") % (opportunity.name, history.subject)
601 def _merge_opportunity_attachments(self, cr, uid, opportunity_id, opportunities, context=None):
602 attachment = self.pool.get('ir.attachment')
604 # return attachments of opportunity
605 def _get_attachments(opportunity_id):
606 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', opportunity_id)], context=context)
607 return attachment.browse(cr, uid, attachment_ids, context=context)
610 first_attachments = _get_attachments(opportunity_id)
611 for opportunity in opportunities:
612 attachments = _get_attachments(opportunity.id)
613 for first in first_attachments:
614 for attachment in attachments:
615 if attachment.name == first.name:
617 name = "%s (%s)" % (attachment.name, count,),
618 res_id = opportunity_id,
620 attachment.write(values)
625 def merge_opportunity(self, cr, uid, ids, 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
634 if context is None: context = {}
637 raise osv.except_osv(_('Warning!'),_('Please select more than one element (lead or opportunity) from the list view.'))
639 lead_ids = context.get('lead_ids', [])
641 ctx_opportunities = self.browse(cr, uid, lead_ids, context=context)
642 opportunities = self.browse(cr, uid, ids, context=context)
643 opportunities_list = list(set(opportunities) - set(ctx_opportunities))
644 oldest = self._merge_find_oldest(cr, uid, ids, context=context)
645 if ctx_opportunities:
646 first_opportunity = ctx_opportunities[0]
647 tail_opportunities = opportunities_list + ctx_opportunities[1:]
649 first_opportunity = opportunities_list[0]
650 tail_opportunities = opportunities_list[1:]
652 merged_data = self._merge_data(cr, uid, ids, oldest, CRM_LEAD_FIELDS_TO_MERGE, context=context)
654 # Merge messages and attachements into the first opportunity
655 self._merge_opportunity_history(cr, uid, first_opportunity.id, tail_opportunities, context=context)
656 self._merge_opportunity_attachments(cr, uid, first_opportunity.id, tail_opportunities, context=context)
658 # Merge notifications about loss of information
659 self._merge_notify(cr, uid, first_opportunity, opportunities, context=context)
660 # Write merged data into first opportunity
661 self.write(cr, uid, [first_opportunity.id], merged_data, context=context)
662 # Delete tail opportunities
663 self.unlink(cr, uid, [x.id for x in tail_opportunities], context=context)
665 # Open first opportunity
666 self.case_open(cr, uid, [first_opportunity.id])
667 return first_opportunity.id
669 def _convert_opportunity_data(self, cr, uid, lead, customer, section_id=False, context=None):
670 crm_stage = self.pool.get('crm.case.stage')
673 contact_id = self.pool.get('res.partner').address_get(cr, uid, [customer.id])['default']
676 section_id = lead.section_id and lead.section_id.id or False
679 stage_ids = crm_stage.search(cr, uid, [('sequence','>=',1), ('section_ids','=', section_id)])
681 stage_ids = crm_stage.search(cr, uid, [('sequence','>=',1)])
682 stage_id = stage_ids and stage_ids[0] or False
685 'planned_revenue': lead.planned_revenue,
686 'probability': lead.probability,
688 'partner_id': customer and customer.id or False,
689 'user_id': (lead.user_id and lead.user_id.id),
690 'type': 'opportunity',
691 'stage_id': stage_id or False,
692 'date_action': fields.datetime.now(),
693 'date_open': fields.datetime.now(),
694 'email_from': customer and customer.email or lead.email_from,
695 'phone': customer and customer.phone or lead.phone,
698 def convert_opportunity(self, cr, uid, ids, partner_id, user_ids=False, section_id=False, context=None):
701 partner = self.pool.get('res.partner')
702 customer = partner.browse(cr, uid, partner_id, context=context)
703 for lead in self.browse(cr, uid, ids, context=context):
704 if lead.state in ('done', 'cancel'):
706 vals = self._convert_opportunity_data(cr, uid, lead, customer, section_id, context=context)
707 self.write(cr, uid, [lead.id], vals, context=context)
708 self.message_post(cr, uid, ids, body=_("Lead has been <b>converted to an opportunity</b>."), subtype="crm.mt_lead_convert_to_opportunity", context=context)
710 if user_ids or section_id:
711 self.allocate_salesman(cr, uid, ids, user_ids, section_id, context=context)
715 def _lead_create_contact(self, cr, uid, lead, name, is_company, parent_id=False, context=None):
716 partner = self.pool.get('res.partner')
717 vals = { 'name': name,
718 'user_id': lead.user_id.id,
719 'comment': lead.description,
720 'section_id': lead.section_id.id or False,
721 'parent_id': parent_id,
723 'mobile': lead.mobile,
724 'email': lead.email_from and tools.email_split(lead.email_from)[0],
726 'title': lead.title and lead.title.id or False,
727 'function': lead.function,
728 'street': lead.street,
729 'street2': lead.street2,
732 'country_id': lead.country_id and lead.country_id.id or False,
733 'state_id': lead.state_id and lead.state_id.id or False,
734 'is_company': is_company,
737 partner = partner.create(cr, uid,vals, context)
740 def _create_lead_partner(self, cr, uid, lead, context=None):
742 if lead.partner_name and lead.contact_name:
743 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
744 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, partner_id, context=context)
745 elif lead.partner_name and not lead.contact_name:
746 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
747 elif not lead.partner_name and lead.contact_name:
748 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, context=context)
750 partner_id = self._lead_create_contact(cr, uid, lead, lead.name, False, context=context)
753 def _lead_set_partner(self, cr, uid, lead, partner_id, context=None):
755 Assign a partner to a lead.
757 :param object lead: browse record of the lead to process
758 :param int partner_id: identifier of the partner to assign
759 :return bool: True if the partner has properly been assigned
762 res_partner = self.pool.get('res.partner')
764 res_partner.write(cr, uid, partner_id, {'section_id': lead.section_id.id or False})
765 contact_id = res_partner.address_get(cr, uid, [partner_id])['default']
766 res = lead.write({'partner_id': partner_id}, context=context)
767 message = _("%s <b>partner</b> is now set to <em>%s</em>." % ('Opportunity' if lead.type == 'opportunity' else 'Lead', lead.partner_id.name))
768 self.message_post(cr, uid, [lead.id], body=message, context=context)
771 def handle_partner_assignation(self, cr, uid, ids, action='create', partner_id=False, context=None):
773 Handle partner assignation during a lead conversion.
774 if action is 'create', create new partner with contact and assign lead to new partner_id.
775 otherwise assign lead to the specified partner_id
777 :param list ids: leads/opportunities ids to process
778 :param string action: what has to be done regarding partners (create it, assign an existing one, or nothing)
779 :param int partner_id: partner to assign if any
780 :return dict: dictionary organized as followed: {lead_id: partner_assigned_id}
782 #TODO this is a duplication of the handle_partner_assignation method of crm_phonecall
784 # If a partner_id is given, force this partner for all elements
785 force_partner_id = partner_id
786 for lead in self.browse(cr, uid, ids, context=context):
787 # If the action is set to 'create' and no partner_id is set, create a new one
788 if action == 'create':
789 partner_id = force_partner_id or self._create_lead_partner(cr, uid, lead, context)
790 self._lead_set_partner(cr, uid, lead, partner_id, context=context)
791 partner_ids[lead.id] = partner_id
794 def allocate_salesman(self, cr, uid, ids, user_ids=None, team_id=False, context=None):
796 Assign salesmen and salesteam to a batch of leads. If there are more
797 leads than salesmen, these salesmen will be assigned in round-robin.
798 E.g.: 4 salesmen (S1, S2, S3, S4) for 6 leads (L1, L2, ... L6). They
799 will be assigned as followed: L1 - S1, L2 - S2, L3 - S3, L4 - S4,
802 :param list ids: leads/opportunities ids to process
803 :param list user_ids: salesmen to assign
804 :param int team_id: salesteam to assign
812 value['section_id'] = team_id
814 value['user_id'] = user_ids[index]
815 # Cycle through user_ids
816 index = (index + 1) % len(user_ids)
818 self.write(cr, uid, [lead_id], value, context=context)
821 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):
823 :param string action: ('schedule','Schedule a call'), ('log','Log a call')
825 phonecall = self.pool.get('crm.phonecall')
826 model_data = self.pool.get('ir.model.data')
829 res_id = model_data._get_id(cr, uid, 'crm', 'categ_phone2')
831 categ_id = model_data.browse(cr, uid, res_id, context=context).res_id
832 for lead in self.browse(cr, uid, ids, context=context):
834 section_id = lead.section_id and lead.section_id.id or False
836 user_id = lead.user_id and lead.user_id.id or False
838 'name': call_summary,
839 'opportunity_id': lead.id,
840 'user_id': user_id or False,
841 'categ_id': categ_id or False,
842 'description': desc or '',
843 'date': schedule_time,
844 'section_id': section_id or False,
845 'partner_id': lead.partner_id and lead.partner_id.id or False,
846 'partner_phone': phone or lead.phone or (lead.partner_id and lead.partner_id.phone or False),
847 'partner_mobile': lead.partner_id and lead.partner_id.mobile or False,
848 'priority': lead.priority,
850 new_id = phonecall.create(cr, uid, vals, context=context)
851 phonecall.case_open(cr, uid, [new_id], context=context)
853 phonecall.case_close(cr, uid, [new_id], context=context)
854 phonecall_dict[lead.id] = new_id
855 self.schedule_phonecall_send_note(cr, uid, [lead.id], new_id, action, context=context)
856 return phonecall_dict
858 def redirect_opportunity_view(self, cr, uid, opportunity_id, context=None):
859 models_data = self.pool.get('ir.model.data')
861 # Get opportunity views
862 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
863 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_oppor')
865 'name': _('Opportunity'),
867 'view_mode': 'tree, form',
868 'res_model': 'crm.lead',
869 'domain': [('type', '=', 'opportunity')],
870 'res_id': int(opportunity_id),
872 'views': [(form_view or False, 'form'),
873 (tree_view or False, 'tree'),
874 (False, 'calendar'), (False, 'graph')],
875 'type': 'ir.actions.act_window',
878 def redirect_lead_view(self, cr, uid, lead_id, context=None):
879 models_data = self.pool.get('ir.model.data')
882 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_leads')
883 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_leads')
887 'view_mode': 'tree, form',
888 'res_model': 'crm.lead',
889 'domain': [('type', '=', 'lead')],
890 'res_id': int(lead_id),
892 'views': [(form_view or False, 'form'),
893 (tree_view or False, 'tree'),
894 (False, 'calendar'), (False, 'graph')],
895 'type': 'ir.actions.act_window',
898 def action_makeMeeting(self, cr, uid, ids, context=None):
900 Open meeting's calendar view to schedule meeting on current opportunity.
901 :return dict: dictionary value for created Meeting view
903 opportunity = self.browse(cr, uid, ids[0], context)
904 res = self.pool.get('ir.actions.act_window').for_xml_id(cr, uid, 'base_calendar', 'action_crm_meeting', context)
906 'default_opportunity_id': opportunity.id,
907 'default_partner_id': opportunity.partner_id and opportunity.partner_id.id or False,
908 'default_partner_ids' : opportunity.partner_id and [opportunity.partner_id.id] or False,
909 'default_user_id': uid,
910 'default_section_id': opportunity.section_id and opportunity.section_id.id or False,
911 'default_email_from': opportunity.email_from,
912 'default_name': opportunity.name,
916 def write(self, cr, uid, ids, vals, context=None):
917 if vals.get('stage_id') and not vals.get('probability'):
918 # change probability of lead(s) if required by stage
919 stage = self.pool.get('crm.case.stage').browse(cr, uid, vals['stage_id'], context=context)
921 vals['probability'] = stage.probability
922 if vals.get('section_id'):
923 section_id = self.pool.get('crm.case.section').browse(cr, uid, vals.get('section_id'), context=context)
924 vals.setdefault('message_follower_ids', [])
925 vals['message_follower_ids'] += [(6, 0,[follower.id]) for follower in section_id.message_follower_ids]
926 res = super(crm_lead,self).write(cr, uid, ids, vals, context)
927 # subscribe new salesteam followers & subtypes to the lead
928 if vals.get('section_id'):
929 self._subscribe_followers_subtype(cr, uid, ids, vals.get('section_id'), 'crm.case.section', context=context)
932 # ----------------------------------------
934 # ----------------------------------------
936 def message_new(self, cr, uid, msg, custom_values=None, context=None):
937 """ Overrides mail_thread message_new that is called by the mailgateway
938 through message_process.
939 This override updates the document according to the email.
941 if custom_values is None: custom_values = {}
943 desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
944 custom_values.update({
945 'name': msg.get('subject') or _("No Subject"),
947 'email_from': msg.get('from'),
948 'email_cc': msg.get('cc'),
951 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
952 custom_values['priority'] = msg.get('priority')
953 return super(crm_lead, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
955 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
956 """ Overrides mail_thread message_update that is called by the mailgateway
957 through message_process.
958 This method updates the document according to the email.
960 if isinstance(ids, (str, int, long)):
962 if update_vals is None: update_vals = {}
964 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
965 update_vals['priority'] = msg.get('priority')
967 'cost':'planned_cost',
968 'revenue': 'planned_revenue',
969 'probability':'probability',
971 for line in msg.get('body', '').split('\n'):
973 res = tools.command_re.match(line)
974 if res and maps.get(res.group(1).lower()):
975 key = maps.get(res.group(1).lower())
976 update_vals[key] = res.group(2).lower()
978 return super(crm_lead, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
980 # ----------------------------------------
981 # OpenChatter methods and notifications
982 # ----------------------------------------
984 def schedule_phonecall_send_note(self, cr, uid, ids, phonecall_id, action, context=None):
985 phonecall = self.pool.get('crm.phonecall').browse(cr, uid, [phonecall_id], context=context)[0]
986 if action == 'log': prefix = 'Logged'
987 else: prefix = 'Scheduled'
988 message = _("<b>%s a call</b> for the <em>%s</em>.") % (prefix, phonecall.date)
989 return self.message_post(cr, uid, ids, body=message, context=context)
991 def onchange_state(self, cr, uid, ids, state_id, context=None):
993 country_id=self.pool.get('res.country.state').browse(cr, uid, state_id, context).country_id.id
994 return {'value':{'country_id':country_id}}
997 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: