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