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