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