diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index fb9ceca963a..2c7b2b541f7 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -7,6 +7,7 @@ from datetime import date, datetime from psycopg2 import sql from odoo import api, fields, models, tools, SUPERUSER_ID +from odoo.osv import expression from odoo.tools.translate import _ from odoo.tools import email_re, email_split from odoo.exceptions import UserError, AccessError @@ -97,6 +98,10 @@ class Lead(models.Model): ('red', 'Next activity late'), ('green', 'Next activity is planned')], string='Kanban State', compute='_compute_kanban_state') + activity_date_deadline_my = fields.Date( + 'My Activities Deadline', compute='_compute_activity_date_deadline_my', + search='_search_activity_date_deadline_my', compute_sudo=False, + readonly=True, store=False, groups="base.group_user") tag_ids = fields.Many2many( 'crm.tag', 'crm_tag_rel', 'lead_id', 'tag_id', string='Tags', help="Classify and analyze your lead/opportunity categories like: Training, Service") @@ -176,6 +181,7 @@ class Lead(models.Model): ('check_probability', 'check(probability >= 0 and probability <= 100)', 'The probability of closing the deal should be between 0% and 100%!') ] + @api.depends('activity_date_deadline') def _compute_kanban_state(self): today = date.today() for lead in self: @@ -188,6 +194,25 @@ class Lead(models.Model): kanban_state = 'red' lead.kanban_state = kanban_state + @api.depends('activity_ids.date_deadline') + @api.depends_context('uid') + def _compute_activity_date_deadline_my(self): + todo_activities = [] + if self.ids: + todo_activities = self.env['mail.activity'].search([ + ('user_id', '=', self._uid), + ('res_model', '=', self._name), + ('res_id', 'in', self.ids) + ], order='date_deadline ASC') + for record in self: + record.activity_date_deadline_my = next( + (activity.date_deadline for activity in todo_activities if activity.res_id == record.id), + False + ) + + def _search_activity_date_deadline_my(self, operator, operand): + return ['&', ('activity_ids.user_id', '=', self._uid), ('activity_ids.date_deadline', operator, operand)] + @api.depends('user_id', 'type') def _compute_team_id(self): """ When changing the user, also set a team_id or restrict team id @@ -455,6 +480,86 @@ class Lead(models.Model): return write_result + @api.model + def search(self, args, offset=0, limit=None, order=None, count=False): + """ Override to support ordering on activity_date_deadline_my. + + Ordering through web client calls search_read with an order parameter set. + Search_read then calls search. In this override we therefore override search + to intercept a search without count with an order on activity_date_deadline_my. + In that case we do the search in two steps. + + First step: fill with deadline-based results + + * Perform a read_group on my activities to get a mapping lead_id / deadline + Remember date_deadline is required, we always have a value for it. Only + the earliest deadline per lead is kept. + * Search leads linked to those activities that also match the asked domain + and order from the original search request. + * Results of that search will be at the top of returned results. Use limit + None because we have to search all leads linked to activities as ordering + on deadline is done in post processing. + * Reorder them according to deadline asc or desc depending on original + search ordering. Finally take only a subset of those leads to fill with + results matching asked offset / limit. + + Second step: fill with other results. If first step does not gives results + enough to match offset and limit parameters we fill with a search on other + leads. We keep the asked domain and ordering while filtering out already + scanned leads to keep a coherent results. + + All other search and search_read are left untouched by this override to avoid + side effects. Search_count is not affected by this override. + """ + if count or not order or 'activity_date_deadline_my' not in order: + return super(Lead, self).search(args, offset=offset, limit=limit, order=order, count=count) + order_items = [order_item.strip().lower() for order_item in (order or self._order).split(',')] + + # Perform a read_group on my activities to get a mapping lead_id / deadline + # Remember date_deadline is required, we always have a value for it. Only + # the earliest deadline per lead is kept. + activity_asc = any('activity_date_deadline_my asc' in item for item in order_items) + my_lead_activities = self.env['mail.activity'].read_group( + [('res_model', '=', self._name), ('user_id', '=', self.env.uid)], + ['res_id', 'date_deadline:min'], + ['res_id'], + orderby='date_deadline ASC' + ) + my_lead_mapping = dict((item['res_id'], item['date_deadline']) for item in my_lead_activities) + my_lead_ids = list(my_lead_mapping.keys()) + my_lead_domain = expression.AND([[('id', 'in', my_lead_ids)], args]) + my_lead_order = ', '.join(item for item in order_items if 'activity_date_deadline_my' not in item) + + # Search leads linked to those activities and order them. See docstring + # of this method for more details. + search_res = super(Lead, self).search(my_lead_domain, offset=0, limit=None, order=my_lead_order, count=count) + my_lead_ids_ordered = sorted(search_res.ids, key=lambda lead_id: my_lead_mapping[lead_id], reverse=not activity_asc) + # keep only requested window (offset + limit, or offset+) + my_lead_ids_keep = my_lead_ids_ordered[offset:(offset + limit)] if limit else my_lead_ids_ordered[offset:] + # keep list of already skipped lead ids to exclude them from future search + my_lead_ids_skip = my_lead_ids_ordered[:(offset + limit)] if limit else my_lead_ids_ordered + + # do not go further if limit is achieved + if limit and len(my_lead_ids_keep) >= limit: + return self.browse(my_lead_ids_keep) + + # Fill with remaining leads. If a limit is given, simply remove count of + # already fetched. Otherwise keep none. If an offset is set we have to + # reduce it by already fetch results hereabove. Order is updated to exclude + # activity_date_deadline_my when calling super() . + lead_limit = (limit - len(my_lead_ids_keep)) if limit else None + if offset: + lead_offset = max((offset - len(search_res), 0)) + else: + lead_offset = 0 + lead_order = ', '.join(item for item in order_items if 'activity_date_deadline_my' not in item) + + other_lead_res = super(Lead, self).search( + expression.AND([[('id', 'not in', my_lead_ids_skip)], args]), + offset=lead_offset, limit=lead_limit, order=lead_order, count=count + ) + return self.browse(my_lead_ids_keep) + other_lead_res + def _update_probability(self): lead_probabilities = self.sudo()._pls_get_naive_bayes_probabilities() for lead in self: diff --git a/addons/crm/tests/common.py b/addons/crm/tests/common.py index 449a5a50ce8..b94acd89b03 100644 --- a/addons/crm/tests/common.py +++ b/addons/crm/tests/common.py @@ -150,6 +150,24 @@ class TestCrmCommon(TestSalesCommon, MailCase): 'zip': '97648', }) + def _create_leads_batch(self, lead_type='lead', count=10): + """ Helper tool method creating a batch of leads, useful when dealing + with batch processes. Please update me. + + :param string type: 'lead', 'opportunity', 'mixed' (lead then opp), + None (depends on configuration); + """ + types = ['lead', 'opportunity'] + partners = [self.contact_1.id, self.contact_2.id, False] + leads_data = [{ + 'name': 'TestLead_%02d' % (x), + 'type': lead_type if lead_type else types[x % 2], + 'partner_id': partners[x % 3], + 'priority': '%s' % (x % 3), + } for x in range(count)] + + return self.env['crm.lead'].create(leads_data) + def _create_duplicates(self, lead, create_opp=True): """ Helper tool method creating, based on a given lead diff --git a/addons/crm/tests/test_crm_activity.py b/addons/crm/tests/test_crm_activity.py index 70996159c27..6f57b5dbd19 100644 --- a/addons/crm/tests/test_crm_activity.py +++ b/addons/crm/tests/test_crm_activity.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import date, timedelta + from odoo.addons.crm.tests.common import TestCrmCommon +from odoo.tests.common import users class TestCrmMailActivity(TestCrmCommon): @@ -22,6 +25,89 @@ class TestCrmMailActivity(TestCrmCommon): 'summary': 'ACT 2 : I want to show you my ERP !', 'res_model_id': cls.env['ir.model']._get('crm.lead').id, }) + for activity_type in cls.activity_type_1 + cls.activity_type_2: + cls.env['ir.model.data'].create({ + 'name': activity_type.name.lower().replace(' ', '_'), + 'module': 'crm', + 'model': activity_type._name, + 'res_id': activity_type.id, + }) + + @users('user_sales_leads') + def test_crm_activity_ordering(self): + """ Test ordering on "my activities", linked to a hack introduced for a b2b. + Purpose is to be able to order on "my activities", which is a filtered o2m. + In this test we will check search, limit, order and offset linked to the + override of search for this non stored computed field. """ + # Initialize some batch data + default_order = self.env['crm.lead']._order + self.assertEqual(default_order, "priority desc, id desc") # force updating this test is order changes + test_leads = self._create_leads_batch(count=10).sorted('id') + + # assert initial data, ensure we did not break base behavior + for lead in test_leads: + self.assertFalse(lead.activity_date_deadline_my) + search_res = self.env['crm.lead'].search([('id', 'in', test_leads.ids)], limit=5, offset=0, order='id ASC') + self.assertEqual(search_res.ids, test_leads[:5].ids) + search_res = self.env['crm.lead'].search([('id', 'in', test_leads.ids)], limit=5, offset=5, order='id ASC') + self.assertEqual(search_res.ids, test_leads[5:10].ids) + + # Let's schedule some activities for "myself" and "my bro"and check those are correctly computed + # LEAD NUMBER DEADLINE (MY) PRIORITY LATE DEADLINE (MY) + # 0 +2D 0 +2D + # 1 -1D 1 +2D + # 2 -2D 2 +2D + # 3 -1D 0 -1D + # 4 -2D 1 -2D + # 5 +2D 2 +2D + # 6+ / 0/1/2/0 / + today = date.today() + deadline_in2d, deadline_in1d = today + timedelta(days=2), today + timedelta(days=1) + deadline_was2d, deadline_was1d = today + timedelta(days=-2), today + timedelta(days=-1) + deadlines_my = [deadline_in2d, deadline_was1d, deadline_was2d, deadline_was1d, deadline_was2d, + deadline_in2d, False, False, False, False] + deadlines_gl = [deadline_in1d, deadline_was1d, deadline_was2d, deadline_was1d, deadline_was2d, + deadline_in2d, False, False, False, False] + + test_leads[0:4].activity_schedule(act_type_xmlid='crm.call_for_demo', user_id=self.user_sales_manager.id, date_deadline=deadline_in1d) + test_leads[0:3].activity_schedule(act_type_xmlid='crm.initial_contact', date_deadline=deadline_in2d) + test_leads[5].activity_schedule(act_type_xmlid='crm.initial_contact', date_deadline=deadline_in2d) + (test_leads[1] | test_leads[3]).activity_schedule(act_type_xmlid='crm.initial_contact', date_deadline=deadline_was1d) + (test_leads[2] | test_leads[4]).activity_schedule(act_type_xmlid='crm.call_for_demo', date_deadline=deadline_was2d) + test_leads.invalidate_cache() + + expected_ids_asc = [2, 4, 1, 3, 5, 0, 8, 7, 9, 6] + expected_leads_asc = self.env['crm.lead'].browse([test_leads[lid].id for lid in expected_ids_asc]) + expected_ids_desc = [5, 0, 1, 3, 2, 4, 8, 7, 9, 6] + expected_leads_desc = self.env['crm.lead'].browse([test_leads[lid].id for lid in expected_ids_desc]) + + for idx, lead in enumerate(test_leads): + self.assertEqual(lead.activity_date_deadline_my, deadlines_my[idx]) + self.assertEqual(lead.activity_date_deadline, deadlines_gl[idx], 'Fail at %s' % idx) + + # Let's go for a first batch of search + _order = 'activity_date_deadline_my ASC, %s' % default_order + _domain = [('id', 'in', test_leads.ids)] + + search_res = self.env['crm.lead'].search(_domain, limit=None, offset=0, order=_order) + self.assertEqual(expected_leads_asc.ids, search_res.ids) + search_res = self.env['crm.lead'].search(_domain, limit=4, offset=0, order=_order) + self.assertEqual(expected_leads_asc[:4].ids, search_res.ids) + search_res = self.env['crm.lead'].search(_domain, limit=4, offset=3, order=_order) + self.assertEqual(expected_leads_asc[3:7].ids, search_res.ids) + search_res = self.env['crm.lead'].search(_domain, limit=None, offset=3, order=_order) + self.assertEqual(expected_leads_asc[3:].ids, search_res.ids) + + _order = 'activity_date_deadline_my DESC, %s' % default_order + search_res = self.env['crm.lead'].search(_domain, limit=None, offset=0, order=_order) + self.assertEqual(expected_leads_desc.ids, search_res.ids) + search_res = self.env['crm.lead'].search(_domain, limit=4, offset=0, order=_order) + self.assertEqual(expected_leads_desc[:4].ids, search_res.ids) + search_res = self.env['crm.lead'].search(_domain, limit=4, offset=3, order=_order) + self.assertEqual(expected_leads_desc[3:7].ids, search_res.ids) + search_res = self.env['crm.lead'].search(_domain, limit=None, offset=3, order=_order) + self.assertEqual(expected_leads_desc[3:].ids, search_res.ids) + def test_crm_activity_recipients(self): """ This test case checks diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 8f473228e5a..073359131d1 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -584,7 +584,6 @@ crm.lead - @@ -594,6 +593,7 @@ + diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py index ccb64f08bb4..40c58d0ed57 100644 --- a/addons/mail/models/mail_activity.py +++ b/addons/mail/models/mail_activity.py @@ -670,7 +670,7 @@ class MailActivityMixin(models.AbstractModel): activity_date_deadline = fields.Date( 'Next Activity Deadline', compute='_compute_activity_date_deadline', search='_search_activity_date_deadline', - readonly=True, store=False, + compute_sudo=False, readonly=True, store=False, groups="base.group_user") activity_summary = fields.Char( 'Next Activity Summary', diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index 42b31bc2e3c..5fb716e48dc 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -891,7 +891,7 @@ var ListRenderer = BasicRenderer.extend({ .attr('tabindex', -1) .toggleClass('o-sort-down', isNodeSorted ? !order[0].asc : false) .toggleClass('o-sort-up', isNodeSorted ? order[0].asc : false) - .addClass(field.sortable && 'o_column_sortable'); + .addClass((field.sortable || this.state.fieldsInfo.list[name].options.allow_order || false) && 'o_column_sortable'); if (isNodeSorted) { $th.attr('aria-sort', order[0].asc ? 'ascending' : 'descending');