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