[MERGE] trunk
[odoo/odoo.git] / addons / crm / crm_lead.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
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 osv import fields, osv
23 from datetime import datetime
24 import crm
25 import time
26 from tools.translate import _
27 from crm import crm_case
28 import binascii
29 import tools
30
31
32 CRM_LEAD_PENDING_STATES = (
33     crm.AVAILABLE_STATES[2][0], # Cancelled
34     crm.AVAILABLE_STATES[3][0], # Done
35     crm.AVAILABLE_STATES[4][0], # Pending
36 )
37
38 class crm_lead(crm_case, osv.osv):
39     """ CRM Lead Case """
40     _name = "crm.lead"
41     _description = "Lead/Opportunity"
42     _order = "priority,date_action,id desc"
43     _inherit = ['mail.thread','res.partner.address']
44
45     def _compute_day(self, cr, uid, ids, fields, args, context=None):
46         """
47         @param cr: the current row, from the database cursor,
48         @param uid: the current user’s ID for security checks,
49         @param ids: List of Openday’s IDs
50         @return: difference between current date and log date
51         @param context: A standard dictionary for contextual values
52         """
53         cal_obj = self.pool.get('resource.calendar')
54         res_obj = self.pool.get('resource.resource')
55
56         res = {}
57         for lead in self.browse(cr, uid, ids, context=context):
58             for field in fields:
59                 res[lead.id] = {}
60                 duration = 0
61                 ans = False
62                 if field == 'day_open':
63                     if lead.date_open:
64                         date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
65                         date_open = datetime.strptime(lead.date_open, "%Y-%m-%d %H:%M:%S")
66                         ans = date_open - date_create
67                         date_until = lead.date_open
68                 elif field == 'day_close':
69                     if lead.date_closed:
70                         date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
71                         date_close = datetime.strptime(lead.date_closed, "%Y-%m-%d %H:%M:%S")
72                         date_until = lead.date_closed
73                         ans = date_close - date_create
74                 if ans:
75                     resource_id = False
76                     if lead.user_id:
77                         resource_ids = res_obj.search(cr, uid, [('user_id','=',lead.user_id.id)])
78                         if len(resource_ids):
79                             resource_id = resource_ids[0]
80
81                     duration = float(ans.days)
82                     if lead.section_id and lead.section_id.resource_calendar_id:
83                         duration =  float(ans.days) * 24
84                         new_dates = cal_obj.interval_get(cr,
85                             uid,
86                             lead.section_id.resource_calendar_id and lead.section_id.resource_calendar_id.id or False,
87                             datetime.strptime(lead.create_date, '%Y-%m-%d %H:%M:%S'),
88                             duration,
89                             resource=resource_id
90                         )
91                         no_days = []
92                         date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
93                         for in_time, out_time in new_dates:
94                             if in_time.date not in no_days:
95                                 no_days.append(in_time.date)
96                             if out_time > date_until:
97                                 break
98                         duration =  len(no_days)
99                 res[lead.id][field] = abs(int(duration))
100         return res
101
102     def _history_search(self, cr, uid, obj, name, args, context=None):
103         res = []
104         msg_obj = self.pool.get('mail.message')
105         message_ids = msg_obj.search(cr, uid, [('email_from','!=',False), ('subject', args[0][1], args[0][2])], context=context)
106         lead_ids = self.search(cr, uid, [('message_ids', 'in', message_ids)], context=context)
107
108         if lead_ids:
109             return [('id', 'in', lead_ids)]
110         else:
111             return [('id', '=', '0')]
112
113     def _get_email_subject(self, cr, uid, ids, fields, args, context=None):
114         res = {}
115         for obj in self.browse(cr, uid, ids, context=context):
116             res[obj.id] = ''
117             for msg in obj.message_ids:
118                 if msg.email_from:
119                     res[obj.id] = msg.subject
120                     break
121         return res
122
123     _columns = {
124         # Overridden from res.partner.address:
125         'partner_id': fields.many2one('res.partner', 'Partner', ondelete='set null',
126             select=True, help="Optional linked partner, usually after conversion of the lead"),
127
128         'id': fields.integer('ID'),
129         'name': fields.char('Name', size=64, select=1),
130         'active': fields.boolean('Active', required=False),
131         'date_action_last': fields.datetime('Last Action', readonly=1),
132         'date_action_next': fields.datetime('Next Action', readonly=1),
133         'email_from': fields.char('Email', size=128, help="E-mail address of the contact", select=1),
134         'section_id': fields.many2one('crm.case.section', 'Sales Team', \
135                         select=True, help='When sending mails, the default email address is taken from the sales team.'),
136         'create_date': fields.datetime('Creation Date' , readonly=True),
137         '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"),
138         'description': fields.text('Notes'),
139         'write_date': fields.datetime('Update Date' , readonly=True),
140
141         'categ_id': fields.many2one('crm.case.categ', 'Category', \
142             domain="['|',('section_id','=',section_id),('section_id','=',False), ('object_id.model', '=', 'crm.lead')]"),
143         'type_id': fields.many2one('crm.case.resource.type', 'Campaign', \
144             domain="['|',('section_id','=',section_id),('section_id','=',False)]", help="From which campaign (seminar, marketing campaign, mass mailing, ...) did this contact come from?"),
145         'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel (mail, direct, phone, ...)"),
146         'contact_name': fields.char('Contact Name', size=64),
147         'partner_name': fields.char("Customer Name", size=64,help='The name of the future partner that will be created while converting the into opportunity', select=1),
148         'optin': fields.boolean('Opt-In', help="If opt-in is checked, this contact has accepted to receive emails."),
149         'optout': fields.boolean('Opt-Out', help="If opt-out is checked, this contact has refused to receive emails or unsubscribed to a campaign."),
150         'type':fields.selection([ ('lead','Lead'), ('opportunity','Opportunity'), ],'Type', help="Type is used to separate Leads and Opportunities"),
151         'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority'),
152         'date_closed': fields.datetime('Closed', readonly=True),
153         'stage_id': fields.many2one('crm.case.stage', 'Stage', domain="[('section_ids', '=', section_id)]"),
154         'user_id': fields.many2one('res.users', 'Salesman'),
155         'referred': fields.char('Referred By', size=64),
156         'date_open': fields.datetime('Opened', readonly=True),
157         'day_open': fields.function(_compute_day, string='Days to Open', \
158                                 multi='day_open', type="float", store=True),
159         'day_close': fields.function(_compute_day, string='Days to Close', \
160                                 multi='day_close', type="float", store=True),
161         'state': fields.selection(crm.AVAILABLE_STATES, 'State', size=16, readonly=True,
162                                   help='The state is set to \'Draft\', when a case is created.\
163                                   \nIf the case is in progress the state is set to \'Open\'.\
164                                   \nWhen the case is over, the state is set to \'Done\'.\
165                                   \nIf the case needs to be reviewed then the state is set to \'Pending\'.'),
166         'message_ids': fields.one2many('mail.message', 'res_id', 'Messages', domain=[('model','=',_name)]),
167         'subjects': fields.function(_get_email_subject, fnct_search=_history_search, string='Subject of Email', type='char', size=64),
168
169
170         # Only used for type opportunity
171         'partner_address_id': fields.many2one('res.partner.address', 'Partner Contact', domain="[('partner_id','=',partner_id)]"), 
172         'probability': fields.float('Probability (%)',group_operator="avg"),
173         'planned_revenue': fields.float('Expected Revenue'),
174         'ref': fields.reference('Reference', selection=crm._links_get, size=128),
175         'ref2': fields.reference('Reference 2', selection=crm._links_get, size=128),
176         'phone': fields.char("Phone", size=64),
177         'date_deadline': fields.date('Expected Closing'),
178         'date_action': fields.date('Next Action Date'),
179         'title_action': fields.char('Next Action', size=64),
180         'stage_id': fields.many2one('crm.case.stage', 'Stage', domain="[('section_ids', '=', section_id)]"),
181     }
182
183     _defaults = {
184         'active': lambda *a: 1,
185         'user_id': crm_case._get_default_user,
186         'email_from': crm_case._get_default_email,
187         'state': lambda *a: 'draft',
188         'type': lambda *a: 'lead',
189         'section_id': crm_case._get_section,
190         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.lead', context=c),
191         'priority': lambda *a: crm.AVAILABLE_PRIORITIES[2][0],
192         #'stage_id': _get_stage_id,
193     }
194
195     def onchange_partner_address_id(self, cr, uid, ids, add, email=False):
196         """This function returns value of partner email based on Partner Address
197         """
198         if not add:
199             return {'value': {'email_from': False, 'country_id': False}}
200         address = self.pool.get('res.partner.address').browse(cr, uid, add)
201         return {'value': {'email_from': address.email, 'phone': address.phone, 'country_id': address.country_id.id}}
202
203     def on_change_optin(self, cr, uid, ids, optin):
204         return {'value':{'optin':optin,'optout':False}}
205
206     def on_change_optout(self, cr, uid, ids, optout):
207         return {'value':{'optout':optout,'optin':False}}
208
209     def onchange_stage_id(self, cr, uid, ids, stage_id, context={}):
210         if not stage_id:
211             return {'value':{}}
212         stage = self.pool.get('crm.case.stage').browse(cr, uid, stage_id, context)
213         if not stage.on_change:
214             return {'value':{}}
215         return {'value':{'probability': stage.probability}}
216
217     def stage_find_percent(self, cr, uid, percent, section_id):
218         """ Return the first stage with a probability == percent
219         """
220         stage_pool = self.pool.get('crm.case.stage')
221         if section_id :
222             ids = stage_pool.search(cr, uid, [("probability", '=', percent), ("section_ids", 'in', [section_id])])
223         else :
224             ids = stage_pool.search(cr, uid, [("probability", '=', percent)])
225
226         if ids:
227             return ids[0]
228         return False
229
230     def stage_find_lost(self, cr, uid, section_id):
231         return self.stage_find_percent(cr, uid, 0.0, section_id)
232
233     def stage_find_won(self, cr, uid, section_id):
234         return self.stage_find_percent(cr, uid, 100.0, section_id)
235
236     def case_open(self, cr, uid, ids, *args):
237         for l in self.browse(cr, uid, ids):
238             # When coming from draft override date and stage otherwise just set state
239             if l.state == 'draft':
240                 if l.type == 'lead':
241                     message = _("The lead '%s' has been opened.") % l.name
242                 elif l.type == 'opportunity':
243                     message = _("The opportunity '%s' has been opened.") % l.name
244                 else:
245                     message = _("The case '%s' has been opened.") % l.name
246                 self.log(cr, uid, l.id, message)
247                 value = {'date_open': time.strftime('%Y-%m-%d %H:%M:%S')}
248                 self.write(cr, uid, [l.id], value)
249                 if l.type == 'opportunity' and not l.stage_id:
250                     stage_id = self.stage_find(cr, uid, l.section_id.id or False, [('sequence','>',0)])
251                     if stage_id:
252                         self.stage_set(cr, uid, [l.id], stage_id)
253         res = super(crm_lead, self).case_open(cr, uid, ids, *args)
254         return res
255
256     def case_close(self, cr, uid, ids, *args):
257         res = super(crm_lead, self).case_close(cr, uid, ids, *args)
258         self.write(cr, uid, ids, {'date_closed': time.strftime('%Y-%m-%d %H:%M:%S')})
259         for case in self.browse(cr, uid, ids):
260             if case.type == 'lead':
261                 message = _("The lead '%s' has been closed.") % case.name
262             else:
263                 message = _("The case '%s' has been closed.") % case.name
264             self.log(cr, uid, case.id, message)
265         return res
266
267     def case_cancel(self, cr, uid, ids, *args):
268         """Overrides cancel for crm_case for setting probability
269         """
270         res = super(crm_lead, self).case_cancel(cr, uid, ids, args)
271         self.write(cr, uid, ids, {'probability' : 0.0})
272         return res
273
274     def case_reset(self, cr, uid, ids, *args):
275         """Overrides reset as draft in order to set the stage field as empty
276         """
277         res = super(crm_lead, self).case_reset(cr, uid, ids, *args)
278         self.write(cr, uid, ids, {'stage_id': False, 'probability': 0.0})
279         return res
280
281     def case_mark_lost(self, cr, uid, ids, *args):
282         """Mark the case as lost: state = done and probability = 0%
283         """
284         res = super(crm_lead, self).case_close(cr, uid, ids, *args)
285         self.write(cr, uid, ids, {'probability' : 0.0})
286         for l in self.browse(cr, uid, ids):
287             stage_id = self.stage_find_lost(cr, uid, l.section_id.id or False)
288             if stage_id:
289                 self.stage_set(cr, uid, [l.id], stage_id)
290             message = _("The opportunity '%s' has been marked as lost.") % l.name
291             self.log(cr, uid, l.id, message)
292         return res
293
294     def case_mark_won(self, cr, uid, ids, *args):
295         """Mark the case as lost: state = done and probability = 0%
296         """
297         res = super(crm_lead, self).case_close(cr, uid, ids, *args)
298         self.write(cr, uid, ids, {'probability' : 100.0})
299         for l in self.browse(cr, uid, ids):
300             stage_id = self.stage_find_won(cr, uid, l.section_id.id or False)
301             if stage_id:
302                 self.stage_set(cr, uid, [l.id], stage_id)
303             message = _("The opportunity '%s' has been been won.") % l.name
304             self.log(cr, uid, l.id, message)
305         return res
306
307     def convert_opportunity(self, cr, uid, ids, context=None):
308         """ Precomputation for converting lead to opportunity
309         """
310         if context is None:
311             context = {}
312         context.update({'active_ids': ids})
313
314         data_obj = self.pool.get('ir.model.data')
315         value = {}
316
317
318         for case in self.browse(cr, uid, ids, context=context):
319             context.update({'active_id': case.id})
320             data_id = data_obj._get_id(cr, uid, 'crm', 'view_crm_lead2opportunity_partner')
321             view_id1 = False
322             if data_id:
323                 view_id1 = data_obj.browse(cr, uid, data_id, context=context).res_id
324             value = {
325                     'name': _('Create Partner'),
326                     'view_type': 'form',
327                     'view_mode': 'form,tree',
328                     'res_model': 'crm.lead2opportunity.partner',
329                     'view_id': False,
330                     'context': context,
331                     'views': [(view_id1, 'form')],
332                     'type': 'ir.actions.act_window',
333                     'target': 'new',
334                     'nodestroy': True
335             }
336         return value
337
338     def message_new(self, cr, uid, msg, custom_values=None, context=None):
339         """Automatically calls when new email message arrives"""
340         res_id = super(crm_lead, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
341         subject = msg.get('subject')  or _("No Subject")
342         body = msg.get('body_text')
343
344         msg_from = msg.get('from')
345         priority = msg.get('priority')
346         vals = {
347             'name': subject,
348             'email_from': msg_from,
349             'email_cc': msg.get('cc'),
350             'description': body,
351             'user_id': False,
352         }
353         if priority:
354             vals['priority'] = priority
355         vals.update(self.message_partner_by_email(cr, uid, msg.get('from', False)))
356         res_id = self.write(cr, uid, [res_id], vals, context)
357         return res_id
358
359     def message_update(self, cr, uid, ids, msg, vals={}, default_act='pending', context=None):
360         if isinstance(ids, (str, int, long)):
361             ids = [ids]
362
363         super(crm_lead, self).message_update(cr, uid, msg,
364                                              custom_values=custom_values,
365                                              context=context)
366
367         if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
368             vals['priority'] = msg.get('priority')
369         maps = {
370             'cost':'planned_cost',
371             'revenue': 'planned_revenue',
372             'probability':'probability'
373         }
374         vls = {}
375         for line in msg['body_text'].split('\n'):
376             line = line.strip()
377             res = tools.misc.command_re.match(line)
378             if res and maps.get(res.group(1).lower()):
379                 key = maps.get(res.group(1).lower())
380                 vls[key] = res.group(2).lower()
381         vals.update(vls)
382
383         # Unfortunately the API is based on lists
384         # but we want to update the state based on the
385         # previous state, so we have to loop:
386         for case in self.browse(cr, uid, ids, context=context):
387             values = dict(vals)
388             if case.state in CRM_LEAD_PENDING_STATES:
389                 values.update(state=crm.AVAILABLE_STATES[1][0]) #re-open
390             res = self.write(cr, uid, [case.id], values, context=context)
391         return res
392
393     def msg_send(self, cr, uid, id, *args, **argv):
394         """ Send The Message
395         @param ids: List of email’s IDs
396         """
397         return True
398
399     def action_makeMeeting(self, cr, uid, ids, context=None):
400         """
401         This opens Meeting's calendar view to schedule meeting on current Opportunity
402         @return : Dictionary value for created Meeting view
403         """
404         value = {}
405         for opp in self.browse(cr, uid, ids, context=context):
406             data_obj = self.pool.get('ir.model.data')
407
408             # Get meeting views
409             result = data_obj._get_id(cr, uid, 'crm', 'view_crm_case_meetings_filter')
410             res = data_obj.read(cr, uid, result, ['res_id'])
411             id1 = data_obj._get_id(cr, uid, 'crm', 'crm_case_calendar_view_meet')
412             id2 = data_obj._get_id(cr, uid, 'crm', 'crm_case_form_view_meet')
413             id3 = data_obj._get_id(cr, uid, 'crm', 'crm_case_tree_view_meet')
414             if id1:
415                 id1 = data_obj.browse(cr, uid, id1, context=context).res_id
416             if id2:
417                 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
418             if id3:
419                 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
420
421             context = {
422                 'default_opportunity_id': opp.id,
423                 'default_partner_id': opp.partner_id and opp.partner_id.id or False,
424                 'default_user_id': uid, 
425                 'default_section_id': opp.section_id and opp.section_id.id or False,
426                 'default_email_from': opp.email_from,
427                 'default_state': 'open',  
428                 'default_name': opp.name
429             }
430             value = {
431                 'name': _('Meetings'),
432                 'context': context,
433                 'view_type': 'form',
434                 'view_mode': 'calendar,form,tree',
435                 'res_model': 'crm.meeting',
436                 'view_id': False,
437                 'views': [(id1, 'calendar'), (id2, 'form'), (id3, 'tree')],
438                 'type': 'ir.actions.act_window',
439                 'search_view_id': res['res_id'],
440                 'nodestroy': True
441             }
442         return value
443
444
445     def unlink(self, cr, uid, ids, context=None):
446         for lead in self.browse(cr, uid, ids, context):
447             if (not lead.section_id.allow_unlink) and (lead.state <> 'draft'):
448                 raise osv.except_osv(_('Warning !'),
449                     _('You can not delete this lead. You should better cancel it.'))
450         return super(crm_lead, self).unlink(cr, uid, ids, context)
451
452
453     def write(self, cr, uid, ids, vals, context=None):
454         if not context:
455             context = {}
456
457         if 'date_closed' in vals:
458             return super(crm_lead,self).write(cr, uid, ids, vals, context=context)
459
460         if 'stage_id' in vals and vals['stage_id']:
461             stage_obj = self.pool.get('crm.case.stage').browse(cr, uid, vals['stage_id'], context=context)
462             text = _("Changed Stage to: %s") % stage_obj.name
463             self.message_append(cr, uid, ids, text, body_text=text, context=context)
464             message=''
465             for case in self.browse(cr, uid, ids, context=context):
466                 if case.type == 'lead' or  context.get('stage_type',False)=='lead':
467                     message = _("The stage of lead '%s' has been changed to '%s'.") % (case.name, stage_obj.name)
468                 elif case.type == 'opportunity':
469                     message = _("The stage of opportunity '%s' has been changed to '%s'.") % (case.name, stage_obj.name)
470                 self.log(cr, uid, case.id, message)
471         return super(crm_lead,self).write(cr, uid, ids, vals, context)
472
473 crm_lead()
474
475 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: