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