From 5f4add917a55f62c024a7a79c9240dc628a9f14f Mon Sep 17 00:00:00 2001 From: Pierre-Yves Dufays Date: Wed, 11 Oct 2023 10:36:58 +0200 Subject: [PATCH] [IMP] crm, {test_}mail: allows to filter record having "done" activity To allow to search records having "done" activity (archived) assigned to someone, we have modified the search method of the field activity_user_id (in the activity mixin) so that it matches also responsible of archived activities. This field is mainly used when coming from the systray to display user activities including "done" ones but could be also used in custom filters. We have also modified the default domain when fetching record for the activity view so that it matches records having at least one activity, archived or not. This prevents to load more records than necessary to display the activity view. We also adapt the test accordingly. Task-3300854 Part-of: odoo/odoo#138135 --- addons/crm/views/crm_lead_views.xml | 3 +- addons/mail/models/mail_activity_mixin.py | 9 +- .../mail/static/src/core/web/activity_menu.js | 2 +- .../src/views/web/activity/activity_model.js | 4 +- .../mock_server/models/mail_activity.js | 218 +++++++++++++----- .../test_mail/static/tests/activity_tests.js | 31 ++- .../tests/systray_activity_menu_tests.js | 2 +- 7 files changed, 206 insertions(+), 63 deletions(-) diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 6dbd85964d1..a8f3bd2dc6b 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -1123,7 +1123,8 @@ crm.lead tree,kanban,graph,pivot,calendar,form,activity - [('activity_ids','!=',False)] + + [("activity_ids.active", "in", [True, False])] {'default_type': 'opportunity', 'search_default_assigned_to_me': 1} diff --git a/addons/mail/models/mail_activity_mixin.py b/addons/mail/models/mail_activity_mixin.py index 4350af7ec57..aeb1f543ba5 100644 --- a/addons/mail/models/mail_activity_mixin.py +++ b/addons/mail/models/mail_activity_mixin.py @@ -62,7 +62,7 @@ class MailActivityMixin(models.AbstractModel): 'Today: Activity date is today\nPlanned: Future activities.') activity_user_id = fields.Many2one( 'res.users', 'Responsible User', - related='activity_ids.user_id', readonly=False, + compute='_compute_activity_user_id', readonly=True, search='_search_activity_user_id', groups="base.group_user") activity_type_id = fields.Many2one( @@ -111,6 +111,11 @@ class MailActivityMixin(models.AbstractModel): record.activity_exception_decoration = exception_activity_type_id and exception_activity_type_id.decoration_type record.activity_exception_icon = exception_activity_type_id and exception_activity_type_id.icon + @api.depends('activity_ids.user_id') + def _compute_activity_user_id(self): + for record in self: + record.activity_user_id = record.activity_ids[0].user_id if record.activity_ids else False + def _search_activity_exception_decoration(self, operator, operand): return [('activity_ids.activity_type_id.decoration_type', operator, operand)] @@ -204,7 +209,7 @@ class MailActivityMixin(models.AbstractModel): @api.model def _search_activity_user_id(self, operator, operand): - return [('activity_ids.user_id', operator, operand)] + return [('activity_ids', 'any', [('active', 'in', [True, False]), ('user_id', operator, operand)])] @api.model def _search_activity_type_id(self, operator, operand): diff --git a/addons/mail/static/src/core/web/activity_menu.js b/addons/mail/static/src/core/web/activity_menu.js index 700e9112bfe..72aa7b2e191 100644 --- a/addons/mail/static/src/core/web/activity_menu.js +++ b/addons/mail/static/src/core/web/activity_menu.js @@ -55,7 +55,7 @@ export class ActivityMenu extends Component { // So, duplicates are faking the count and "Load more" doesn't show up force_search_count: 1, }; - let domain = [["activity_ids.user_id", "=", this.userId]]; + let domain = [["activity_user_id", "=", this.userId]]; if (group.domain) { domain = Domain.and([domain, group.domain]).toList(); } diff --git a/addons/mail/static/src/views/web/activity/activity_model.js b/addons/mail/static/src/views/web/activity/activity_model.js index bdb1ea60445..489eabac05a 100644 --- a/addons/mail/static/src/views/web/activity/activity_model.js +++ b/addons/mail/static/src/views/web/activity/activity_model.js @@ -7,7 +7,9 @@ export class ActivityModel extends RelationalModel { async load(params = {}) { this.originalDomain = params.domain ? [...params.domain] : []; - params.domain?.push(["activity_ids", "!=", false]); + // Ensure that only (active) records with at least one activity, "done" (archived) or not, are fetched. + // We don't use active_test=false in the context because otherwise we would also get archived records. + params.domain?.push(["activity_ids.active", "in", [true, false]]); if (params && "groupBy" in params) { params.groupBy = []; } diff --git a/addons/mail/static/tests/helpers/mock_server/models/mail_activity.js b/addons/mail/static/tests/helpers/mock_server/models/mail_activity.js index 915c733c927..d74a24ceace 100644 --- a/addons/mail/static/tests/helpers/mock_server/models/mail_activity.js +++ b/addons/mail/static/tests/helpers/mock_server/models/mail_activity.js @@ -1,9 +1,10 @@ /* @odoo-module */ -import { sortBy } from "@web/core/utils/arrays"; +import { Domain } from "@web/core/domain"; +import { groupBy, sortBy, unique } from "@web/core/utils/arrays"; import { patch } from "@web/core/utils/patch"; import { MockServer } from "@web/../tests/helpers/mock_server"; -import { serializeDate, deserializeDate } from "@web/core/l10n/dates"; +import { deserializeDate } from "@web/core/l10n/dates"; const { DateTime} = luxon; @@ -26,7 +27,14 @@ patch(MockServer.prototype, { const domain = args.args[1] || args.kwargs.domain; const limit = args[2] || args.kwargs.limit || 0; const offset = args[3] || args.kwargs.offset || 0; - return this._mockMailActivityGetActivityData(res_model, domain, limit, offset); + const fetch_done = args[4] || args.kwargs.fetch_done || false; + return this._mockMailActivityGetActivityData( + res_model, + domain, + limit, + offset, + fetch_done + ); } return super._performRPC(route, args); }, @@ -87,7 +95,7 @@ patch(MockServer.prototype, { * @param {integer[]} ids * @returns {Object} */ - _mockMailActivityActionFeedback(ids) { + _mockMailActivityActionFeedback(ids, attachment_ids= null) { this._mockMailActivityActionDone(ids); }, /** @@ -118,63 +126,163 @@ patch(MockServer.prototype, { * @param {number} offset * @returns {Object} */ - _mockMailActivityGetActivityData(res_model, domain, limit = 0, offset = 0) { + /** + * Simulate partially (time zone not supported) `_compute_state_from_date` on `mail.activity`. + * + * @param {DateTime} date_deadline to convert into state + * @returns {string} dateline status (today, planned or overdue) + * @private + */ + _mockComputeStateFromDate(date_deadline) { + const now = DateTime.now(); + if (date_deadline.hasSame(now, "day")) { + return "today"; + } else if (date_deadline > now) { + return "planned"; + } + return "overdue"; + }, + /** + * Simulates `get_activity_data` on `mail.activity`. + * + * @private + * @param {string} res_model + * @param {string} domain + * @param {number} limit + * @param {number} offset + * @param {boolean} fetch_done + * @returns {Object} + */ + _mockMailActivityGetActivityData(res_model, domain, limit = 0, offset = 0, fetch_done = false) { const self = this; - const allRecords = this.getRecords(res_model, domain); + + // 1. Retrieve all ongoing and completed activities according to the parameters + const activityTypes = this.getRecords("mail.activity.type", [ + "|", + ["res_model", "=", res_model], + ["res_model", "=", false], + ]); + // Remove domain term used to filter record having "done" activities (not understood by the getRecords mock) + domain = Domain.removeDomainLeaves(new Domain(domain ?? []).toList(), [ + "activity_ids.active", + ]).toList(); + const allRecords = this.getRecords(res_model, domain ?? []); const records = limit ? allRecords.slice(offset, offset + limit) : allRecords; + const activityDomain = [["res_model", "=", res_model]]; + const isFiltered = domain || limit || offset; + const domainResIds = records.map((r) => r.id); + if (isFiltered) { + activityDomain.push(["res_id", "in", domainResIds]); + } + const allActivities = this.getRecords("mail.activity", activityDomain, { + active_test: !fetch_done, + }); + const allOngoing = allActivities.filter((a) => a.active); + const allCompleted = allActivities.filter((a) => !a.active); - const activityTypes = this.getRecords("mail.activity.type", []); - const activityIds = records.map((x) => x.activity_ids).flat(); + // 2. Get attachment of completed activities + let attachmentsById; + if (allCompleted.length) { + const attachmentIds = allCompleted.map((a) => a.attachment_ids).flat(); + attachmentsById = attachmentIds.length + ? Object.fromEntries( + this.getRecords("ir.attachment", [["id", "in", attachmentIds]]).map((a) => [a.id, a]) + ) + : {}; + } else { + attachmentsById = {}; + } - const groupedActivities = {}; + // 3. Group activities per records and activity type + const groupedCompleted = groupBy(allCompleted, (a) => [a.res_id, a.activity_type_id]); + const groupedOngoing = groupBy(allOngoing, (a) => [a.res_id, a.activity_type_id]); + + // 4. Format data const resIdToDeadline = {}; - const groups = self.mockReadGroup("mail.activity", { - domain: [["id", "in", activityIds]], - fields: [ - "res_id", - "activity_type_id", - "ids:array_agg(id)", - "date_deadline:min(date_deadline)", - ], - groupby: ["res_id", "activity_type_id"], - lazy: false, - }); - groups.forEach(function (group) { - // mockReadGroup doesn't correctly return all asked fields - const activites = self.getRecords("mail.activity", group.__domain); - group.activity_type_id = group.activity_type_id[0]; - let minDate; - activites.forEach(function (activity) { - if (!minDate || deserializeDate(activity.date_deadline) < deserializeDate(minDate)) { - minDate = activity.date_deadline; - } - }); - group.date_deadline = minDate; - resIdToDeadline[group.res_id] = minDate; - let state; - if (group.date_deadline === serializeDate(DateTime.now())) { - state = "today"; - } else if (deserializeDate(group.date_deadline) > DateTime.now()) { - state = "planned"; - } else { - state = "overdue"; + const resIdToDateDone = {}; + const groupedActivities = {}; + for (const resIdStrTuple of new Set([ + ...Object.keys(groupedCompleted), + ...Object.keys(groupedOngoing), + ])) { + const [resId, activityTypeId] = resIdStrTuple.split(",").map((n) => Number(n)); + const ongoing = groupedOngoing[resIdStrTuple] || []; + const completed = groupedCompleted[resIdStrTuple] || []; + const dateDone = completed.length + ? DateTime.max(...completed.map((a) => deserializeDate(a.date_done))) + : false; + const dateDeadline = ongoing.length + ? DateTime.min(...ongoing.map((a) => deserializeDate(a.date_deadline))) + : false; + if ( + dateDeadline && + (resIdToDeadline[resId] === undefined || dateDeadline < resIdToDeadline[resId]) + ) { + resIdToDeadline[resId] = dateDeadline; } - if (!groupedActivities[group.res_id]) { - groupedActivities[group.res_id] = {}; + if ( + dateDone && + (resIdToDateDone[resId] === undefined || dateDone > resIdToDateDone[resId]) + ) { + resIdToDateDone[resId] = dateDone; } - groupedActivities[group.res_id][group.activity_type_id] = { - count: group.__count, - state: state, - o_closest_deadline: group.date_deadline, - ids: activites.map((x) => x.id), + const userAssignedIds = unique( + sortBy( + ongoing.filter((a) => a.user_id), + (a) => a.date_deadline + ).map((a) => a.user_id) + ); + const reportingDate = ongoing.length ? dateDeadline : dateDone; + const attachments = completed + .map((act) => act.attachment_ids) + .flat() + .map((attachmentId) => attachmentsById[attachmentId]); + const attachmentsInfo = {}; + if (attachments.length) { + const lastAttachmentCreateDate = DateTime.max( + ...attachments.map((a) => deserializeDate(a.create_date)) + ); + const mostRecentAttachment = attachments.find((a) => + lastAttachmentCreateDate.equals(deserializeDate(a.create_date)) + ); + attachmentsInfo.attachments = { + most_recent_id: mostRecentAttachment.id, + most_recent_name: mostRecentAttachment.name, + count: attachments.length, + }; + } + if (!(resId in groupedActivities)) { + groupedActivities[resId] = {}; + } + groupedActivities[resId][activityTypeId] = { + count_by_state: { + ...Object.fromEntries( + Object.entries( + groupBy(ongoing, (a) => + self._mockComputeStateFromDate(deserializeDate(a.date_deadline)) + ) + ).map(([state, activities]) => [state, activities.length]) + ), + ...(completed.length ? { done: completed.length } : {}), + }, + ids: ongoing.map((a) => a.id).concat(completed.map((a) => a.id)), + reporting_date: reportingDate ? reportingDate.toFormat("yyyy-LL-dd") : false, + state: ongoing.length ? self._mockComputeStateFromDate(dateDeadline) : "done", + user_assigned_ids: userAssignedIds, + ...attachmentsInfo, }; - }); + } + const ongoingResIds = sortBy(Object.keys(resIdToDeadline), (item) => resIdToDeadline[item]); + const completedResIds = sortBy( + Object.keys(resIdToDateDone).filter((resId) => !(resId in resIdToDeadline)), + (item) => resIdToDateDone[item] + ); return { activity_types: activityTypes.map((type) => { - let mailTemplates = []; + let templates = []; if (type.mail_template_ids) { - mailTemplates = type.mail_template_ids.map((template_id) => { + templates = type.mail_template_ids.map((template_id) => { const template = this.getRecords("mail.template", [ ["id", "=", template_id], ])[0]; @@ -184,12 +292,14 @@ patch(MockServer.prototype, { }; }); } - return [type.id, type.display_name, mailTemplates]; + return { + id: type.id, + name: type.display_name, + template_ids: templates, + keep_done: type.keep_done, + }; }), - activity_res_ids: sortBy( - records.map((x) => x.id), - (id) => deserializeDate(resIdToDeadline[id]) - ), + activity_res_ids: ongoingResIds.concat(completedResIds).map((idStr) => Number(idStr)), grouped_activities: groupedActivities, }; }, diff --git a/addons/test_mail/static/tests/activity_tests.js b/addons/test_mail/static/tests/activity_tests.js index a07528a6bc5..cde4946c552 100644 --- a/addons/test_mail/static/tests/activity_tests.js +++ b/addons/test_mail/static/tests/activity_tests.js @@ -6,7 +6,10 @@ import { ActivityModel } from "@mail/views/web/activity/activity_model"; import { ActivityRenderer } from "@mail/views/web/activity/activity_renderer"; import { start } from "@mail/../tests/helpers/test_utils"; +import { RelationalModel } from "@web/model/relational_model/relational_model"; +import { Domain } from "@web/core/domain"; import { serializeDate } from "@web/core/l10n/dates"; +import { deepEqual } from "@web/core/utils/objects"; import { session } from "@web/session"; import testUtils from "@web/../tests/legacy/helpers/test_utils"; import { editInput, patchWithCleanup, click, patchDate } from "@web/../tests/helpers/utils"; @@ -23,6 +26,28 @@ QUnit.module("test_mail", {}, function () { QUnit.module("activity view", { async beforeEach() { patchDate(2023, 4, 8, 10, 0, 0); + patchWithCleanup(RelationalModel.prototype, { + async load(params) { + if (params.domain) { + // Remove domain term used to filter record having "done" activities (not understood by the getRecords mock) + const domain = new Domain(params.domain); + const newDomain = Domain.removeDomainLeaves(domain.toList(), [ + "activity_ids.active", + ]); + if (!deepEqual(domain.toList(), newDomain.toList())) { + return super.load({ + ...params, + domain: newDomain.toList(), + context: params.context + ? { ...params.context, active_test: false } + : { active_test: false }, + }); + } + return super.load(params); + } + return super.load(params); + }, + }); pyEnv = await startServer(); const mailTemplateIds = pyEnv["mail.template"].create([ { name: "Template1" }, @@ -186,7 +211,7 @@ QUnit.module("test_mail", {}, function () { 'should contain "Meeting Room Furnitures" in first colum of second row' ); - const today = DateTime.now().toLocaleString({ day: "numeric", month: "short" }); + const today = DateTime.now().toLocaleString(luxon.DateTime.DATE_SHORT); assert.ok( $activity.find( @@ -380,8 +405,8 @@ QUnit.module("test_mail", {}, function () { }); assert.verifySteps([ - JSON.stringify([["activity_ids", "!=", false]]), - JSON.stringify([["activity_ids", "!=", false]]), + JSON.stringify([["activity_ids.active", "in", [true, false]]]), + '[[1,"=",1]]', // Due to the patch above that removes it ]); }); diff --git a/addons/test_mail/static/tests/systray_activity_menu_tests.js b/addons/test_mail/static/tests/systray_activity_menu_tests.js index 1819bedcac0..16a2638ae34 100644 --- a/addons/test_mail/static/tests/systray_activity_menu_tests.js +++ b/addons/test_mail/static/tests/systray_activity_menu_tests.js @@ -81,7 +81,7 @@ QUnit.test("activity menu widget: activity menu with 2 models", async (assert) = await contains(".o-mail-ActivityMenu-counter", { text: "5" }); const actionChecks = { context: { force_search_count: 1 }, - domain: [["activity_ids.user_id", "=", session.uid]], + domain: [["activity_user_id", "=", session.uid]], }; patchWithCleanup(env.services.action, { doAction(action) {