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