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) {