[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
This commit is contained in:
Pierre-Yves Dufays
2023-10-19 19:08:12 +00:00
parent f54cb53df2
commit 5f4add917a
7 changed files with 206 additions and 63 deletions
+2 -1
View File
@@ -1123,7 +1123,8 @@
<field name="res_model">crm.lead</field>
<field name="view_mode">tree,kanban,graph,pivot,calendar,form,activity</field>
<field name="view_id" ref="crm_lead_view_list_activities"/>
<field name="domain">[('activity_ids','!=',False)]</field>
<!-- Ensure that only records with at least one activity, "done" (archived) or not, are fetched. -->
<field name="domain">[("activity_ids.active", "in", [True, False])]</field>
<field name="search_view_id" ref="crm.view_crm_case_my_activities_filter"/>
<field name="context">{'default_type': 'opportunity',
'search_default_assigned_to_me': 1}
+7 -2
View File
@@ -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):
@@ -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();
}
@@ -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 = [];
}
@@ -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,
};
},
@@ -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
]);
});
@@ -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) {