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']
76 def _get_default_section_id(self, cr, uid, context=None):
77 """ Gives default section by checking if present in the context """
78 return (self._resolve_section_id_from_context(cr, uid, context=context) or False)
80 def _get_default_stage_id(self, cr, uid, context=None):
81 """ Gives default stage_id """
82 section_id = self._get_default_section_id(cr, uid, context=context)
83 return self.stage_find(cr, uid, [], section_id, [('state', '=', 'draft')], context=context)
85 def _resolve_section_id_from_context(self, cr, uid, context=None):
86 """ Returns ID of section based on the value of 'section_id'
87 context key, or None if it cannot be resolved to a single
92 if type(context.get('default_section_id')) in (int, long):
93 return context.get('default_section_id')
94 if isinstance(context.get('default_section_id'), basestring):
95 section_name = context['default_section_id']
96 section_ids = self.pool.get('crm.case.section').name_search(cr, uid, name=section_name, context=context)
97 if len(section_ids) == 1:
98 return int(section_ids[0][0])
101 def _resolve_type_from_context(self, cr, uid, context=None):
102 """ Returns the type (lead or opportunity) from the type context
103 key. Returns None if it cannot be resolved.
107 return context.get('default_type')
109 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
110 access_rights_uid = access_rights_uid or uid
111 stage_obj = self.pool.get('crm.case.stage')
112 order = stage_obj._order
113 # lame hack to allow reverting search, should just work in the trivial case
114 if read_group_order == 'stage_id desc':
115 order = "%s desc" % order
116 # retrieve section_id from the context and write the domain
117 # - ('id', 'in', 'ids'): add columns that should be present
118 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
119 # - OR ('section_ids', '=', section_id), ('fold', '=', False) if section_id: add section columns that are not folded
121 section_id = self._resolve_section_id_from_context(cr, uid, context=context)
123 search_domain += ['|', ('section_ids', '=', section_id)]
124 search_domain += [('id', 'in', ids)]
126 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
127 # retrieve type from the context (if set: choose 'type' or 'both')
128 type = self._resolve_type_from_context(cr, uid, context=context)
130 search_domain += ['|', ('type', '=', type), ('type', '=', 'both')]
132 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
133 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
134 # restore order of the search
135 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
138 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
139 fold[stage.id] = stage.fold or False
142 def fields_view_get(self, cr, user, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
143 res = super(crm_lead,self).fields_view_get(cr, user, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
144 if view_type == 'form':
145 res['arch'] = self.fields_view_get_address(cr, user, res['arch'], context=context)
149 'stage_id': _read_group_stage_ids
152 def _compute_day(self, cr, uid, ids, fields, args, context=None):
154 :return dict: difference between current date and log date
156 cal_obj = self.pool.get('resource.calendar')
157 res_obj = self.pool.get('resource.resource')
160 for lead in self.browse(cr, uid, ids, context=context):
165 if field == 'day_open':
167 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
168 date_open = datetime.strptime(lead.date_open, "%Y-%m-%d %H:%M:%S")
169 ans = date_open - date_create
170 date_until = lead.date_open
171 elif field == 'day_close':
173 date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
174 date_close = datetime.strptime(lead.date_closed, "%Y-%m-%d %H:%M:%S")
175 date_until = lead.date_closed
176 ans = date_close - date_create
180 resource_ids = res_obj.search(cr, uid, [('user_id','=',lead.user_id.id)])
181 if len(resource_ids):
182 resource_id = resource_ids[0]
184 duration = float(ans.days)
185 if lead.section_id and lead.section_id.resource_calendar_id:
186 duration = float(ans.days) * 24
187 new_dates = cal_obj.interval_get(cr,
189 lead.section_id.resource_calendar_id and lead.section_id.resource_calendar_id.id or False,
190 datetime.strptime(lead.create_date, '%Y-%m-%d %H:%M:%S'),
195 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
196 for in_time, out_time in new_dates:
197 if in_time.date not in no_days:
198 no_days.append(in_time.date)
199 if out_time > date_until:
201 duration = len(no_days)
202 res[lead.id][field] = abs(int(duration))
205 def _history_search(self, cr, uid, obj, name, args, context=None):
207 msg_obj = self.pool.get('mail.message')
208 message_ids = msg_obj.search(cr, uid, [('email_from','!=',False), ('subject', args[0][1], args[0][2])], context=context)
209 lead_ids = self.search(cr, uid, [('message_ids', 'in', message_ids)], context=context)
212 return [('id', 'in', lead_ids)]
214 return [('id', '=', '0')]
217 'partner_id': fields.many2one('res.partner', 'Partner', ondelete='set null',
218 select=True, help="Linked partner (optional). Usually created when converting the lead."),
220 'id': fields.integer('ID', readonly=True),
221 'name': fields.char('Subject', size=64, required=True, select=1),
222 'active': fields.boolean('Active', required=False),
223 'date_action_last': fields.datetime('Last Action', readonly=1),
224 'date_action_next': fields.datetime('Next Action', readonly=1),
225 'email_from': fields.char('Email', size=128, help="Email address of the contact", select=1),
226 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
227 select=True, help='When sending mails, the default email address is taken from the sales team.'),
228 'create_date': fields.datetime('Creation Date' , readonly=True),
229 '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"),
230 'description': fields.text('Notes'),
231 'write_date': fields.datetime('Update Date' , readonly=True),
232 'categ_ids': fields.many2many('crm.case.categ', 'crm_lead_category_rel', 'lead_id', 'category_id', 'Categories', \
233 domain="['|',('section_id','=',section_id),('section_id','=',False), ('object_id.model', '=', 'crm.lead')]"),
234 'type_id': fields.many2one('crm.case.resource.type', 'Campaign', \
235 domain="['|',('section_id','=',section_id),('section_id','=',False)]", help="From which campaign (seminar, marketing campaign, mass mailing, ...) did this contact come from?"),
236 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel (mail, direct, phone, ...)"),
237 'contact_name': fields.char('Contact Name', size=64),
238 '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),
239 '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."),
240 'type':fields.selection([ ('lead','Lead'), ('opportunity','Opportunity'), ],'Type', help="Type is used to separate Leads and Opportunities"),
241 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
242 'date_closed': fields.datetime('Closed', readonly=True),
243 'stage_id': fields.many2one('crm.case.stage', 'Stage',
244 domain="[('fold', '=', False), ('section_ids', '=', section_id), '|', ('type', '=', type), ('type', '=', 'both')]"),
245 'user_id': fields.many2one('res.users', 'Salesperson'),
246 'referred': fields.char('Referred By', size=64),
247 'date_open': fields.datetime('Opened', readonly=True),
248 'day_open': fields.function(_compute_day, string='Days to Open', \
249 multi='day_open', type="float", store=True),
250 'day_close': fields.function(_compute_day, string='Days to Close', \
251 multi='day_close', type="float", store=True),
252 'state': fields.related('stage_id', 'state', type="selection", store=True,
253 selection=crm.AVAILABLE_STATES, string="Status", readonly=True,
254 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\'.'),
256 # Only used for type opportunity
257 'probability': fields.float('Success Rate (%)',group_operator="avg"),
258 'planned_revenue': fields.float('Expected Revenue'),
259 'ref': fields.reference('Reference', selection=crm._links_get, size=128),
260 'ref2': fields.reference('Reference 2', selection=crm._links_get, size=128),
261 'phone': fields.char("Phone", size=64),
262 'date_deadline': fields.date('Expected Closing', help="Estimate of the date on which the opportunity will be won."),
263 'date_action': fields.date('Next Action Date', select=True),
264 'title_action': fields.char('Next Action', size=64),
265 'color': fields.integer('Color Index'),
266 'partner_address_name': fields.related('partner_id', 'name', type='char', string='Partner Contact Name', readonly=True),
267 'partner_address_email': fields.related('partner_id', 'email', type='char', string='Partner Contact Email', readonly=True),
268 'company_currency': fields.related('company_id', 'currency_id', type='many2one', string='Currency', readonly=True, relation="res.currency"),
269 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
270 'user_login': fields.related('user_id', 'login', type='char', string='User Login', readonly=True),
272 # Fields for address, due to separation from crm and res.partner
273 'street': fields.char('Street', size=128),
274 'street2': fields.char('Street2', size=128),
275 'zip': fields.char('Zip', change_default=True, size=24),
276 'city': fields.char('City', size=128),
277 'state_id': fields.many2one("res.country.state", 'State'),
278 'country_id': fields.many2one('res.country', 'Country'),
279 'phone': fields.char('Phone', size=64),
280 'fax': fields.char('Fax', size=64),
281 'mobile': fields.char('Mobile', size=64),
282 'function': fields.char('Function', size=128),
283 'title': fields.many2one('res.partner.title', 'Title'),
284 'company_id': fields.many2one('res.company', 'Company', select=1),
285 'payment_mode': fields.many2one('crm.payment.mode', 'Payment Mode', \
286 domain="[('section_id','=',section_id)]"),
287 'planned_cost': fields.float('Planned Costs'),
293 'user_id': lambda s, cr, uid, c: s._get_default_user(cr, uid, c),
294 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
295 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
296 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
297 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.lead', context=c),
298 'priority': lambda *a: crm.AVAILABLE_PRIORITIES[2][0],
303 ('check_probability', 'check(probability >= 0 and probability <= 100)', 'The probability of closing the deal should be between 0% and 100%!')
306 def create(self, cr, uid, vals, context=None):
307 obj_id = super(crm_lead, self).create(cr, uid, vals, context)
308 section_id = self.browse(cr, uid, obj_id, context=context).section_id
310 followers = [follow.id for follow in section_id.message_follower_ids]
311 self.message_subscribe(cr, uid, [obj_id], followers, context=context)
312 self.create_send_note(cr, uid, [obj_id], context=context)
315 def onchange_stage_id(self, cr, uid, ids, stage_id, context=None):
318 stage = self.pool.get('crm.case.stage').browse(cr, uid, stage_id, context)
319 if not stage.on_change:
321 return {'value':{'probability': stage.probability}}
323 def on_change_partner(self, cr, uid, ids, partner_id, context=None):
327 partner = self.pool.get('res.partner').browse(cr, uid, partner_id, context=context)
329 'partner_name' : partner.name,
330 'street' : partner.street,
331 'street2' : partner.street2,
332 'city' : partner.city,
333 'state_id' : partner.state_id and partner.state_id.id or False,
334 'country_id' : partner.country_id and partner.country_id.id or False,
335 'email_from' : partner.email,
336 'phone' : partner.phone,
337 'mobile' : partner.mobile,
340 return {'value' : values}
342 def _check(self, cr, uid, ids=False, context=None):
343 """ Override of the base.stage method.
344 Function called by the scheduler to process cases for date actions
345 Only works on not done and cancelled cases
347 cr.execute('select * from crm_case \
348 where (date_action_last<%s or date_action_last is null) \
349 and (date_action_next<=%s or date_action_next is null) \
350 and state not in (\'cancel\',\'done\')',
351 (time.strftime("%Y-%m-%d %H:%M:%S"),
352 time.strftime('%Y-%m-%d %H:%M:%S')))
354 ids2 = map(lambda x: x[0], cr.fetchall() or [])
355 cases = self.browse(cr, uid, ids2, context=context)
356 return self._action(cr, uid, cases, False, context=context)
358 def stage_find(self, cr, uid, cases, section_id, domain=None, order='sequence', context=None):
359 """ Override of the base.stage method
360 Parameter of the stage search taken from the lead:
361 - type: stage type must be the same or 'both'
362 - section_id: if set, stages must belong to this section or
363 be a default stage; if not set, stages must be default
366 if isinstance(cases, (int, long)):
367 cases = self.browse(cr, uid, cases, context=context)
368 # collect all section_ids
372 type = context.get('default_type')
375 section_ids.append(section_id)
378 section_ids.append(lead.section_id.id)
379 if lead.type not in types:
380 types.append(lead.type)
381 # OR all section_ids and OR with case_default
384 search_domain += [('|')] * len(section_ids)
385 for section_id in section_ids:
386 search_domain.append(('section_ids', '=', section_id))
387 search_domain.append(('case_default', '=', True))
388 # AND with cases types
389 search_domain.append(('type', 'in', types))
390 # AND with the domain in parameter
391 search_domain += list(domain)
392 # perform search, return the first found
393 stage_ids = self.pool.get('crm.case.stage').search(cr, uid, search_domain, order=order, context=context)
398 def case_cancel(self, cr, uid, ids, context=None):
399 """ Overrides case_cancel from base_stage to set probability """
400 res = super(crm_lead, self).case_cancel(cr, uid, ids, context=context)
401 self.write(cr, uid, ids, {'probability' : 0.0}, context=context)
404 def case_reset(self, cr, uid, ids, context=None):
405 """ Overrides case_reset from base_stage to set probability """
406 res = super(crm_lead, self).case_reset(cr, uid, ids, context=context)
407 self.write(cr, uid, ids, {'probability': 0.0}, context=context)
410 def case_mark_lost(self, cr, uid, ids, context=None):
411 """ Mark the case as lost: state=cancel and probability=0 """
412 for lead in self.browse(cr, uid, ids):
413 stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 0.0)], context=context)
415 self.case_set(cr, uid, [lead.id], values_to_update={'probability': 0.0}, new_stage_id=stage_id, context=context)
416 self.case_mark_lost_send_note(cr, uid, ids, context=context)
419 def case_mark_won(self, cr, uid, ids, context=None):
420 """ Mark the case as lost: state=done and probability=100 """
421 for lead in self.browse(cr, uid, ids):
422 stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 100.0)], context=context)
424 self.case_set(cr, uid, [lead.id], values_to_update={'probability': 100.0}, new_stage_id=stage_id, context=context)
425 self.case_mark_won_send_note(cr, uid, ids, context=context)
428 def set_priority(self, cr, uid, ids, priority):
429 """ Set lead priority
431 return self.write(cr, uid, ids, {'priority' : priority})
433 def set_high_priority(self, cr, uid, ids, context=None):
434 """ Set lead priority to high
436 return self.set_priority(cr, uid, ids, '1')
438 def set_normal_priority(self, cr, uid, ids, context=None):
439 """ Set lead priority to normal
441 return self.set_priority(cr, uid, ids, '3')
443 def _merge_get_result_type(self, cr, uid, opps, context=None):
445 Define the type of the result of the merge. If at least one of the
446 element to merge is an opp, the resulting new element will be an opp.
447 Otherwise it will be a lead.
449 We'll directly use a list of browse records instead of a list of ids
450 for performances' sake: it will spare a second browse of the
453 :param list opps: list of browse records containing the leads/opps to process
454 :return string type: the type of the final element
457 if (opp.type == 'opportunity'):
462 def _merge_data(self, cr, uid, ids, oldest, fields, context=None):
464 Prepare lead/opp data into a dictionary for merging. Different types
465 of fields are processed in different ways:
466 - text: all the values are concatenated
467 - m2m and o2m: those fields aren't processed
468 - m2o: the first not null value prevails (the other are dropped)
469 - any other type of field: same as m2o
471 :param list ids: list of ids of the leads to process
472 :param list fields: list of leads' fields to process
473 :return dict data: contains the merged values
475 opportunities = self.browse(cr, uid, ids, context=context)
477 def _get_first_not_null(attr):
478 if hasattr(oldest, attr):
479 return getattr(oldest, attr)
480 for opp in opportunities:
481 if hasattr(opp, attr):
482 return getattr(opp, attr)
485 def _get_first_not_null_id(attr):
486 res = _get_first_not_null(attr)
487 return res and res.id or False
489 def _concat_all(attr):
490 return ', '.join(filter(lambda x: x, [getattr(opp, attr) or '' for opp in opportunities if hasattr(opp, attr)]))
492 # Process the fields' values
494 for field_name in fields:
495 field_info = self._all_columns.get(field_name)
496 if field_info is None:
498 field = field_info.column
499 if field._type in ('many2many', 'one2many'):
501 elif field._type == 'many2one':
502 data[field_name] = _get_first_not_null_id(field_name) # !!
503 elif field._type == 'text':
504 data[field_name] = _concat_all(field_name) #not lost
506 data[field_name] = _get_first_not_null(field_name) #not lost
508 # Define the resulting type ('lead' or 'opportunity')
509 data['type'] = self._merge_get_result_type(cr, uid, opportunities, context)
513 def _merge_find_oldest(self, cr, uid, ids, context=None):
515 Return the oldest lead found among ids.
517 :param list ids: list of ids of the leads to inspect
518 :return object: browse record of the oldest of the leads
523 if context.get('convert'):
524 ids = list(set(ids) - set(context.get('lead_ids', [])))
526 # Search opportunities order by create date
527 opportunity_ids = self.search(cr, uid, [('id', 'in', ids)], order='create_date', context=context)
528 oldest_opp_id = opportunity_ids[0]
529 return self.browse(cr, uid, oldest_opp_id, context=context)
531 def _mail_body(self, cr, uid, lead, fields, title=False, context=None):
534 body.append("%s\n" % (title))
536 for field_name in fields:
537 field_info = self._all_columns.get(field_name)
538 if field_info is None:
540 field = field_info.column
543 if field._type == 'selection':
544 if hasattr(field.selection, '__call__'):
545 key = field.selection(self, cr, uid, context=context)
547 key = field.selection
548 value = dict(key).get(lead[field_name], lead[field_name])
549 elif field._type == 'many2one':
551 value = lead[field_name].name_get()[0][1]
552 elif field._type == 'many2many':
554 for val in lead[field_name]:
555 field_value = val.name_get()[0][1]
556 value += field_value + ","
558 value = lead[field_name]
560 body.append("%s: %s" % (field.string, value or ''))
561 return "<br/>".join(body + ['<br/>'])
563 def _merge_notify(self, cr, uid, opportunity_id, opportunities, context=None):
565 Create a message gathering merged leads/opps information.
567 #TOFIX: mail template should be used instead of fix body, subject text
569 result_type = self._merge_get_result_type(cr, uid, opportunities, context)
570 if result_type == 'lead':
571 merge_message = _('Merged leads')
573 merge_message = _('Merged opportunities')
574 subject = [merge_message]
575 for opportunity in opportunities:
576 subject.append(opportunity.name)
577 title = "%s : %s" % (merge_message, opportunity.name)
578 details.append(self._mail_body(cr, uid, opportunity, CRM_LEAD_FIELDS_TO_MERGE, title=title, context=context))
580 # Chatter message's subject
581 subject = subject[0] + ": " + ", ".join(subject[1:])
582 details = "\n\n".join(details)
583 return self.message_post(cr, uid, [opportunity_id], body=details, subject=subject, context=context)
585 def _merge_opportunity_history(self, cr, uid, opportunity_id, opportunities, context=None):
586 message = self.pool.get('mail.message')
587 for opportunity in opportunities:
588 for history in opportunity.message_ids:
589 message.write(cr, uid, history.id, {
590 'res_id': opportunity_id,
591 'subject' : _("From %s : %s") % (opportunity.name, history.subject)
596 def _merge_opportunity_attachments(self, cr, uid, opportunity_id, opportunities, context=None):
597 attachment = self.pool.get('ir.attachment')
599 # return attachments of opportunity
600 def _get_attachments(opportunity_id):
601 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', opportunity_id)], context=context)
602 return attachment.browse(cr, uid, attachment_ids, context=context)
605 first_attachments = _get_attachments(opportunity_id)
606 for opportunity in opportunities:
607 attachments = _get_attachments(opportunity.id)
608 for first in first_attachments:
609 for attachment in attachments:
610 if attachment.name == first.name:
612 name = "%s (%s)" % (attachment.name, count,),
613 res_id = opportunity_id,
615 attachment.write(values)
620 def merge_opportunity(self, cr, uid, ids, context=None):
622 Different cases of merge:
623 - merge leads together = 1 new lead
624 - merge at least 1 opp with anything else (lead or opp) = 1 new opp
626 :param list ids: leads/opportunities ids to merge
627 :return int id: id of the resulting lead/opp
629 if context is None: context = {}
632 raise osv.except_osv(_('Warning!'),_('Please select more than one element (lead or opportunity) from the list view.'))
634 lead_ids = context.get('lead_ids', [])
636 ctx_opportunities = self.browse(cr, uid, lead_ids, context=context)
637 opportunities = self.browse(cr, uid, ids, context=context)
638 opportunities_list = list(set(opportunities) - set(ctx_opportunities))
639 oldest = self._merge_find_oldest(cr, uid, ids, context=context)
640 if ctx_opportunities:
641 first_opportunity = ctx_opportunities[0]
642 tail_opportunities = opportunities_list + ctx_opportunities[1:]
644 first_opportunity = opportunities_list[0]
645 tail_opportunities = opportunities_list[1:]
647 merged_data = self._merge_data(cr, uid, ids, oldest, CRM_LEAD_FIELDS_TO_MERGE, context=context)
649 # Merge messages and attachements into the first opportunity
650 self._merge_opportunity_history(cr, uid, first_opportunity.id, tail_opportunities, context=context)
651 self._merge_opportunity_attachments(cr, uid, first_opportunity.id, tail_opportunities, context=context)
653 # Merge notifications about loss of information
654 self._merge_notify(cr, uid, first_opportunity, opportunities, context=context)
655 # Write merged data into first opportunity
656 self.write(cr, uid, [first_opportunity.id], merged_data, context=context)
657 # Delete tail opportunities
658 self.unlink(cr, uid, [x.id for x in tail_opportunities], context=context)
660 # Open first opportunity
661 self.case_open(cr, uid, [first_opportunity.id])
662 return first_opportunity.id
664 def _convert_opportunity_data(self, cr, uid, lead, customer, section_id=False, context=None):
665 crm_stage = self.pool.get('crm.case.stage')
668 contact_id = self.pool.get('res.partner').address_get(cr, uid, [customer.id])['default']
671 section_id = lead.section_id and lead.section_id.id or False
674 stage_ids = crm_stage.search(cr, uid, [('sequence','>=',1), ('section_ids','=', section_id)])
676 stage_ids = crm_stage.search(cr, uid, [('sequence','>=',1)])
677 stage_id = stage_ids and stage_ids[0] or False
680 'planned_revenue': lead.planned_revenue,
681 'probability': lead.probability,
683 'partner_id': customer and customer.id or False,
684 'user_id': (lead.user_id and lead.user_id.id),
685 'type': 'opportunity',
686 'stage_id': stage_id or False,
687 'date_action': fields.datetime.now(),
688 'date_open': fields.datetime.now(),
689 'email_from': customer and customer.email or lead.email_from,
690 'phone': customer and customer.phone or lead.phone,
693 def convert_opportunity(self, cr, uid, ids, partner_id, user_ids=False, section_id=False, context=None):
696 partner = self.pool.get('res.partner')
697 customer = partner.browse(cr, uid, partner_id, context=context)
698 for lead in self.browse(cr, uid, ids, context=context):
699 if lead.state in ('done', 'cancel'):
701 vals = self._convert_opportunity_data(cr, uid, lead, customer, section_id, context=context)
702 self.write(cr, uid, [lead.id], vals, context=context)
703 self.convert_opportunity_send_note(cr, uid, lead, context=context)
705 if user_ids or section_id:
706 self.allocate_salesman(cr, uid, ids, user_ids, section_id, context=context)
710 def _lead_create_contact(self, cr, uid, lead, name, is_company, parent_id=False, context=None):
711 partner = self.pool.get('res.partner')
712 vals = { 'name': name,
713 'user_id': lead.user_id.id,
714 'comment': lead.description,
715 'section_id': lead.section_id.id or False,
716 'parent_id': parent_id,
718 'mobile': lead.mobile,
719 'email': lead.email_from and tools.email_split(lead.email_from)[0],
721 'title': lead.title and lead.title.id or False,
722 'function': lead.function,
723 'street': lead.street,
724 'street2': lead.street2,
727 'country_id': lead.country_id and lead.country_id.id or False,
728 'state_id': lead.state_id and lead.state_id.id or False,
729 'is_company': is_company,
732 partner = partner.create(cr, uid,vals, context)
735 def _create_lead_partner(self, cr, uid, lead, context=None):
737 if lead.partner_name and lead.contact_name:
738 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
739 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, partner_id, context=context)
740 elif lead.partner_name and not lead.contact_name:
741 partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
742 elif not lead.partner_name and lead.contact_name:
743 partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, context=context)
745 partner_id = self._lead_create_contact(cr, uid, lead, lead.name, False, context=context)
748 def _lead_set_partner(self, cr, uid, lead, partner_id, context=None):
750 Assign a partner to a lead.
752 :param object lead: browse record of the lead to process
753 :param int partner_id: identifier of the partner to assign
754 :return bool: True if the partner has properly been assigned
757 res_partner = self.pool.get('res.partner')
759 res_partner.write(cr, uid, partner_id, {'section_id': lead.section_id.id or False})
760 contact_id = res_partner.address_get(cr, uid, [partner_id])['default']
761 res = lead.write({'partner_id': partner_id}, context=context)
762 self._lead_set_partner_send_note(cr, uid, [lead.id], context)
765 def handle_partner_assignation(self, cr, uid, ids, action='create', partner_id=False, context=None):
767 Handle partner assignation during a lead conversion.
768 if action is 'create', create new partner with contact and assign lead to new partner_id.
769 otherwise assign lead to the specified partner_id
771 :param list ids: leads/opportunities ids to process
772 :param string action: what has to be done regarding partners (create it, assign an existing one, or nothing)
773 :param int partner_id: partner to assign if any
774 :return dict: dictionary organized as followed: {lead_id: partner_assigned_id}
776 #TODO this is a duplication of the handle_partner_assignation method of crm_phonecall
778 # If a partner_id is given, force this partner for all elements
779 force_partner_id = partner_id
780 for lead in self.browse(cr, uid, ids, context=context):
781 # If the action is set to 'create' and no partner_id is set, create a new one
782 if action == 'create':
783 partner_id = force_partner_id or self._create_lead_partner(cr, uid, lead, context)
784 self._lead_set_partner(cr, uid, lead, partner_id, context=context)
785 partner_ids[lead.id] = partner_id
788 def allocate_salesman(self, cr, uid, ids, user_ids=None, team_id=False, context=None):
790 Assign salesmen and salesteam to a batch of leads. If there are more
791 leads than salesmen, these salesmen will be assigned in round-robin.
792 E.g.: 4 salesmen (S1, S2, S3, S4) for 6 leads (L1, L2, ... L6). They
793 will be assigned as followed: L1 - S1, L2 - S2, L3 - S3, L4 - S4,
796 :param list ids: leads/opportunities ids to process
797 :param list user_ids: salesmen to assign
798 :param int team_id: salesteam to assign
806 value['section_id'] = team_id
808 value['user_id'] = user_ids[index]
809 # Cycle through user_ids
810 index = (index + 1) % len(user_ids)
812 self.write(cr, uid, [lead_id], value, context=context)
815 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):
817 :param string action: ('schedule','Schedule a call'), ('log','Log a call')
819 phonecall = self.pool.get('crm.phonecall')
820 model_data = self.pool.get('ir.model.data')
823 res_id = model_data._get_id(cr, uid, 'crm', 'categ_phone2')
825 categ_id = model_data.browse(cr, uid, res_id, context=context).res_id
826 for lead in self.browse(cr, uid, ids, context=context):
828 section_id = lead.section_id and lead.section_id.id or False
830 user_id = lead.user_id and lead.user_id.id or False
832 'name': call_summary,
833 'opportunity_id': lead.id,
834 'user_id': user_id or False,
835 'categ_id': categ_id or False,
836 'description': desc or '',
837 'date': schedule_time,
838 'section_id': section_id or False,
839 'partner_id': lead.partner_id and lead.partner_id.id or False,
840 'partner_phone': phone or lead.phone or (lead.partner_id and lead.partner_id.phone or False),
841 'partner_mobile': lead.partner_id and lead.partner_id.mobile or False,
842 'priority': lead.priority,
844 new_id = phonecall.create(cr, uid, vals, context=context)
845 phonecall.case_open(cr, uid, [new_id], context=context)
847 phonecall.case_close(cr, uid, [new_id], context=context)
848 phonecall_dict[lead.id] = new_id
849 self.schedule_phonecall_send_note(cr, uid, [lead.id], new_id, action, context=context)
850 return phonecall_dict
852 def redirect_opportunity_view(self, cr, uid, opportunity_id, context=None):
853 models_data = self.pool.get('ir.model.data')
855 # Get opportunity views
856 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
857 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_oppor')
859 'name': _('Opportunity'),
861 'view_mode': 'tree, form',
862 'res_model': 'crm.lead',
863 'domain': [('type', '=', 'opportunity')],
864 'res_id': int(opportunity_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 redirect_lead_view(self, cr, uid, lead_id, context=None):
873 models_data = self.pool.get('ir.model.data')
876 dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_leads')
877 dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_leads')
881 'view_mode': 'tree, form',
882 'res_model': 'crm.lead',
883 'domain': [('type', '=', 'lead')],
884 'res_id': int(lead_id),
886 'views': [(form_view or False, 'form'),
887 (tree_view or False, 'tree'),
888 (False, 'calendar'), (False, 'graph')],
889 'type': 'ir.actions.act_window',
892 def action_makeMeeting(self, cr, uid, ids, context=None):
894 Open meeting's calendar view to schedule meeting on current opportunity.
895 :return dict: dictionary value for created Meeting view
897 opportunity = self.browse(cr, uid, ids[0], context)
898 res = self.pool.get('ir.actions.act_window').for_xml_id(cr, uid, 'base_calendar', 'action_crm_meeting', context)
900 'default_opportunity_id': opportunity.id,
901 'default_partner_id': opportunity.partner_id and opportunity.partner_id.id or False,
902 'default_partner_ids' : opportunity.partner_id and [opportunity.partner_id.id] or False,
903 'default_user_id': uid,
904 'default_section_id': opportunity.section_id and opportunity.section_id.id or False,
905 'default_email_from': opportunity.email_from,
906 'default_name': opportunity.name,
910 def write(self, cr, uid, ids, vals, context=None):
911 if vals.get('stage_id') and not vals.get('probability'):
912 # change probability of lead(s) if required by stage
913 stage = self.pool.get('crm.case.stage').browse(cr, uid, vals['stage_id'], context=context)
915 vals['probability'] = stage.probability
916 if vals.get('section_id'):
917 section_id = self.pool.get('crm.case.section').browse(cr, uid, vals.get('section_id'), context=context)
919 vals.setdefault('message_follower_ids', [])
920 vals['message_follower_ids'] += [(4, follower.id) for follower in section_id.message_follower_ids]
921 return super(crm_lead,self).write(cr, uid, ids, vals, context)
923 # ----------------------------------------
925 # ----------------------------------------
927 def message_new(self, cr, uid, msg, custom_values=None, context=None):
928 """ Overrides mail_thread message_new that is called by the mailgateway
929 through message_process.
930 This override updates the document according to the email.
932 if custom_values is None: custom_values = {}
934 desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
935 custom_values.update({
936 'name': msg.get('subject') or _("No Subject"),
938 'email_from': msg.get('from'),
939 'email_cc': msg.get('cc'),
942 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
943 custom_values['priority'] = msg.get('priority')
944 return super(crm_lead, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
946 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
947 """ Overrides mail_thread message_update that is called by the mailgateway
948 through message_process.
949 This method updates the document according to the email.
951 if isinstance(ids, (str, int, long)):
953 if update_vals is None: update_vals = {}
955 if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
956 update_vals['priority'] = msg.get('priority')
958 'cost':'planned_cost',
959 'revenue': 'planned_revenue',
960 'probability':'probability',
962 for line in msg.get('body', '').split('\n'):
964 res = tools.command_re.match(line)
965 if res and maps.get(res.group(1).lower()):
966 key = maps.get(res.group(1).lower())
967 update_vals[key] = res.group(2).lower()
969 return super(crm_lead, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
971 # ----------------------------------------
972 # OpenChatter methods and notifications
973 # ----------------------------------------
975 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
976 """ Override of the (void) default notification method. """
977 stage_name = self.pool.get('crm.case.stage').name_get(cr, uid, [stage_id], context=context)[0][1]
978 return self.message_post(cr, uid, ids, body=_("Stage changed to <b>%s</b>.") % (stage_name), subtype="mt_crm_stage", context=context)
980 def case_get_note_msg_prefix(self, cr, uid, lead, context=None):
981 if isinstance(lead, (int, long)):
982 lead = self.browse(cr, uid, [lead], context=context)[0]
983 return ('Opportunity' if lead.type == 'opportunity' else 'Lead')
985 def create_send_note(self, cr, uid, ids, context=None):
987 message = _("%s has been <b>created</b>.") % (self.case_get_note_msg_prefix(cr, uid, id, context=context))
988 self.message_post(cr, uid, [id], body=message, context=context)
991 def case_mark_lost_send_note(self, cr, uid, ids, context=None):
992 message = _("Opportunity has been <b>lost</b>.")
993 return self.message_post(cr, uid, ids, body=message, subtype="mt_crm_lost", context=context)
995 def case_mark_won_send_note(self, cr, uid, ids, context=None):
996 message = _("Opportunity has been <b>won</b>.")
997 return self.message_post(cr, uid, ids, body=message, subtype="mt_crm_won", context=context)
999 def schedule_phonecall_send_note(self, cr, uid, ids, phonecall_id, action, context=None):
1000 phonecall = self.pool.get('crm.phonecall').browse(cr, uid, [phonecall_id], context=context)[0]
1001 if action == 'log': prefix = 'Logged'
1002 else: prefix = 'Scheduled'
1003 message = _("<b>%s a call</b> for the <em>%s</em>.") % (prefix, phonecall.date)
1004 return self.message_post(cr, uid, ids, body=message, context=context)
1006 def _lead_set_partner_send_note(self, cr, uid, ids, context=None):
1007 for lead in self.browse(cr, uid, ids, context=context):
1008 message = _("%s <b>partner</b> is now set to <em>%s</em>." % (self.case_get_note_msg_prefix(cr, uid, lead, context=context), lead.partner_id.name))
1009 lead.message_post(body=message)
1012 def convert_opportunity_send_note(self, cr, uid, lead, context=None):
1013 message = _("Lead has been <b>converted to an opportunity</b>.")
1014 lead.message_post(body=message)
1017 def onchange_state(self, cr, uid, ids, state_id, context=None):
1019 country_id=self.pool.get('res.country.state').browse(cr, uid, state_id, context).country_id.id
1020 return {'value':{'country_id':country_id}}
1023 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: