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