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