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