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