[MERGE] forward port of branch saas-3 up to fe8106f
[odoo/odoo.git] / addons / crm / crm_lead.py
index 772631b..86d6333 100644 (file)
@@ -28,18 +28,20 @@ from openerp import SUPERUSER_ID
 from openerp import tools
 from openerp.addons.base.res.res_partner import format_address
 from openerp.osv import fields, osv, orm
-from openerp.tools import html2plaintext
 from openerp.tools.translate import _
+from openerp.tools import email_re, email_split
+
 
 CRM_LEAD_FIELDS_TO_MERGE = ['name',
     'partner_id',
-    'channel_id',
+    'campaign_id',
     'company_id',
     'country_id',
     'section_id',
     'state_id',
     'stage_id',
-    'type_id',
+    'medium_id',
+    'source_id',
     'user_id',
     'title',
     'city',
@@ -67,8 +69,8 @@ class crm_lead(format_address, osv.osv):
     """ CRM Lead Case """
     _name = "crm.lead"
     _description = "Lead/Opportunity"
-    _order = "priority,date_action,id desc"
-    _inherit = ['mail.thread', 'ir.needaction_mixin']
+    _order = "priority desc,date_action,id desc"
+    _inherit = ['mail.thread', 'ir.needaction_mixin', 'crm.tracking.mixin']
 
     _track = {
         'stage_id': {
@@ -79,17 +81,22 @@ class crm_lead(format_address, osv.osv):
             'crm.mt_lead_lost': lambda self, cr, uid, obj, ctx=None: obj.probability == 0 and obj.stage_id and obj.stage_id.fold and obj.stage_id.sequence > 1,
         },
     }
+    _mail_mass_mailing = _('Leads / Opportunities')
 
     def get_empty_list_help(self, cr, uid, help, context=None):
+        context = dict(context or {})
         if context.get('default_type') == 'lead':
             context['empty_list_help_model'] = 'crm.case.section'
             context['empty_list_help_id'] = context.get('default_section_id')
         context['empty_list_help_document_name'] = _("leads")
         return super(crm_lead, self).get_empty_list_help(cr, uid, help, context=context)
 
-    def _get_default_section_id(self, cr, uid, context=None):
+    def _get_default_section_id(self, cr, uid, user_id=False, context=None):
         """ Gives default section by checking if present in the context """
-        return self._resolve_section_id_from_context(cr, uid, context=context) or False
+        section_id = self._resolve_section_id_from_context(cr, uid, context=context) or False
+        if not section_id:
+            section_id = self.pool.get('res.users').browse(cr, uid, user_id or uid, context).default_section_id.id or False
+        return section_id
 
     def _get_default_stage_id(self, cr, uid, context=None):
         """ Gives default stage_id """
@@ -170,9 +177,6 @@ class crm_lead(format_address, osv.osv):
         """
         :return dict: difference between current date and log date
         """
-        cal_obj = self.pool.get('resource.calendar')
-        res_obj = self.pool.get('resource.resource')
-
         res = {}
         for lead in self.browse(cr, uid, ids, context=context):
             for field in fields:
@@ -184,47 +188,27 @@ class crm_lead(format_address, osv.osv):
                         date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
                         date_open = datetime.strptime(lead.date_open, "%Y-%m-%d %H:%M:%S")
                         ans = date_open - date_create
-                        date_until = lead.date_open
                 elif field == 'day_close':
                     if lead.date_closed:
                         date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
                         date_close = datetime.strptime(lead.date_closed, "%Y-%m-%d %H:%M:%S")
-                        date_until = lead.date_closed
                         ans = date_close - date_create
                 if ans:
-                    resource_id = False
-                    if lead.user_id:
-                        resource_ids = res_obj.search(cr, uid, [('user_id','=',lead.user_id.id)])
-                        if len(resource_ids):
-                            resource_id = resource_ids[0]
-
-                    duration = float(ans.days)
-                    if lead.section_id and lead.section_id.resource_calendar_id:
-                        duration =  float(ans.days) * 24
-                        new_dates = cal_obj.interval_get(cr,
-                            uid,
-                            lead.section_id.resource_calendar_id and lead.section_id.resource_calendar_id.id or False,
-                            datetime.strptime(lead.create_date, '%Y-%m-%d %H:%M:%S'),
-                            duration,
-                            resource=resource_id
-                        )
-                        no_days = []
-                        date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
-                        for in_time, out_time in new_dates:
-                            if in_time.date not in no_days:
-                                no_days.append(in_time.date)
-                            if out_time > date_until:
-                                break
-                        duration =  len(no_days)
-                res[lead.id][field] = abs(int(duration))
+                    duration = abs(int(ans.days))
+                res[lead.id][field] = duration
         return res
-
+    def _meeting_count(self, cr, uid, ids, field_name, arg, context=None):
+        Event = self.pool['calendar.event']
+        return {
+            opp_id: Event.search_count(cr,uid, [('opportunity_id', '=', opp_id)], context=context)
+            for opp_id in ids
+        }
     _columns = {
         'partner_id': fields.many2one('res.partner', 'Partner', ondelete='set null', track_visibility='onchange',
             select=True, help="Linked partner (optional). Usually created when converting the lead."),
 
         'id': fields.integer('ID', readonly=True),
-        'name': fields.char('Subject', size=64, required=True, select=1),
+        'name': fields.char('Subject', required=True, select=1),
         'active': fields.boolean('Active', required=False),
         'date_action_last': fields.datetime('Last Action', readonly=1),
         'date_action_next': fields.datetime('Next Action', readonly=1),
@@ -235,11 +219,8 @@ class crm_lead(format_address, osv.osv):
         'email_cc': fields.text('Global CC', 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"),
         'description': fields.text('Notes'),
         'write_date': fields.datetime('Update Date', readonly=True),
-        'categ_ids': fields.many2many('crm.case.categ', 'crm_lead_category_rel', 'lead_id', 'category_id', 'Categories', \
-            domain="['|',('section_id','=',section_id),('section_id','=',False), ('object_id.model', '=', 'crm.lead')]"),
-        'type_id': fields.many2one('crm.case.resource.type', 'Campaign', \
-            domain="['|',('section_id','=',section_id),('section_id','=',False)]", help="From which campaign (seminar, marketing campaign, mass mailing, ...) did this contact come from?"),
-        'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel (mail, direct, phone, ...)"),
+        'categ_ids': fields.many2many('crm.case.categ', 'crm_lead_category_rel', 'lead_id', 'category_id', 'Tags', \
+            domain="['|', ('section_id', '=', section_id), ('section_id', '=', False), ('object_id.model', '=', 'crm.lead')]", help="Classify and analyze your lead/opportunity categories like: Training, Service"),
         'contact_name': fields.char('Contact Name', size=64),
         '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),
         'opt_out': fields.boolean('Opt-Out', oldname='optout',
@@ -247,16 +228,18 @@ class crm_lead(format_address, osv.osv):
                     "Filter 'Available for Mass Mailing' allows users to filter the leads when performing mass mailing."),
         'type': fields.selection([ ('lead','Lead'), ('opportunity','Opportunity'), ],'Type', select=True, help="Type is used to separate Leads and Opportunities"),
         'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
-        'date_closed': fields.datetime('Closed', readonly=True),
+        'date_closed': fields.datetime('Closed', readonly=True, copy=False),
         'stage_id': fields.many2one('crm.case.stage', 'Stage', track_visibility='onchange', select=True,
                         domain="['&', ('section_ids', '=', section_id), '|', ('type', '=', type), ('type', '=', 'both')]"),
         'user_id': fields.many2one('res.users', 'Salesperson', select=True, track_visibility='onchange'),
-        'referred': fields.char('Referred By', size=64),
+        'referred': fields.char('Referred By'),
         'date_open': fields.datetime('Assigned', readonly=True),
-        'day_open': fields.function(_compute_day, string='Days to Open', \
-                                multi='day_open', type="float", store=True),
-        'day_close': fields.function(_compute_day, string='Days to Close', \
-                                multi='day_close', type="float", store=True),
+        'day_open': fields.function(_compute_day, string='Days to Assign',
+                                    multi='day_open', type="float",
+                                    store={'crm.lead': (lambda self, cr, uid, ids, c={}: ids, ['date_open'], 10)}),
+        'day_close': fields.function(_compute_day, string='Days to Close',
+                                     multi='day_open', type="float",
+                                     store={'crm.lead': (lambda self, cr, uid, ids, c={}: ids, ['date_closed'], 10)}),
         'date_last_stage_update': fields.datetime('Last Stage Update', select=True),
 
         # Messaging and marketing
@@ -269,7 +252,7 @@ class crm_lead(format_address, osv.osv):
         'phone': fields.char("Phone", size=64),
         'date_deadline': fields.date('Expected Closing', help="Estimate of the date on which the opportunity will be won."),
         'date_action': fields.date('Next Action Date', select=True),
-        'title_action': fields.char('Next Action', size=64),
+        'title_action': fields.char('Next Action'),
         'color': fields.integer('Color Index'),
         'partner_address_name': fields.related('partner_id', 'name', type='char', string='Partner Contact Name', readonly=True),
         'partner_address_email': fields.related('partner_id', 'email', type='char', string='Partner Contact Email', readonly=True),
@@ -278,21 +261,22 @@ class crm_lead(format_address, osv.osv):
         'user_login': fields.related('user_id', 'login', type='char', string='User Login', readonly=True),
 
         # Fields for address, due to separation from crm and res.partner
-        'street': fields.char('Street', size=128),
-        'street2': fields.char('Street2', size=128),
+        'street': fields.char('Street'),
+        'street2': fields.char('Street2'),
         'zip': fields.char('Zip', change_default=True, size=24),
-        'city': fields.char('City', size=128),
+        'city': fields.char('City'),
         'state_id': fields.many2one("res.country.state", 'State'),
         'country_id': fields.many2one('res.country', 'Country'),
-        'phone': fields.char('Phone', size=64),
-        'fax': fields.char('Fax', size=64),
-        'mobile': fields.char('Mobile', size=64),
-        'function': fields.char('Function', size=128),
+        'phone': fields.char('Phone'),
+        'fax': fields.char('Fax'),
+        'mobile': fields.char('Mobile'),
+        'function': fields.char('Function'),
         'title': fields.many2one('res.partner.title', 'Title'),
         'company_id': fields.many2one('res.company', 'Company', select=1),
         'payment_mode': fields.many2one('crm.payment.mode', 'Payment Mode', \
                             domain="[('section_id','=',section_id)]"),
         'planned_cost': fields.float('Planned Costs'),
+        'meeting_count': fields.function(_meeting_count, string='# Meetings', type='integer'),
     }
 
     _defaults = {
@@ -300,7 +284,7 @@ class crm_lead(format_address, osv.osv):
         'type': 'lead',
         'user_id': lambda s, cr, uid, c: uid,
         'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
-        'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
+        'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, context=c),
         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.lead', context=c),
         'priority': lambda *a: crm.AVAILABLE_PRIORITIES[2][0],
         'color': 0,
@@ -327,7 +311,8 @@ class crm_lead(format_address, osv.osv):
         if partner_id:
             partner = self.pool.get('res.partner').browse(cr, uid, partner_id, context=context)
             values = {
-                'partner_name': partner.name,
+                'partner_name': partner.parent_id.name if partner.parent_id else partner.name,
+                'contact_name': partner.name if partner.parent_id else False,
                 'street': partner.street,
                 'street2': partner.street2,
                 'city': partner.city,
@@ -344,7 +329,7 @@ class crm_lead(format_address, osv.osv):
     def on_change_user(self, cr, uid, ids, user_id, context=None):
         """ When changing the user, also set a section_id or restrict section id
             to the ones user_id is member of. """
-        section_id = self._get_default_section_id(cr, uid, context=context) or False
+        section_id = self._get_default_section_id(cr, uid, user_id=user_id, context=context) or False
         if user_id and not section_id:
             section_ids = self.pool.get('crm.case.section').search(cr, uid, ['|', ('user_id', '=', user_id), ('member_ids', '=', user_id)], context=context)
             if section_ids:
@@ -398,7 +383,6 @@ class crm_lead(format_address, osv.osv):
 
     def case_mark_lost(self, cr, uid, ids, context=None):
         """ Mark the case as lost: state=cancel and probability=0
-            :deprecated: this method will be removed in OpenERP v8.
         """
         stages_leads = {}
         for lead in self.browse(cr, uid, ids, context=context):
@@ -419,7 +403,6 @@ class crm_lead(format_address, osv.osv):
 
     def case_mark_won(self, cr, uid, ids, context=None):
         """ Mark the case as won: state=done and probability=100
-            :deprecated: this method will be removed in OpenERP v8.
         """
         stages_leads = {}
         for lead in self.browse(cr, uid, ids, context=context):
@@ -452,21 +435,6 @@ class crm_lead(format_address, osv.osv):
             self.write(cr, uid, [case.id], data, context=context)
         return True
 
-    def set_priority(self, cr, uid, ids, priority, context=None):
-        """ Set lead priority
-        """
-        return self.write(cr, uid, ids, {'priority': priority}, context=context)
-
-    def set_high_priority(self, cr, uid, ids, context=None):
-        """ Set lead priority to high
-        """
-        return self.set_priority(cr, uid, ids, '1', context=context)
-
-    def set_normal_priority(self, cr, uid, ids, context=None):
-        """ Set lead priority to normal
-        """
-        return self.set_priority(cr, uid, ids, '3', context=context)
-
     def _merge_get_result_type(self, cr, uid, opps, context=None):
         """
         Define the type of the result of the merge.  If at least one of the
@@ -617,7 +585,7 @@ class crm_lead(format_address, osv.osv):
                 values = {'res_id': opportunity_id,}
                 for attachment_in_first in first_attachments:
                     if attachment.name == attachment_in_first.name:
-                        name = "%s (%s)" % (attachment.name, count,),
+                        values['name'] = "%s (%s)" % (attachment.name, count,),
                 count+=1
                 attachment.write(values)
         return True
@@ -629,6 +597,38 @@ class crm_lead(format_address, osv.osv):
                 phonecall_obj.write(cr, uid, phonecall_id, {'opportunity_id': opportunity_id}, context=context)
         return True
 
+    def get_duplicated_leads(self, cr, uid, ids, partner_id, include_lost=False, context=None):
+        """
+        Search for opportunities that have the same partner and that arent done or cancelled
+        """
+        lead = self.browse(cr, uid, ids[0], context=context)
+        email = lead.partner_id and lead.partner_id.email or lead.email_from
+        return self.pool['crm.lead']._get_duplicated_leads_by_emails(cr, uid, partner_id, email, include_lost=include_lost, context=context)
+
+    def _get_duplicated_leads_by_emails(self, cr, uid, partner_id, email, include_lost=False, context=None):
+        """
+        Search for opportunities that have   the same partner and that arent done or cancelled
+        """
+        final_stage_domain = [('stage_id.probability', '<', 100), '|', ('stage_id.probability', '>', 0), ('stage_id.sequence', '<=', 1)]
+        partner_match_domain = []
+        for email in set(email_split(email) + [email]):
+            partner_match_domain.append(('email_from', '=ilike', email))
+        if partner_id:
+            partner_match_domain.append(('partner_id', '=', partner_id))
+        partner_match_domain = ['|'] * (len(partner_match_domain) - 1) + partner_match_domain
+        if not partner_match_domain:
+            return []
+        domain = partner_match_domain
+        if not include_lost:
+            domain += final_stage_domain
+        return self.search(cr, uid, domain, context=context)
+
+    def merge_dependences(self, cr, uid, highest, opportunities, context=None):
+        self._merge_notify(cr, uid, highest, opportunities, context=context)
+        self._merge_opportunity_history(cr, uid, highest, opportunities, context=context)
+        self._merge_opportunity_attachments(cr, uid, highest, opportunities, context=context)
+        self._merge_opportunity_phonecalls(cr, uid, highest, opportunities, context=context)
+
     def merge_opportunity(self, cr, uid, ids, user_id=False, section_id=False, context=None):
         """
         Different cases of merge:
@@ -671,15 +671,12 @@ class crm_lead(format_address, osv.osv):
         if section_id:
             merged_data['section_id'] = section_id
 
-        # Merge messages and attachements into the first opportunity
-        self._merge_opportunity_history(cr, uid, highest.id, tail_opportunities, context=context)
-        self._merge_opportunity_attachments(cr, uid, highest.id, tail_opportunities, context=context)
-        self._merge_opportunity_phonecalls(cr, uid, highest.id, tail_opportunities, context=context)
-
         # Merge notifications about loss of information
         opportunities = [highest]
         opportunities.extend(opportunities_rest)
-        self._merge_notify(cr, uid, highest.id, opportunities, context=context)
+
+        self.merge_dependences(cr, uid, highest.id, tail_opportunities, context=context)
+
         # Check if the stage is in the stages of the sales team. If not, assign the stage with the lowest sequence
         if merged_data.get('section_id'):
             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)
@@ -884,8 +881,8 @@ class crm_lead(format_address, osv.osv):
             'res_id': int(opportunity_id),
             'view_id': False,
             'views': [(form_view or False, 'form'),
-                    (tree_view or False, 'tree'),
-                    (False, 'calendar'), (False, 'graph')],
+                      (tree_view or False, 'tree'), (False, 'kanban'),
+                      (False, 'calendar'), (False, 'graph')],
             'type': 'ir.actions.act_window',
         }
 
@@ -909,31 +906,33 @@ class crm_lead(format_address, osv.osv):
             'type': 'ir.actions.act_window',
         }
 
-    def action_makeMeeting(self, cr, uid, ids, context=None):
+    def action_schedule_meeting(self, cr, uid, ids, context=None):
         """
         Open meeting's calendar view to schedule meeting on current opportunity.
         :return dict: dictionary value for created Meeting view
         """
-        opportunity = self.browse(cr, uid, ids[0], context)
+        lead = self.browse(cr, uid, ids[0], context)
         res = self.pool.get('ir.actions.act_window').for_xml_id(cr, uid, 'calendar', 'action_calendar_event', context)
+        partner_ids = [self.pool['res.users'].browse(cr, uid, uid, context=context).partner_id.id]
+        if lead.partner_id:
+            partner_ids.append(lead.partner_id.id)
         res['context'] = {
-            'default_opportunity_id': opportunity.id,
-            'default_partner_id': opportunity.partner_id and opportunity.partner_id.id or False,
-            'default_partner_ids' : opportunity.partner_id and [opportunity.partner_id.id] or False,
-            'default_user_id': uid,
-            'default_section_id': opportunity.section_id and opportunity.section_id.id or False,
-            'default_email_from': opportunity.email_from,
-            'default_name': opportunity.name,
+            'default_opportunity_id': lead.type == 'opportunity' and lead.id or False,
+            'default_partner_id': lead.partner_id and lead.partner_id.id or False,
+            'default_partner_ids': partner_ids,
+            'default_section_id': lead.section_id and lead.section_id.id or False,
+            'default_name': lead.name,
         }
         return res
 
     def create(self, cr, uid, vals, context=None):
-        if context is None:
-            context = {}
+        context = dict(context or {})
         if vals.get('type') and not context.get('default_type'):
             context['default_type'] = vals.get('type')
         if vals.get('section_id') and not context.get('default_section_id'):
             context['default_section_id'] = vals.get('section_id')
+        if vals.get('user_id'):
+            vals['date_open'] = fields.datetime.now()
 
         # context: no_log, because subtype already handle this
         create_context = dict(context, mail_create_nolog=True)
@@ -943,7 +942,9 @@ class crm_lead(format_address, osv.osv):
         # stage change: update date_last_stage_update
         if 'stage_id' in vals:
             vals['date_last_stage_update'] = fields.datetime.now()
-        # stage change with new stage: update probability
+        if vals.get('user_id'):
+            vals['date_open'] = fields.datetime.now()
+        # stage change with new stage: update probability and date_closed
         if vals.get('stage_id') and not vals.get('probability'):
             onchange_stage_values = self.onchange_stage_id(cr, uid, ids, vals.get('stage_id'), context=context)['value']
             vals.update(onchange_stage_values)
@@ -962,9 +963,16 @@ class crm_lead(format_address, osv.osv):
             default['date_open'] = fields.datetime.now()
         else:
             default['date_open'] = False
-        default['date_closed'] = False
-        default['stage_id'] = self._get_default_stage_id(cr, uid, local_context)
-        return super(crm_lead, self).copy(cr, uid, id, default, context=context)
+        return super(crm_lead, self).copy(cr, uid, id, default, context=local_context)
+
+    def get_empty_list_help(self, cr, uid, help, context=None):
+        context = dict(context or {})
+        context['empty_list_help_model'] = 'crm.case.section'
+        context['empty_list_help_id'] = context.get('default_section_id', None)
+        context['empty_list_help_document_name'] = _("opportunity")
+        if context.get('default_type') == 'lead':
+            context['empty_list_help_document_name'] = _("lead")
+        return super(crm_lead, self).get_empty_list_help(cr, uid, help, context=context)
 
     # ----------------------------------------
     # Mail Gateway
@@ -972,18 +980,18 @@ class crm_lead(format_address, osv.osv):
 
     def message_get_reply_to(self, cr, uid, ids, context=None):
         """ Override to get the reply_to of the parent project. """
-        return [lead.section_id.message_get_reply_to()[0] if lead.section_id else False
-                    for lead in self.browse(cr, SUPERUSER_ID, ids, context=context)]
+        leads = self.browse(cr, SUPERUSER_ID, ids, context=context)
+        section_ids = set([lead.section_id.id for lead in leads if lead.section_id])
+        aliases = self.pool['crm.case.section'].message_get_reply_to(cr, uid, list(section_ids), context=context)
+        return dict((lead.id, aliases.get(lead.section_id and lead.section_id.id or 0, False)) for lead in leads)
 
-    def _get_formview_action(self, cr, uid, id, context=None):
-        action = super(crm_lead, self)._get_formview_action(cr, uid, id, context=context)
+    def get_formview_id(self, cr, uid, id, context=None):
         obj = self.browse(cr, uid, id, context=context)
         if obj.type == 'opportunity':
             model, view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
-            action.update({
-                'views': [(view_id, 'form')],
-                })
-        return action
+        else:
+            view_id = super(crm_lead, self).get_formview_id(cr, uid, id, context=context)
+        return view_id
 
     def message_get_suggested_recipients(self, cr, uid, ids, context=None):
         recipients = super(crm_lead, self).message_get_suggested_recipients(cr, uid, ids, context=context)
@@ -1073,4 +1081,16 @@ class crm_lead(format_address, osv.osv):
             return {'value':{'country_id':country_id}}
         return {}
 
+    def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
+        res = super(crm_lead, self).message_partner_info_from_emails(cr, uid, id, emails, link_mail=link_mail, context=context)
+        lead = self.browse(cr, uid, id, context=context)
+        for partner_info in res:
+            if not partner_info.get('partner_id') and (lead.partner_name or lead.contact_name):
+                emails = email_re.findall(partner_info['full_name'] or '')
+                email = emails and emails[0] or ''
+                if email and lead.email_from and email.lower() == lead.email_from.lower():
+                    partner_info['full_name'] = '%s <%s>' % (lead.partner_name or lead.contact_name, email)
+                    break
+        return res
+
 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: