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