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