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