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