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