[MERGE] Forward porting of saas-1 until rev 8739
[odoo/odoo.git] / addons / crm / crm_lead.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-today OpenERP SA (<http://www.openerp.com>)
6 #
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.
11 #
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.
16 #
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/>.
19 #
20 ##############################################################################
21
22 from openerp.addons.base_status.base_stage import base_stage
23 import crm
24 from datetime import datetime
25 from operator import itemgetter
26 from openerp.osv import fields, osv, orm
27 import time
28 from openerp import SUPERUSER_ID
29 from openerp import tools
30 from openerp.tools.translate import _
31 from openerp.tools import html2plaintext
32
33 from openerp.addons.base.res.res_partner import format_address
34
35 CRM_LEAD_FIELDS_TO_MERGE = ['name',
36     'partner_id',
37     'channel_id',
38     'company_id',
39     'country_id',
40     'section_id',
41     'state_id',
42     'stage_id',
43     'type_id',
44     'user_id',
45     'title',
46     'city',
47     'contact_name',
48     'description',
49     'email',
50     'fax',
51     'mobile',
52     'partner_name',
53     'phone',
54     'probability',
55     'planned_revenue',
56     'street',
57     'street2',
58     'zip',
59     'create_date',
60     'date_action_last',
61     'date_action_next',
62     'email_from',
63     'email_cc',
64     'partner_name']
65 CRM_LEAD_PENDING_STATES = (
66     crm.AVAILABLE_STATES[2][0], # Cancelled
67     crm.AVAILABLE_STATES[3][0], # Done
68     crm.AVAILABLE_STATES[4][0], # Pending
69 )
70
71 class crm_lead(base_stage, format_address, osv.osv):
72     """ CRM Lead Case """
73     _name = "crm.lead"
74     _description = "Lead/Opportunity"
75     _order = "priority,date_action,id desc"
76     _inherit = ['mail.thread', 'ir.needaction_mixin']
77
78     _track = {
79         'state': {
80             'crm.mt_lead_create': lambda self, cr, uid, obj, ctx=None: obj['state'] in ['new', 'draft'],
81             'crm.mt_lead_won': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'done',
82             'crm.mt_lead_lost': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'cancel',
83         },
84         'stage_id': {
85             'crm.mt_lead_stage': lambda self, cr, uid, obj, ctx=None: obj['state'] not in ['new', 'draft', 'cancel', 'done'],
86         },
87     }
88
89     def get_empty_list_help(self, cr, uid, help, context=None):
90         if context.get('default_type') == 'lead':
91             context['empty_list_help_model'] = 'crm.case.section'
92             context['empty_list_help_id'] = context.get('default_section_id')
93         context['empty_list_help_document_name'] = _("leads")
94         return super(crm_lead, self).get_empty_list_help(cr, uid, help, context=context)
95
96     def create(self, cr, uid, vals, context=None):
97         if context is None:
98             context = {}
99         if not vals.get('stage_id'):
100             ctx = context.copy()
101             if vals.get('section_id'):
102                 ctx['default_section_id'] = vals['section_id']
103             if vals.get('type'):
104                 ctx['default_type'] = vals['type']
105             vals['stage_id'] = self._get_default_stage_id(cr, uid, context=ctx)
106         # context: no_log, because subtype already handle this
107         create_context = dict(context, mail_create_nolog=True)
108         return super(crm_lead, self).create(cr, uid, vals, context=create_context)
109
110     def _get_default_section_id(self, cr, uid, context=None):
111         """ Gives default section by checking if present in the context """
112         return self._resolve_section_id_from_context(cr, uid, context=context) or False
113
114     def _get_default_stage_id(self, cr, uid, context=None):
115         """ Gives default stage_id """
116         section_id = self._get_default_section_id(cr, uid, context=context)
117         return self.stage_find(cr, uid, [], section_id, [('state', '=', 'draft')], context=context)
118
119     def _resolve_section_id_from_context(self, cr, uid, context=None):
120         """ Returns ID of section based on the value of 'section_id'
121             context key, or None if it cannot be resolved to a single
122             Sales Team.
123         """
124         if context is None:
125             context = {}
126         if type(context.get('default_section_id')) in (int, long):
127             return context.get('default_section_id')
128         if isinstance(context.get('default_section_id'), basestring):
129             section_name = context['default_section_id']
130             section_ids = self.pool.get('crm.case.section').name_search(cr, uid, name=section_name, context=context)
131             if len(section_ids) == 1:
132                 return int(section_ids[0][0])
133         return None
134
135     def _resolve_type_from_context(self, cr, uid, context=None):
136         """ Returns the type (lead or opportunity) from the type context
137             key. Returns None if it cannot be resolved.
138         """
139         if context is None:
140             context = {}
141         return context.get('default_type')
142
143     def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
144         access_rights_uid = access_rights_uid or uid
145         stage_obj = self.pool.get('crm.case.stage')
146         order = stage_obj._order
147         # lame hack to allow reverting search, should just work in the trivial case
148         if read_group_order == 'stage_id desc':
149             order = "%s desc" % order
150         # retrieve section_id from the context and write the domain
151         # - ('id', 'in', 'ids'): add columns that should be present
152         # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
153         # - OR ('section_ids', '=', section_id), ('fold', '=', False) if section_id: add section columns that are not folded
154         search_domain = []
155         section_id = self._resolve_section_id_from_context(cr, uid, context=context)
156         if section_id:
157             search_domain += ['|', ('section_ids', '=', section_id)]
158             search_domain += [('id', 'in', ids)]
159         else:
160             search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
161         # retrieve type from the context (if set: choose 'type' or 'both')
162         type = self._resolve_type_from_context(cr, uid, context=context)
163         if type:
164             search_domain += ['|', ('type', '=', type), ('type', '=', 'both')]
165         # perform search
166         stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
167         result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
168         # restore order of the search
169         result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
170
171         fold = {}
172         for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
173             fold[stage.id] = stage.fold or False
174         return result, fold
175
176     def fields_view_get(self, cr, user, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
177         res = super(crm_lead,self).fields_view_get(cr, user, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
178         if view_type == 'form':
179             res['arch'] = self.fields_view_get_address(cr, user, res['arch'], context=context)
180         return res
181
182     _group_by_full = {
183         'stage_id': _read_group_stage_ids
184     }
185
186     def _compute_day(self, cr, uid, ids, fields, args, context=None):
187         """
188         :return dict: difference between current date and log date
189         """
190         cal_obj = self.pool.get('resource.calendar')
191         res_obj = self.pool.get('resource.resource')
192
193         res = {}
194         for lead in self.browse(cr, uid, ids, context=context):
195             for field in fields:
196                 res[lead.id] = {}
197                 duration = 0
198                 ans = False
199                 if field == 'day_open':
200                     if lead.date_open:
201                         date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
202                         date_open = datetime.strptime(lead.date_open, "%Y-%m-%d %H:%M:%S")
203                         ans = date_open - date_create
204                         date_until = lead.date_open
205                 elif field == 'day_close':
206                     if lead.date_closed:
207                         date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
208                         date_close = datetime.strptime(lead.date_closed, "%Y-%m-%d %H:%M:%S")
209                         date_until = lead.date_closed
210                         ans = date_close - date_create
211                 if ans:
212                     resource_id = False
213                     if lead.user_id:
214                         resource_ids = res_obj.search(cr, uid, [('user_id','=',lead.user_id.id)])
215                         if len(resource_ids):
216                             resource_id = resource_ids[0]
217
218                     duration = float(ans.days)
219                     if lead.section_id and lead.section_id.resource_calendar_id:
220                         duration =  float(ans.days) * 24
221                         new_dates = cal_obj.interval_get(cr,
222                             uid,
223                             lead.section_id.resource_calendar_id and lead.section_id.resource_calendar_id.id or False,
224                             datetime.strptime(lead.create_date, '%Y-%m-%d %H:%M:%S'),
225                             duration,
226                             resource=resource_id
227                         )
228                         no_days = []
229                         date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
230                         for in_time, out_time in new_dates:
231                             if in_time.date not in no_days:
232                                 no_days.append(in_time.date)
233                             if out_time > date_until:
234                                 break
235                         duration =  len(no_days)
236                 res[lead.id][field] = abs(int(duration))
237         return res
238
239     def _history_search(self, cr, uid, obj, name, args, context=None):
240         res = []
241         msg_obj = self.pool.get('mail.message')
242         message_ids = msg_obj.search(cr, uid, [('email_from','!=',False), ('subject', args[0][1], args[0][2])], context=context)
243         lead_ids = self.search(cr, uid, [('message_ids', 'in', message_ids)], context=context)
244
245         if lead_ids:
246             return [('id', 'in', lead_ids)]
247         else:
248             return [('id', '=', '0')]
249
250     _columns = {
251         'partner_id': fields.many2one('res.partner', 'Partner', ondelete='set null', track_visibility='onchange',
252             select=True, help="Linked partner (optional). Usually created when converting the lead."),
253
254         'id': fields.integer('ID', readonly=True),
255         'name': fields.char('Subject', size=64, required=True, select=1),
256         'active': fields.boolean('Active', required=False),
257         'date_action_last': fields.datetime('Last Action', readonly=1),
258         'date_action_next': fields.datetime('Next Action', readonly=1),
259         'email_from': fields.char('Email', size=128, help="Email address of the contact", select=1),
260         'section_id': fields.many2one('crm.case.section', 'Sales Team',
261                         select=True, track_visibility='onchange', help='When sending mails, the default email address is taken from the sales team.'),
262         'create_date': fields.datetime('Creation Date' , readonly=True),
263         '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"),
264         'description': fields.text('Notes'),
265         'write_date': fields.datetime('Update Date' , readonly=True),
266         'categ_ids': fields.many2many('crm.case.categ', 'crm_lead_category_rel', 'lead_id', 'category_id', 'Categories', \
267             domain="['|',('section_id','=',section_id),('section_id','=',False), ('object_id.model', '=', 'crm.lead')]"),
268         'type_id': fields.many2one('crm.case.resource.type', 'Campaign', \
269             domain="['|',('section_id','=',section_id),('section_id','=',False)]", help="From which campaign (seminar, marketing campaign, mass mailing, ...) did this contact come from?"),
270         'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel (mail, direct, phone, ...)"),
271         'contact_name': fields.char('Contact Name', size=64),
272         '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),
273         'opt_out': fields.boolean('Opt-Out', oldname='optout',
274             help="If opt-out is checked, this contact has refused to receive emails for mass mailing and marketing campaign. "
275                     "Filter 'Available for Mass Mailing' allows users to filter the leads when performing mass mailing."),
276         'type':fields.selection([ ('lead','Lead'), ('opportunity','Opportunity'), ],'Type', help="Type is used to separate Leads and Opportunities"),
277         'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
278         'date_closed': fields.datetime('Closed', readonly=True),
279         'stage_id': fields.many2one('crm.case.stage', 'Stage', track_visibility='onchange',
280                         domain="['&', ('section_ids', '=', section_id), '|', ('type', '=', type), ('type', '=', 'both')]"),
281         'user_id': fields.many2one('res.users', 'Salesperson', select=True, track_visibility='onchange'),
282         'referred': fields.char('Referred By', size=64),
283         'date_open': fields.datetime('Opened', readonly=True),
284         'day_open': fields.function(_compute_day, string='Days to Open', \
285                                 multi='day_open', type="float", store=True),
286         'day_close': fields.function(_compute_day, string='Days to Close', \
287                                 multi='day_close', type="float", store=True),
288         'state': fields.related('stage_id', 'state', type="selection", store=True,
289                 selection=crm.AVAILABLE_STATES, string="Status", readonly=True,
290                 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\'.'),
291
292         # Only used for type opportunity
293         'probability': fields.float('Success Rate (%)',group_operator="avg"),
294         'planned_revenue': fields.float('Expected Revenue', track_visibility='always'),
295         'ref': fields.reference('Reference', selection=crm._links_get, size=128),
296         'ref2': fields.reference('Reference 2', selection=crm._links_get, size=128),
297         'phone': fields.char("Phone", size=64),
298         'date_deadline': fields.date('Expected Closing', help="Estimate of the date on which the opportunity will be won."),
299         'date_action': fields.date('Next Action Date', select=True),
300         'title_action': fields.char('Next Action', size=64),
301         'color': fields.integer('Color Index'),
302         'partner_address_name': fields.related('partner_id', 'name', type='char', string='Partner Contact Name', readonly=True),
303         'partner_address_email': fields.related('partner_id', 'email', type='char', string='Partner Contact Email', readonly=True),
304         'company_currency': fields.related('company_id', 'currency_id', type='many2one', string='Currency', readonly=True, relation="res.currency"),
305         'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
306         'user_login': fields.related('user_id', 'login', type='char', string='User Login', readonly=True),
307
308         # Fields for address, due to separation from crm and res.partner
309         'street': fields.char('Street', size=128),
310         'street2': fields.char('Street2', size=128),
311         'zip': fields.char('Zip', change_default=True, size=24),
312         'city': fields.char('City', size=128),
313         'state_id': fields.many2one("res.country.state", 'State'),
314         'country_id': fields.many2one('res.country', 'Country'),
315         'phone': fields.char('Phone', size=64),
316         'fax': fields.char('Fax', size=64),
317         'mobile': fields.char('Mobile', size=64),
318         'function': fields.char('Function', size=128),
319         'title': fields.many2one('res.partner.title', 'Title'),
320         'company_id': fields.many2one('res.company', 'Company', select=1),
321         'payment_mode': fields.many2one('crm.payment.mode', 'Payment Mode', \
322                             domain="[('section_id','=',section_id)]"),
323         'planned_cost': fields.float('Planned Costs'),
324     }
325
326     _defaults = {
327         'active': 1,
328         'type': 'lead',
329         'user_id': lambda s, cr, uid, c: s._get_default_user(cr, uid, c),
330         'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
331         'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
332         'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
333         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.lead', context=c),
334         'priority': lambda *a: crm.AVAILABLE_PRIORITIES[2][0],
335         'color': 0,
336     }
337
338     _sql_constraints = [
339         ('check_probability', 'check(probability >= 0 and probability <= 100)', 'The probability of closing the deal should be between 0% and 100%!')
340     ]
341
342     def onchange_stage_id(self, cr, uid, ids, stage_id, context=None):
343         if not stage_id:
344             return {'value':{}}
345         stage = self.pool.get('crm.case.stage').browse(cr, uid, stage_id, context)
346         if not stage.on_change:
347             return {'value':{}}
348         return {'value':{'probability': stage.probability}}
349
350     def on_change_partner(self, cr, uid, ids, partner_id, context=None):
351         result = {}
352         values = {}
353         if partner_id:
354             partner = self.pool.get('res.partner').browse(cr, uid, partner_id, context=context)
355             values = {
356                 'partner_name' : partner.name,
357                 'street' : partner.street,
358                 'street2' : partner.street2,
359                 'city' : partner.city,
360                 'state_id' : partner.state_id and partner.state_id.id or False,
361                 'country_id' : partner.country_id and partner.country_id.id or False,
362                 'email_from' : partner.email,
363                 'phone' : partner.phone,
364                 'mobile' : partner.mobile,
365                 'fax' : partner.fax,
366             }
367         return {'value' : values}
368
369     def on_change_user(self, cr, uid, ids, user_id, context=None):
370         """ When changing the user, also set a section_id or restrict section id
371             to the ones user_id is member of. """
372         section_id = self._get_default_section_id(cr, uid, context=context) or False
373         if user_id and not section_id:
374             section_ids = self.pool.get('crm.case.section').search(cr, uid, ['|', ('user_id', '=', user_id), ('member_ids', '=', user_id)], context=context)
375             if section_ids:
376                 section_id = section_ids[0]
377         return {'value': {'section_id': section_id}}
378
379     def _check(self, cr, uid, ids=False, context=None):
380         """ Override of the base.stage method.
381             Function called by the scheduler to process cases for date actions
382             Only works on not done and cancelled cases
383         """
384         cr.execute('select * from crm_case \
385                 where (date_action_last<%s or date_action_last is null) \
386                 and (date_action_next<=%s or date_action_next is null) \
387                 and state not in (\'cancel\',\'done\')',
388                 (time.strftime("%Y-%m-%d %H:%M:%S"),
389                     time.strftime('%Y-%m-%d %H:%M:%S')))
390
391         ids2 = map(lambda x: x[0], cr.fetchall() or [])
392         cases = self.browse(cr, uid, ids2, context=context)
393         return self._action(cr, uid, cases, False, context=context)
394
395     def stage_find(self, cr, uid, cases, section_id, domain=None, order='sequence', context=None):
396         """ Override of the base.stage method
397             Parameter of the stage search taken from the lead:
398             - type: stage type must be the same or 'both'
399             - section_id: if set, stages must belong to this section or
400               be a default stage; if not set, stages must be default
401               stages
402         """
403         if isinstance(cases, (int, long)):
404             cases = self.browse(cr, uid, cases, context=context)
405         # collect all section_ids
406         section_ids = []
407         types = ['both']
408         if not cases :
409             type = context.get('default_type')
410             types += [type]
411         if section_id:
412             section_ids.append(section_id)
413         for lead in cases:
414             if lead.section_id:
415                 section_ids.append(lead.section_id.id)
416             if lead.type not in types:
417                 types.append(lead.type)
418         # OR all section_ids and OR with case_default
419         search_domain = []
420         if section_ids:
421             search_domain += [('|')] * len(section_ids)
422             for section_id in section_ids:
423                 search_domain.append(('section_ids', '=', section_id))
424         else:
425             search_domain.append(('case_default', '=', True))
426         # AND with cases types
427         search_domain.append(('type', 'in', types))
428         # AND with the domain in parameter
429         search_domain += list(domain)
430         # perform search, return the first found
431         stage_ids = self.pool.get('crm.case.stage').search(cr, uid, search_domain, order=order, context=context)
432         if stage_ids:
433             return stage_ids[0]
434         return False
435
436     def case_cancel(self, cr, uid, ids, context=None):
437         """ Overrides case_cancel from base_stage to set probability """
438         res = super(crm_lead, self).case_cancel(cr, uid, ids, context=context)
439         self.write(cr, uid, ids, {'probability' : 0.0}, context=context)
440         return res
441
442     def case_reset(self, cr, uid, ids, context=None):
443         """ Overrides case_reset from base_stage to set probability """
444         res = super(crm_lead, self).case_reset(cr, uid, ids, context=context)
445         self.write(cr, uid, ids, {'probability': 0.0}, context=context)
446         return res
447
448     def case_mark_lost(self, cr, uid, ids, context=None):
449         """ Mark the case as lost: state=cancel and probability=0 """
450         for lead in self.browse(cr, uid, ids):
451             stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 0.0),('on_change','=',True)], context=context)
452             if stage_id:
453                 self.case_set(cr, uid, [lead.id], values_to_update={'probability': 0.0}, new_stage_id=stage_id, context=context)
454         return True
455
456     def case_mark_won(self, cr, uid, ids, context=None):
457         """ Mark the case as won: state=done and probability=100 """
458         for lead in self.browse(cr, uid, ids):
459             stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 100.0),('on_change','=',True)], context=context)
460             if stage_id:
461                 self.case_set(cr, uid, [lead.id], values_to_update={'probability': 100.0}, new_stage_id=stage_id, context=context)
462         return True
463
464     def set_priority(self, cr, uid, ids, priority):
465         """ Set lead priority
466         """
467         return self.write(cr, uid, ids, {'priority' : priority})
468
469     def set_high_priority(self, cr, uid, ids, context=None):
470         """ Set lead priority to high
471         """
472         return self.set_priority(cr, uid, ids, '1')
473
474     def set_normal_priority(self, cr, uid, ids, context=None):
475         """ Set lead priority to normal
476         """
477         return self.set_priority(cr, uid, ids, '3')
478
479     def _merge_get_result_type(self, cr, uid, opps, context=None):
480         """
481         Define the type of the result of the merge.  If at least one of the
482         element to merge is an opp, the resulting new element will be an opp.
483         Otherwise it will be a lead.
484
485         We'll directly use a list of browse records instead of a list of ids
486         for performances' sake: it will spare a second browse of the
487         leads/opps.
488
489         :param list opps: list of browse records containing the leads/opps to process
490         :return string type: the type of the final element
491         """
492         for opp in opps:
493             if (opp.type == 'opportunity'):
494                 return 'opportunity'
495
496         return 'lead'
497
498     def _merge_data(self, cr, uid, ids, oldest, fields, context=None):
499         """
500         Prepare lead/opp data into a dictionary for merging.  Different types
501         of fields are processed in different ways:
502         - text: all the values are concatenated
503         - m2m and o2m: those fields aren't processed
504         - m2o: the first not null value prevails (the other are dropped)
505         - any other type of field: same as m2o
506
507         :param list ids: list of ids of the leads to process
508         :param list fields: list of leads' fields to process
509         :return dict data: contains the merged values
510         """
511         opportunities = self.browse(cr, uid, ids, context=context)
512
513         def _get_first_not_null(attr):
514             for opp in opportunities:
515                 if hasattr(opp, attr) and bool(getattr(opp, attr)):
516                     return getattr(opp, attr)
517             return False
518
519         def _get_first_not_null_id(attr):
520             res = _get_first_not_null(attr)
521             return res and res.id or False
522
523         def _concat_all(attr):
524             return '\n\n'.join(filter(lambda x: x, [getattr(opp, attr) or '' for opp in opportunities if hasattr(opp, attr)]))
525
526         # Process the fields' values
527         data = {}
528         for field_name in fields:
529             field_info = self._all_columns.get(field_name)
530             if field_info is None:
531                 continue
532             field = field_info.column
533             if field._type in ('many2many', 'one2many'):
534                 continue
535             elif field._type == 'many2one':
536                 data[field_name] = _get_first_not_null_id(field_name)  # !!
537             elif field._type == 'text':
538                 data[field_name] = _concat_all(field_name)  #not lost
539             else:
540                 data[field_name] = _get_first_not_null(field_name)  #not lost
541
542         # Define the resulting type ('lead' or 'opportunity')
543         data['type'] = self._merge_get_result_type(cr, uid, opportunities, context)
544         return data
545
546     def _mail_body(self, cr, uid, lead, fields, title=False, context=None):
547         body = []
548         if title:
549             body.append("%s\n" % (title))
550
551         for field_name in fields:
552             field_info = self._all_columns.get(field_name)
553             if field_info is None:
554                 continue
555             field = field_info.column
556             value = ''
557
558             if field._type == 'selection':
559                 if hasattr(field.selection, '__call__'):
560                     key = field.selection(self, cr, uid, context=context)
561                 else:
562                     key = field.selection
563                 value = dict(key).get(lead[field_name], lead[field_name])
564             elif field._type == 'many2one':
565                 if lead[field_name]:
566                     value = lead[field_name].name_get()[0][1]
567             elif field._type == 'many2many':
568                 if lead[field_name]:
569                     for val in lead[field_name]:
570                         field_value = val.name_get()[0][1]
571                         value += field_value + ","
572             else:
573                 value = lead[field_name]
574
575             body.append("%s: %s" % (field.string, value or ''))
576         return "<br/>".join(body + ['<br/>'])
577
578     def _merge_notify(self, cr, uid, opportunity_id, opportunities, context=None):
579         """
580         Create a message gathering merged leads/opps information.
581         """
582         #TOFIX: mail template should be used instead of fix body, subject text
583         details = []
584         result_type = self._merge_get_result_type(cr, uid, opportunities, context)
585         if result_type == 'lead':
586             merge_message = _('Merged leads')
587         else:
588             merge_message = _('Merged opportunities')
589         subject = [merge_message]
590         for opportunity in opportunities:
591             subject.append(opportunity.name)
592             title = "%s : %s" % (opportunity.type == 'opportunity' and _('Merged opportunity') or _('Merged lead'), opportunity.name)
593             fields = list(CRM_LEAD_FIELDS_TO_MERGE)
594             details.append(self._mail_body(cr, uid, opportunity, fields, title=title, context=context))
595
596         # Chatter message's subject
597         subject = subject[0] + ": " + ", ".join(subject[1:])
598         details = "\n\n".join(details)
599         return self.message_post(cr, uid, [opportunity_id], body=details, subject=subject, context=context)
600
601     def _merge_opportunity_history(self, cr, uid, opportunity_id, opportunities, context=None):
602         message = self.pool.get('mail.message')
603         for opportunity in opportunities:
604             for history in opportunity.message_ids:
605                 message.write(cr, uid, history.id, {
606                         'res_id': opportunity_id,
607                         'subject' : _("From %s : %s") % (opportunity.name, history.subject)
608                 }, context=context)
609
610         return True
611
612     def _merge_opportunity_attachments(self, cr, uid, opportunity_id, opportunities, context=None):
613         attach_obj = self.pool.get('ir.attachment')
614
615         # return attachments of opportunity
616         def _get_attachments(opportunity_id):
617             attachment_ids = attach_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', opportunity_id)], context=context)
618             return attach_obj.browse(cr, uid, attachment_ids, context=context)
619
620         first_attachments = _get_attachments(opportunity_id)
621         #counter of all attachments to move. Used to make sure the name is different for all attachments
622         count = 1
623         for opportunity in opportunities:
624             attachments = _get_attachments(opportunity.id)
625             for attachment in attachments:
626                 values = {'res_id': opportunity_id,}
627                 for attachment_in_first in first_attachments:
628                     if attachment.name == attachment_in_first.name:
629                         name = "%s (%s)" % (attachment.name, count,),
630                 count+=1
631                 attachment.write(values)
632         return True
633
634     def merge_opportunity(self, cr, uid, ids, context=None):
635         """
636         Different cases of merge:
637         - merge leads together = 1 new lead
638         - merge at least 1 opp with anything else (lead or opp) = 1 new opp
639
640         :param list ids: leads/opportunities ids to merge
641         :return int id: id of the resulting lead/opp
642         """
643         if context is None:
644             context = {}
645
646         if len(ids) <= 1:
647             raise osv.except_osv(_('Warning!'), _('Please select more than one element (lead or opportunity) from the list view.'))
648
649         opportunities = self.browse(cr, uid, ids, context=context)
650         sequenced_opps = []
651         for opportunity in opportunities:
652             sequence = -1
653             if opportunity.stage_id and opportunity.stage_id.state != 'cancel':
654                 sequence = opportunity.stage_id.sequence
655             sequenced_opps.append(((int(sequence != -1 and opportunity.type == 'opportunity'), sequence, -opportunity.id), opportunity))
656
657         sequenced_opps.sort(reverse=True)
658         opportunities = map(itemgetter(1), sequenced_opps)
659         ids = [opportunity.id for opportunity in opportunities]
660         highest = opportunities[0]
661         opportunities_rest = opportunities[1:]
662
663         tail_opportunities = opportunities_rest
664
665         fields = list(CRM_LEAD_FIELDS_TO_MERGE)
666         merged_data = self._merge_data(cr, uid, ids, highest, fields, context=context)
667
668         # Merge messages and attachements into the first opportunity
669         self._merge_opportunity_history(cr, uid, highest.id, tail_opportunities, context=context)
670         self._merge_opportunity_attachments(cr, uid, highest.id, tail_opportunities, context=context)
671
672         # Merge notifications about loss of information
673         opportunities = [highest]
674         opportunities.extend(opportunities_rest)
675         self._merge_notify(cr, uid, highest, opportunities, context=context)
676         # Check if the stage is in the stages of the sales team. If not, assign the stage with the lowest sequence
677         if merged_data.get('section_id'):
678             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)
679             if merged_data.get('stage_id') not in section_stage_ids:
680                 merged_data['stage_id'] = section_stage_ids and section_stage_ids[0] or False
681         # Write merged data into first opportunity
682         self.write(cr, uid, [highest.id], merged_data, context=context)
683         # Delete tail opportunities 
684         # 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
685         self.unlink(cr, SUPERUSER_ID, [x.id for x in tail_opportunities], context=context)
686
687         return highest.id
688
689     def _convert_opportunity_data(self, cr, uid, lead, customer, section_id=False, context=None):
690         crm_stage = self.pool.get('crm.case.stage')
691         contact_id = False
692         if customer:
693             contact_id = self.pool.get('res.partner').address_get(cr, uid, [customer.id])['default']
694         if not section_id:
695             section_id = lead.section_id and lead.section_id.id or False
696         val = {
697             'planned_revenue': lead.planned_revenue,
698             'probability': lead.probability,
699             'name': lead.name,
700             'partner_id': customer and customer.id or False,
701             'user_id': (lead.user_id and lead.user_id.id),
702             'type': 'opportunity',
703             'date_action': fields.datetime.now(),
704             'date_open': fields.datetime.now(),
705             'email_from': customer and customer.email or lead.email_from,
706             'phone': customer and customer.phone or lead.phone,
707         }
708         if not lead.stage_id or lead.stage_id.type=='lead':
709             val['stage_id'] = self.stage_find(cr, uid, [lead], section_id, [('state', '=', 'draft'),('type', 'in', ('opportunity','both'))], context=context)
710         return val
711
712     def convert_opportunity(self, cr, uid, ids, partner_id, user_ids=False, section_id=False, context=None):
713         customer = False
714         if partner_id:
715             partner = self.pool.get('res.partner')
716             customer = partner.browse(cr, uid, partner_id, context=context)
717         for lead in self.browse(cr, uid, ids, context=context):
718             if lead.state in ('done', 'cancel'):
719                 continue
720             vals = self._convert_opportunity_data(cr, uid, lead, customer, section_id, context=context)
721             self.write(cr, uid, [lead.id], vals, context=context)
722         self.message_post(cr, uid, ids, body=_("Lead <b>converted into an Opportunity</b>"), subtype="crm.mt_lead_convert_to_opportunity", context=context)
723
724         if user_ids or section_id:
725             self.allocate_salesman(cr, uid, ids, user_ids, section_id, context=context)
726
727         return True
728
729     def _lead_create_contact(self, cr, uid, lead, name, is_company, parent_id=False, context=None):
730         partner = self.pool.get('res.partner')
731         vals = {'name': name,
732             'user_id': lead.user_id.id,
733             'comment': lead.description,
734             'section_id': lead.section_id.id or False,
735             'parent_id': parent_id,
736             'phone': lead.phone,
737             'mobile': lead.mobile,
738             'email': tools.email_split(lead.email_from) and tools.email_split(lead.email_from)[0] or False,
739             'fax': lead.fax,
740             'title': lead.title and lead.title.id or False,
741             'function': lead.function,
742             'street': lead.street,
743             'street2': lead.street2,
744             'zip': lead.zip,
745             'city': lead.city,
746             'country_id': lead.country_id and lead.country_id.id or False,
747             'state_id': lead.state_id and lead.state_id.id or False,
748             'is_company': is_company,
749             'type': 'contact'
750         }
751         partner = partner.create(cr, uid, vals, context=context)
752         return partner
753
754     def _create_lead_partner(self, cr, uid, lead, context=None):
755         partner_id = False
756         if lead.partner_name and lead.contact_name:
757             partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
758             partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, partner_id, context=context)
759         elif lead.partner_name and not lead.contact_name:
760             partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
761         elif not lead.partner_name and lead.contact_name:
762             partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, context=context)
763         elif lead.email_from and self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]:
764             contact_name = self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]
765             partner_id = self._lead_create_contact(cr, uid, lead, contact_name, False, context=context)
766         else:
767             raise osv.except_osv(
768                 _('Warning!'),
769                 _('No customer name defined. Please fill one of the following fields: Company Name, Contact Name or Email ("Name <email@address>")')
770             )
771         return partner_id
772
773     def _lead_set_partner(self, cr, uid, lead, partner_id, context=None):
774         """
775         Assign a partner to a lead.
776
777         :param object lead: browse record of the lead to process
778         :param int partner_id: identifier of the partner to assign
779         :return bool: True if the partner has properly been assigned
780         """
781         res = False
782         res_partner = self.pool.get('res.partner')
783         if partner_id:
784             res_partner.write(cr, uid, partner_id, {'section_id': lead.section_id and lead.section_id.id or False})
785             contact_id = res_partner.address_get(cr, uid, [partner_id])['default']
786             res = lead.write({'partner_id': partner_id}, context=context)
787             message = _("<b>Partner</b> set to <em>%s</em>." % (lead.partner_id.name))
788             self.message_post(cr, uid, [lead.id], body=message, context=context)
789         return res
790
791     def handle_partner_assignation(self, cr, uid, ids, action='create', partner_id=False, context=None):
792         """
793         Handle partner assignation during a lead conversion.
794         if action is 'create', create new partner with contact and assign lead to new partner_id.
795         otherwise assign lead to the specified partner_id
796
797         :param list ids: leads/opportunities ids to process
798         :param string action: what has to be done regarding partners (create it, assign an existing one, or nothing)
799         :param int partner_id: partner to assign if any
800         :return dict: dictionary organized as followed: {lead_id: partner_assigned_id}
801         """
802         #TODO this is a duplication of the handle_partner_assignation method of crm_phonecall
803         partner_ids = {}
804         # If a partner_id is given, force this partner for all elements
805         force_partner_id = partner_id
806         for lead in self.browse(cr, uid, ids, context=context):
807             # If the action is set to 'create' and no partner_id is set, create a new one
808             if action == 'create':
809                 partner_id = force_partner_id or self._create_lead_partner(cr, uid, lead, context)
810             self._lead_set_partner(cr, uid, lead, partner_id, context=context)
811             partner_ids[lead.id] = partner_id
812         return partner_ids
813
814     def allocate_salesman(self, cr, uid, ids, user_ids=None, team_id=False, context=None):
815         """
816         Assign salesmen and salesteam to a batch of leads.  If there are more
817         leads than salesmen, these salesmen will be assigned in round-robin.
818         E.g.: 4 salesmen (S1, S2, S3, S4) for 6 leads (L1, L2, ... L6).  They
819         will be assigned as followed: L1 - S1, L2 - S2, L3 - S3, L4 - S4,
820         L5 - S1, L6 - S2.
821
822         :param list ids: leads/opportunities ids to process
823         :param list user_ids: salesmen to assign
824         :param int team_id: salesteam to assign
825         :return bool
826         """
827         index = 0
828
829         for lead_id in ids:
830             value = {}
831             if team_id:
832                 value['section_id'] = team_id
833             if user_ids:
834                 value['user_id'] = user_ids[index]
835                 # Cycle through user_ids
836                 index = (index + 1) % len(user_ids)
837             if value:
838                 self.write(cr, uid, [lead_id], value, context=context)
839         return True
840
841     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):
842         """
843         :param string action: ('schedule','Schedule a call'), ('log','Log a call')
844         """
845         phonecall = self.pool.get('crm.phonecall')
846         model_data = self.pool.get('ir.model.data')
847         phonecall_dict = {}
848         if not categ_id:
849             res_id = model_data._get_id(cr, uid, 'crm', 'categ_phone2')
850             if res_id:
851                 categ_id = model_data.browse(cr, uid, res_id, context=context).res_id
852         for lead in self.browse(cr, uid, ids, context=context):
853             if not section_id:
854                 section_id = lead.section_id and lead.section_id.id or False
855             if not user_id:
856                 user_id = lead.user_id and lead.user_id.id or False
857             vals = {
858                 'name': call_summary,
859                 'opportunity_id': lead.id,
860                 'user_id': user_id or False,
861                 'categ_id': categ_id or False,
862                 'description': desc or '',
863                 'date': schedule_time,
864                 'section_id': section_id or False,
865                 'partner_id': lead.partner_id and lead.partner_id.id or False,
866                 'partner_phone': phone or lead.phone or (lead.partner_id and lead.partner_id.phone or False),
867                 'partner_mobile': lead.partner_id and lead.partner_id.mobile or False,
868                 'priority': lead.priority,
869             }
870             new_id = phonecall.create(cr, uid, vals, context=context)
871             phonecall.case_open(cr, uid, [new_id], context=context)
872             if action == 'log':
873                 phonecall.case_close(cr, uid, [new_id], context=context)
874             phonecall_dict[lead.id] = new_id
875             self.schedule_phonecall_send_note(cr, uid, [lead.id], new_id, action, context=context)
876         return phonecall_dict
877
878     def redirect_opportunity_view(self, cr, uid, opportunity_id, context=None):
879         models_data = self.pool.get('ir.model.data')
880
881         # Get opportunity views
882         dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
883         dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_oppor')
884         return {
885             'name': _('Opportunity'),
886             'view_type': 'form',
887             'view_mode': 'tree, form',
888             'res_model': 'crm.lead',
889             'domain': [('type', '=', 'opportunity')],
890             'res_id': int(opportunity_id),
891             'view_id': False,
892             'views': [(form_view or False, 'form'),
893                     (tree_view or False, 'tree'),
894                     (False, 'calendar'), (False, 'graph')],
895             'type': 'ir.actions.act_window',
896         }
897
898     def redirect_lead_view(self, cr, uid, lead_id, context=None):
899         models_data = self.pool.get('ir.model.data')
900
901         # Get lead views
902         dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_leads')
903         dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_leads')
904         return {
905             'name': _('Lead'),
906             'view_type': 'form',
907             'view_mode': 'tree, form',
908             'res_model': 'crm.lead',
909             'domain': [('type', '=', 'lead')],
910             'res_id': int(lead_id),
911             'view_id': False,
912             'views': [(form_view or False, 'form'),
913                       (tree_view or False, 'tree'),
914                       (False, 'calendar'), (False, 'graph')],
915             'type': 'ir.actions.act_window',
916         }
917
918     def action_makeMeeting(self, cr, uid, ids, context=None):
919         """
920         Open meeting's calendar view to schedule meeting on current opportunity.
921         :return dict: dictionary value for created Meeting view
922         """
923         opportunity = self.browse(cr, uid, ids[0], context)
924         res = self.pool.get('ir.actions.act_window').for_xml_id(cr, uid, 'base_calendar', 'action_crm_meeting', context)
925         res['context'] = {
926             'default_opportunity_id': opportunity.id,
927             'default_partner_id': opportunity.partner_id and opportunity.partner_id.id or False,
928             'default_partner_ids' : opportunity.partner_id and [opportunity.partner_id.id] or False,
929             'default_user_id': uid,
930             'default_section_id': opportunity.section_id and opportunity.section_id.id or False,
931             'default_email_from': opportunity.email_from,
932             'default_name': opportunity.name,
933         }
934         return res
935
936     def write(self, cr, uid, ids, vals, context=None):
937         if vals.get('stage_id') and not vals.get('probability'):
938             # change probability of lead(s) if required by stage
939             stage = self.pool.get('crm.case.stage').browse(cr, uid, vals['stage_id'], context=context)
940             if stage.on_change:
941                 vals['probability'] = stage.probability
942         return super(crm_lead, self).write(cr, uid, ids, vals, context=context)
943
944     def new_mail_send(self, cr, uid, ids, context=None):
945         '''
946         This function opens a window to compose an email, with the edi sale template message loaded by default
947         '''
948         assert len(ids) == 1, 'This option should only be used for a single id at a time.'
949         ir_model_data = self.pool.get('ir.model.data')
950         try:
951             template_id = ir_model_data.get_object_reference(cr, uid, 'crm', 'email_template_opportunity_mail')[1]
952         except ValueError:
953             template_id = False
954         try:
955             compose_form_id = ir_model_data.get_object_reference(cr, uid, 'mail', 'email_compose_message_wizard_form')[1]
956         except ValueError:
957             compose_form_id = False
958         if context is None:
959             context = {}
960         ctx = context.copy()
961         ctx.update({
962             'default_model': 'crm.lead',
963             'default_res_id': ids[0],
964             'default_use_template': bool(template_id),
965             'default_template_id': template_id,
966             'default_composition_mode': 'comment',
967         })
968         return {
969             'name': _('Compose Email'),
970             'type': 'ir.actions.act_window',
971             'view_type': 'form',
972             'view_mode': 'form',
973             'res_model': 'mail.compose.message',
974             'views': [(compose_form_id, 'form')],
975             'view_id': compose_form_id,
976             'target': 'new',
977             'context': ctx,
978         }
979
980     # ----------------------------------------
981     # Mail Gateway
982     # ----------------------------------------
983
984     def message_get_reply_to(self, cr, uid, ids, context=None):
985         """ Override to get the reply_to of the parent project. """
986         return [lead.section_id.message_get_reply_to()[0] if lead.section_id else False
987                     for lead in self.browse(cr, SUPERUSER_ID, ids, context=context)]
988
989     def _get_formview_action(self, cr, uid, id, context=None):
990         action = super(crm_lead, self)._get_formview_action(cr, uid, id, context=context)
991         obj = self.browse(cr, uid, id, context=context)
992         if obj.type == 'opportunity':
993             model, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
994             action.update({
995                 'views': [(view_id, 'form')],
996                 })
997         return action
998
999     def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1000         recipients = super(crm_lead, self).message_get_suggested_recipients(cr, uid, ids, context=context)
1001         try:
1002             for lead in self.browse(cr, uid, ids, context=context):
1003                 if lead.partner_id:
1004                     self._message_add_suggested_recipient(cr, uid, recipients, lead, partner=lead.partner_id, reason=_('Customer'))
1005                 elif lead.email_from:
1006                     self._message_add_suggested_recipient(cr, uid, recipients, lead, email=lead.email_from, reason=_('Customer Email'))
1007         except (osv.except_osv, orm.except_orm):  # no read access rights -> just ignore suggested recipients because this imply modifying followers
1008             pass
1009         return recipients
1010
1011     def message_new(self, cr, uid, msg, custom_values=None, context=None):
1012         """ Overrides mail_thread message_new that is called by the mailgateway
1013             through message_process.
1014             This override updates the document according to the email.
1015         """
1016         if custom_values is None:
1017             custom_values = {}
1018         desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
1019         defaults = {
1020             'name':  msg.get('subject') or _("No Subject"),
1021             'description': desc,
1022             'email_from': msg.get('from'),
1023             'email_cc': msg.get('cc'),
1024             'partner_id': msg.get('author_id', False),
1025             'user_id': False,
1026         }
1027         if msg.get('author_id'):
1028             defaults.update(self.on_change_partner(cr, uid, None, msg.get('author_id'), context=context)['value'])
1029         if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
1030             defaults['priority'] = msg.get('priority')
1031         defaults.update(custom_values)
1032         return super(crm_lead, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1033
1034     def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1035         """ Overrides mail_thread message_update that is called by the mailgateway
1036             through message_process.
1037             This method updates the document according to the email.
1038         """
1039         if isinstance(ids, (str, int, long)):
1040             ids = [ids]
1041         if update_vals is None: update_vals = {}
1042
1043         if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
1044             update_vals['priority'] = msg.get('priority')
1045         maps = {
1046             'cost':'planned_cost',
1047             'revenue': 'planned_revenue',
1048             'probability':'probability',
1049         }
1050         for line in msg.get('body', '').split('\n'):
1051             line = line.strip()
1052             res = tools.command_re.match(line)
1053             if res and maps.get(res.group(1).lower()):
1054                 key = maps.get(res.group(1).lower())
1055                 update_vals[key] = res.group(2).lower()
1056
1057         return super(crm_lead, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1058
1059     # ----------------------------------------
1060     # OpenChatter methods and notifications
1061     # ----------------------------------------
1062
1063     def schedule_phonecall_send_note(self, cr, uid, ids, phonecall_id, action, context=None):
1064         phonecall = self.pool.get('crm.phonecall').browse(cr, uid, [phonecall_id], context=context)[0]
1065         if action == 'log':
1066             prefix = 'Logged'
1067         else:
1068             prefix = 'Scheduled'
1069         suffix = ' %s' % phonecall.description
1070         message = _("%s a call for %s.%s") % (prefix, phonecall.date, suffix)
1071         return self.message_post(cr, uid, ids, body=message, context=context)
1072
1073     def log_meeting(self, cr, uid, ids, meeting_subject, meeting_date, duration, context=None):
1074         if not duration:
1075             duration = _('unknown')
1076         else:
1077             duration = str(duration)
1078         message = _("Meeting scheduled at '%s'<br> Subject: %s <br> Duration: %s hour(s)") % (meeting_date, meeting_subject, duration)
1079         return self.message_post(cr, uid, ids, body=message, context=context)
1080
1081     def onchange_state(self, cr, uid, ids, state_id, context=None):
1082         if state_id:
1083             country_id=self.pool.get('res.country.state').browse(cr, uid, state_id, context).country_id.id
1084             return {'value':{'country_id':country_id}}
1085         return {}
1086
1087 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: