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