19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,91 @@
declare module "mock_models" {
import { Base as Base2 } from "@mail/../tests/mock_server/mock_models/base";
import { DiscussChannel as DiscussChannel2 } from "@mail/../tests/mock_server/mock_models/discuss_channel";
import { DiscussChannelMember as DiscussChannelMember2 } from "@mail/../tests/mock_server/mock_models/discuss_channel_member";
import { DiscussChannelRtcSession as DiscussChannelRtcSession2 } from "@mail/../tests/mock_server/mock_models/discuss_channel_rtc_session";
import { DiscussVoiceMetadata as DiscussVoiceMetadata2 } from "@mail/../tests/mock_server/mock_models/discuss_voice_metadata";
import { IrAttachment as IrAttachment2 } from "@mail/../tests/mock_server/mock_models/ir_attachment";
import { MailActivity as MailActivity2 } from "@mail/../tests/mock_server/mock_models/mail_activity";
import { MailActivityType as MailActivityType2 } from "@mail/../tests/mock_server/mock_models/mail_activity_type";
import { MailFollowers as MailFollowers2 } from "@mail/../tests/mock_server/mock_models/mail_followers";
import { MailGuest as MailGuest2 } from "@mail/../tests/mock_server/mock_models/mail_guest";
import { MailLinkPreview as MailLinkPreview2 } from "@mail/../tests/mock_server/mock_models/mail_link_preview";
import { MailMessage as MailMessage2 } from "@mail/../tests/mock_server/mock_models/mail_message";
import { MailMessageLinkPreview as MailMessageLinkPreview2 } from "@mail/../tests/mock_server/mock_models/mail_message_link_preview";
import { MailMessageReaction as MailMessageReaction2 } from "@mail/../tests/mock_server/mock_models/mail_message_reaction";
import { MailMessageSubtype as MailMessageSubtype2 } from "@mail/../tests/mock_server/mock_models/mail_message_subtype";
import { MailNotification as MailNotification2 } from "@mail/../tests/mock_server/mock_models/mail_notification";
import { MailScheduledMessage as MailScheduledMessage2 } from "@mail/.../tests/mock_server/mock_models/mail_scheduled_message";
import { MailShortcode as MailShortcode2 } from "@mail/../tests/mock_server/mock_models/mail_shortcode";
import { MailTemplate as MailTemplate2 } from "@mail/../tests/mock_server/mock_models/mail_template";
import { MailThread as MailThread2 } from "@mail/../tests/mock_server/mock_models/mail_thread";
import { MailTrackingValue as MailTrackingValue2 } from "@mail/../tests/mock_server/mock_models/mail_tracking_value";
import { ResFake as ResFake2 } from "@mail/../tests/mock_server/mock_models/res_fake";
import { ResLang as ResLang2 } from "@mail/../tests/mock_server/mock_models/res_lang";
import { ResRole as ResRole2 } from "addons/mail/static/tests/mock_server/mock_models/res_role";
import { ResPartner as ResPartner2 } from "@mail/../tests/mock_server/mock_models/res_partner";
import { ResUsers as ResUsers2 } from "@mail/../tests/mock_server/mock_models/res_users";
import { ResUsersSettings as ResUsersSettings2 } from "@mail/../tests/mock_server/mock_models/res_users_settings";
import { ResUsersSettingsVolumes as ResUsersSettingsVolumes2 } from "@mail/../tests/mock_server/mock_models/res_users_settings_volumes";
export interface Base extends Base2 {}
export interface DiscussChannel extends DiscussChannel2 {}
export interface DiscussChannelMember extends DiscussChannelMember2 {}
export interface DiscussChannelRtcSession extends DiscussChannelRtcSession2 {}
export interface DiscussVoiceMetadata extends DiscussVoiceMetadata2 {}
export interface IrAttachment extends IrAttachment2 {}
export interface MailActivity extends MailActivity2 {}
export interface MailActivityType extends MailActivityType2 {}
export interface MailFollowers extends MailFollowers2 {}
export interface MailGuest extends MailGuest2 {}
export interface MailLinkPreview extends MailLinkPreview2 {}
export interface MailMessage extends MailMessage2 {}
export interface MailMessageLinkPreview extends MailMessageLinkPreview2 {}
export interface MailMessageReaction extends MailMessageReaction2 {}
export interface MailMessageSubtype extends MailMessageSubtype2 {}
export interface MailNotification extends MailNotification2 {}
export interface MailScheduledMessage extends MailScheduledMessage2 {}
export interface MailShortcode extends MailShortcode2 {}
export interface MailTemplate extends MailTemplate2 {}
export interface MailThread extends MailThread2 {}
export interface MailTrackingValue extends MailTrackingValue2 {}
export interface ResFake extends ResFake2 {}
export interface ResLang extends ResLang2 {}
export interface ResPartner extends ResPartner2 {}
export interface ResRole extends ResRole2 {}
export interface ResUsers extends ResUsers2 {}
export interface ResUsersSettings extends ResUsersSettings2 {}
export interface ResUsersSettingsVolumes extends ResUsersSettingsVolumes2 {}
export interface Models {
"base": Base,
"discuss.channel": DiscussChannel,
"discuss.channel.member": DiscussChannelMember,
"discuss.channel.rtc.session": DiscussChannelRtcSession,
"discuss.voice.metadata": DiscussVoiceMetadata,
"ir.attachment": IrAttachment,
"mail.activity": MailActivity,
"mail.activity.type": MailActivityType,
"mail.followers": MailFollowers,
"mail.guest": MailGuest,
"mail.link.preview": MailLinkPreview,
"mail.message": MailMessage,
"mail.message.link.preview": MailMessageLinkPreview,
"mail.message.reaction": MailMessageReaction,
"mail.message.subtype": MailMessageSubtype,
"mail.notification": MailNotification,
"mail.scheduled.message": MailScheduledMessage,
"mail.shortcode": MailShortcode,
"mail.template": MailTemplate,
"mail.thread": MailThread,
"mail.tracking.value": MailTrackingValue,
"res.fake": ResFake,
"res.groups": ResGroups,
"res.lang": ResLang,
"res.partner": ResPartner,
"res.role": ResRole,
"res.users": ResUsers,
"res.users.settings": ResUsersSettings,
"res.users.settings.volumes": ResUsersSettingsVolumes,
}
}

View file

@ -0,0 +1,66 @@
import { getKwArgs, models } from "@web/../tests/web_test_helpers";
import { patch } from "@web/core/utils/patch";
patch(models.ServerModel.prototype, {
/**
* @override
* @type {typeof models.ServerModel["prototype"]["get_views"]}
*/
get_views() {
const result = super.get_views(...arguments);
for (const modelName of Object.keys(result.models)) {
if (this.has_activities) {
result.models[modelName].has_activities = true;
}
}
return result;
},
});
export class Base extends models.ServerModel {
_name = "base";
/**
* @param {Object} trackedFieldNamesToField
* @param {Object} initialTrackedFieldValues
* @param {Object} record
*/
_mail_track(trackedFieldNamesToField, initialTrackedFieldValues, record) {
const kwargs = getKwArgs(
arguments,
"trackedFieldNamesToField",
"initialTrackedFieldValues",
"record"
);
trackedFieldNamesToField = kwargs.trackedFieldNamesToField;
initialTrackedFieldValues = kwargs.initialTrackedFieldValues;
record = kwargs.record;
/** @type {import("mock_models").MailTrackingValue} */
const MailTrackingValue = this.env["mail.tracking.value"];
const trackingValueIds = [];
const changedFieldNames = [];
for (const fname in trackedFieldNamesToField) {
const initialValue = initialTrackedFieldValues[fname];
const newValue = record[fname];
if (!initialValue && !newValue) {
continue;
}
if (initialValue !== newValue) {
const tracking = MailTrackingValue._create_tracking_values(
initialValue,
newValue,
fname,
trackedFieldNamesToField[fname],
this
);
if (tracking) {
trackingValueIds.push(tracking);
}
changedFieldNames.push(fname);
}
}
return { changedFieldNames, trackingValueIds };
}
}

View file

@ -0,0 +1,351 @@
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import { fields, getKwArgs, makeKwArgs, models } from "@web/../tests/web_test_helpers";
import { serializeDateTime, today } from "@web/core/l10n/dates";
import { ensureArray } from "@web/core/utils/arrays";
const { DateTime } = luxon;
export class DiscussChannelMember extends models.ServerModel {
_name = "discuss.channel.member";
is_pinned = fields.Generic({ compute: "_compute_is_pinned" });
is_self = fields.Boolean({ compute: "_compute_is_self" });
unpin_dt = fields.Datetime({ string: "Unpin date" });
message_unread_counter = fields.Generic({ default: 0 });
last_interest_dt = fields.Datetime({
default: () => serializeDateTime(today().minus({ seconds: 1 })),
});
create(values) {
const idOrIds = super.create(values);
this.env["discuss.channel"]._compute_channel_name_member_ids();
const channels_needing_name_update = this.env["discuss.channel"]
._filter([
["channel_name_member_ids", "in", ensureArray(idOrIds)],
["name", "=", false],
[
"channel_type",
"in",
this.env["discuss.channel"]._member_based_naming_channel_types(),
],
])
.filter((channel) => channel.channel_name_member_ids.length <= 3);
for (const channel of channels_needing_name_update) {
const store = new mailDataHelpers.Store().add(
this.env["discuss.channel"].browse(channel.id),
makeKwArgs({ fields: [mailDataHelpers.Store.many("channel_name_member_ids")] })
);
this.env["bus.bus"]._sendone(channel, "mail.record/insert", store.get_result());
}
return idOrIds;
}
write(ids, vals) {
const membersToUpdate = this.browse(ids);
const syncFields = this._sync_field_names();
const oldValsByMember = new Map();
for (const member of membersToUpdate) {
const oldVals = {};
for (const fieldName of syncFields) {
oldVals[fieldName] = member[fieldName];
}
oldValsByMember.set(member.id, oldVals);
}
const result = super.write(ids, vals);
for (const member of membersToUpdate) {
const oldVals = oldValsByMember.get(member.id);
const diff = [];
for (const fieldName of syncFields) {
if (member[fieldName] !== oldVals[fieldName]) {
diff.push(fieldName);
}
}
if (diff.length > 0) {
const store = new mailDataHelpers.Store();
diff.push("channel", "persona");
this.browse(member.id)._to_store(store, diff);
const [partner, guest] = this.env["res.partner"]._get_current_persona();
const busChannel = guest ?? partner;
this.env["bus.bus"]._sendone(busChannel, "mail.record/insert", store.get_result());
}
}
return result;
}
_sync_field_names() {
return ["last_interest_dt", "message_unread_counter", "new_message_separator", "unpin_dt"];
}
/**
* @param {number[]} ids
* @param {boolean} is_typing
*/
notify_typing(ids, is_typing) {
const kwargs = getKwArgs(arguments, "ids", "is_typing");
ids = kwargs.ids;
delete kwargs.ids;
is_typing = kwargs.is_typing;
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").DiscussChannel} */
const DiscussChannel = this.env["discuss.channel"];
/** @type {import("mock_models").DiscussChannelMember} */
const DiscussChannelMember = this.env["discuss.channel.member"];
const members = this.browse(ids);
const notifications = [];
for (const member of members) {
const [channel] = DiscussChannel.browse(member.channel_id);
notifications.push([
channel,
"mail.record/insert",
new mailDataHelpers.Store(DiscussChannelMember.browse(member.id))
.add("discuss.channel.member", {
id: member.id,
isTyping: is_typing,
is_typing_dt: serializeDateTime(DateTime.now()),
})
.get_result(),
]);
}
BusBus._sendmany(notifications);
}
_compute_is_pinned() {
for (const member of this) {
const [channel] = this.env["discuss.channel"].browse(member.channel_id);
member.is_pinned =
!member.unpin_dt ||
member?.last_interest_dt >= member.unpin_dt ||
channel?.last_interest_dt >= member.unpin_dt;
}
}
_compute_is_self() {
const [partner, guest] = this.env["res.partner"]._get_current_persona();
for (const member of this) {
member.is_self = member.partner_id
? member.partner_id === partner?.id
: member.guest_id === guest?.id;
}
}
_compute_message_unread_counter([memberId]) {
const [member] = this.browse(memberId);
return this.env["mail.message"].search_count([
["res_id", "=", member.channel_id],
["model", "=", "discuss.channel"],
["id", ">=", member.new_message_separator],
]);
}
/** @param {number[]} ids */
_to_store(store, fields) {
const kwargs = getKwArgs(arguments, "store", "fields");
fields = kwargs.fields;
store._add_record_fields(
this,
fields.filter(
(field) => !["message_unread_counter", "persona", "channel"].includes(field)
)
);
for (const member of this) {
const data = {};
if (fields.includes("message_unread_counter")) {
data.message_unread_counter = this._compute_message_unread_counter([member.id]);
data.message_unread_counter_bus_id = this.env["bus.bus"].lastBusNotificationId;
}
if (fields.includes("channel")) {
data.channel_id = mailDataHelpers.Store.one(
this.env["discuss.channel"].browse(member.channel_id),
makeKwArgs({ as_thread: true, only_id: true })
);
}
if (fields.includes("persona")) {
store._add_record_fields(this.browse(member.id), this._to_store_persona());
}
if (Object.keys(data).length) {
store._add_record_fields(this.browse(member.id), data);
}
}
}
_to_store_persona(fields) {
return [
mailDataHelpers.Store.attr(
"partner_id",
(m) =>
mailDataHelpers.Store.one(
this.env["res.partner"].browse(m.partner_id),
makeKwArgs({
fields: this._get_store_partner_fields(fields),
})
),
makeKwArgs({
predicate: (m) =>
m.partner_id !== null && m.partner_id !== undefined && m.partner_id,
})
),
mailDataHelpers.Store.attr(
"guest_id",
(m) =>
mailDataHelpers.Store.one(
this.env["mail.guest"].browse(m.guest_id),
makeKwArgs({ fields })
),
makeKwArgs({
predicate: (m) => m.guest_id !== null && m.guest_id !== undefined && m.guest_id,
})
),
];
}
get _to_store_defaults() {
return [
mailDataHelpers.Store.one("channel_id", makeKwArgs({ as_thread: true, only_id: true })),
"create_date",
"fetched_message_id",
"seen_message_id",
"last_interest_dt",
"last_seen_dt",
"new_message_separator",
].concat(this._to_store_persona());
}
_get_store_partner_fields(fields) {
return fields;
}
/**
* @param {number[]} ids
* @param {number} last_message_id
*/
_mark_as_read(ids, last_message_id) {
const kwargs = getKwArgs(arguments, "ids", "last_message_id", "sync");
ids = kwargs.ids;
delete kwargs.ids;
last_message_id = kwargs.last_message_id;
const [member] = this.browse(ids);
if (!member) {
return;
}
const messages = this.env["mail.message"]._filter([
["model", "=", "discuss.channel"],
["res_id", "=", member.channel_id],
]);
if (!messages || messages.length === 0) {
return;
}
this._set_last_seen_message([member.id], last_message_id);
this.env["discuss.channel.member"]._set_new_message_separator(
[member.id],
last_message_id + 1
);
}
/**
* @param {number[]} ids
* @param {number} message_id
* @param {boolean} [notify=true]
*/
_set_last_seen_message(ids, message_id, notify) {
const kwargs = getKwArgs(arguments, "ids", "message_id", "notify");
ids = kwargs.ids;
delete kwargs.ids;
message_id = kwargs.message_id;
notify = kwargs.notify ?? true;
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").DiscussChannel} */
const DiscussChannel = this.env["discuss.channel"];
/** @type {import("mock_models").DiscussChannelMember} */
const DiscussChannelMember = this.env["discuss.channel.member"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
const [member] = this.browse(ids);
if (!member) {
return;
}
DiscussChannelMember.write([member.id], {
fetched_message_id: message_id,
seen_message_id: message_id,
message_unread_counter: DiscussChannelMember._compute_message_unread_counter([
member.id,
]),
});
if (notify) {
const [channel] = this.search_read([["id", "in", ids]]);
const [partner, guest] = ResPartner._get_current_persona();
let target = guest ?? partner;
if (DiscussChannel._types_allowing_seen_infos().includes(channel.channel_type)) {
target = channel;
}
BusBus._sendone(
target,
"mail.record/insert",
new mailDataHelpers.Store(
DiscussChannelMember.browse(member.id),
[
mailDataHelpers.Store.one(
"channel_id",
makeKwArgs({ as_thread: true, only_id: true })
),
"seen_message_id",
].concat(this._to_store_persona())
).get_result()
);
}
}
/**
* @param {number[]} ids
* @param {number} message_id
*/
_set_new_message_separator(ids, message_id) {
const kwargs = getKwArgs(arguments, "ids", "message_id", "sync");
ids = kwargs.ids;
delete kwargs.ids;
message_id = kwargs.message_id;
/** @type {import("mock_models").DiscussChannelMember} */
const DiscussChannelMember = this.env["discuss.channel.member"];
const [member] = DiscussChannelMember.browse(ids);
if (!member) {
return;
}
this.env["discuss.channel.member"].write([member.id], {
new_message_separator: message_id,
});
const message_unread_counter = this._compute_message_unread_counter([member.id]);
this.env["discuss.channel.member"].write([member.id], { message_unread_counter });
}
set_custom_notifications(ids, custom_notifications) {
const kwargs = getKwArgs(arguments, "ids", "custom_notifications");
ids = kwargs.ids;
delete kwargs.ids;
custom_notifications = kwargs.custom_notifications;
/** @type {import("mock_models").DiscussChannelMember} */
const DiscussChannelMember = this.env["discuss.channel.member"];
const channelMememberId = ids[0]; // simulate ensure_one.
DiscussChannelMember.write([channelMememberId], { custom_notifications });
const [partner, guest] = this.env["res.partner"]._get_current_persona();
this.env["bus.bus"]._sendone(
guest ?? partner,
"mail.record/insert",
new mailDataHelpers.Store(
DiscussChannelMember.browse(channelMememberId),
"custom_notifications"
).get_result()
);
}
}

View file

@ -0,0 +1,153 @@
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import { getKwArgs, makeKwArgs, models } from "@web/../tests/web_test_helpers";
export class DiscussChannelRtcSession extends models.ServerModel {
_name = "discuss.channel.rtc.session";
create() {
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").DiscussChannel} */
const DiscussChannel = this.env["discuss.channel"];
/** @type {import("mock_models").DiscussChannelMember} */
const DiscussChannelMember = this.env["discuss.channel.member"];
const sessionIds = super.create(...arguments);
const rtcSessions = this.browse(sessionIds);
/** @type {Record<string, DiscussChannelRtcSession>} */
const sessionsByChannelId = {};
for (const session of rtcSessions) {
const [member] = DiscussChannelMember.browse(session.channel_member_id);
if (!sessionsByChannelId[member.channel_id]) {
sessionsByChannelId[member.channel_id] = [];
}
sessionsByChannelId[member.channel_id].push(session);
}
const notifications = [];
for (const [channelId, sessions] of Object.entries(sessionsByChannelId)) {
const [channel] = DiscussChannel.search_read([["id", "=", Number(channelId)]]);
notifications.push([
channel,
"mail.record/insert",
new mailDataHelpers.Store(DiscussChannel.browse(channel.id), {
rtc_session_ids: mailDataHelpers.Store.many(
this.browse(sessions.map((session) => session.id)),
makeKwArgs({ mode: "ADD" })
),
}).get_result(),
]);
}
for (const record of rtcSessions) {
const [channel] = DiscussChannel.browse(record.channel_id);
if (channel.rtc_session_ids.length === 1) {
DiscussChannel.message_post(
channel.id,
makeKwArgs({
body: `<div data-oe-type="call" class="o_mail_notification"></div>`,
message_type: "notification",
subtype_xmlid: "mail.mt_comment",
})
);
}
}
BusBus._sendmany(notifications);
return sessionIds;
}
unlink(ids) {
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
/** @type {import("mock_models").DiscussChannel} */
const DiscussChannel = this.env["discuss.channel"];
const sessions = this.browse(ids);
for (const session of sessions) {
const [partner] = ResPartner.search_read([["id", "=", session.partner_id]]);
BusBus._sendmany([
[
partner,
"discuss.channel.rtc.session/ended",
{
sessionId: session.id,
},
],
[
partner,
"mail.record/insert",
new mailDataHelpers.Store(DiscussChannel.browse(Number(session.channel_id)), {
rtc_session_ids: mailDataHelpers.Store.many(
sessions,
makeKwArgs({ only_id: true, mode: "DELETE" })
),
}).get_result(),
],
]);
}
super.unlink(...arguments);
}
/**
* @param {number} id
* @param {{ extra?; boolean }} options
*/
_to_store(store, fields, extra) {
const kwargs = getKwArgs(arguments, "store", "fields", "extra");
fields = kwargs.fields;
extra = kwargs.extra ?? false;
store._add_record_fields(this, []);
for (const rtcSession of this) {
let data = [
mailDataHelpers.Store.one(
"channel_member_id",
makeKwArgs({
fields: ["channel"].concat(
this.env["discuss.channel.member"]._to_store_persona([
"name",
"im_status",
])
),
})
),
];
if (extra) {
data = data.concat(["is_camera_on", "is_deaf", "is_muted", "is_screen_sharing_on"]);
}
store._add_record_fields(this.browse(rtcSession.id), data);
}
}
/**
* @param {number} id
* @param {object} values
*/
_update_and_broadcast(id, values) {
const kwargs = getKwArgs(arguments, "id", "values");
id = kwargs.id;
delete kwargs.id;
values = kwargs.values;
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").DiscussChannel} */
const DiscussChannel = this.env["discuss.channel"];
/** @type {import("mock_models").DiscussChannelMember} */
const DiscussChannelMember = this.env["discuss.channel.member"];
/** @type {import("mock_models").DiscussChannelRtcSession} */
const DiscussChannelRtcSession = this.env["discuss.channel.rtc.session"];
this.write([id], values);
const [session] = DiscussChannelRtcSession.browse(id);
const [member] = DiscussChannelMember.browse(session.channel_member_id);
const [channel] = DiscussChannel.search_read([["id", "=", member.channel_id]]);
BusBus._sendone(channel, "discuss.channel.rtc.session/update_and_broadcast", {
data: new mailDataHelpers.Store(
DiscussChannelRtcSession.browse(id),
makeKwArgs({ extra: true })
).get_result(),
channelId: channel.id,
});
}
}

View file

@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class DiscussGifFavorite extends models.ServerModel {
_name = "discuss.gif.favorite";
}

View file

@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class DiscussVoiceMetadata extends models.ServerModel {
_name = "discuss.voice.metadata";
}

View file

@ -0,0 +1,74 @@
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import { getKwArgs, makeKwArgs, webModels } from "@web/../tests/web_test_helpers";
export class IrAttachment extends webModels.IrAttachment {
/**
* @param {number} ids
* @param {boolean} [force]
*/
register_as_main_attachment(ids, force) {
const kwargs = getKwArgs(arguments, "ids", "force");
ids = kwargs.ids;
delete kwargs.ids;
force = kwargs.force ?? true;
const [attachment] = this.browse(ids);
if (!attachment.res_model) {
return true; // dummy value for mock server
}
if (!this.env[attachment.res_model]._fields.message_main_attachment_id) {
return true; // dummy value for mock server
}
const [record] = this.env[attachment.res_model].search_read([
["id", "=", attachment.res_id],
]);
if (force || !record.message_main_attachment_id) {
this.env[attachment.res_model].write([record.id], {
message_main_attachment_id: attachment.id,
});
}
return true; // dummy value for mock server
}
/** @param {number} ids */
_to_store(store, fields) {
const kwargs = getKwArgs(arguments, "store", "fields");
fields = kwargs.fields;
for (const attachment of this) {
const [data] = this._read_format(
attachment.id,
fields.filter((field) => field !== "thread"),
false
);
if (fields.includes("thread")) {
data.thread =
attachment.model !== "mail.compose.message" && attachment.res_id
? mailDataHelpers.Store.one(
this.env[attachment.res_model].browse(attachment.res_id),
makeKwArgs({
as_thread: true,
only_id: true,
})
)
: false;
}
store._add_record_fields(this.browse(attachment.id), data);
}
}
get _to_store_defaults() {
return [
"checksum",
"create_date",
"mimetype",
"name",
"res_name",
"thread",
"type",
"url",
"voice_ids",
];
}
}

View file

@ -0,0 +1,63 @@
import { busModels } from "@bus/../tests/bus_test_helpers";
import { makeKwArgs } from "@web/../tests/web_test_helpers";
import { isIterable } from "@web/core/utils/arrays";
export class IrWebSocket extends busModels.IrWebSocket {
/**
* @override
* @type {typeof busModels.IrWebSocket["prototype"]["_build_bus_channel_list"]}
*/
_build_bus_channel_list(channels) {
/** @type {import("mock_models").DiscussChannel} */
const DiscussChannel = this.env["discuss.channel"];
/** @type {import("mock_models").DiscussChannelMember} */
const DiscussChannelMember = this.env["discuss.channel.member"];
/** @type {import("mock_models").MailGuest} */
const MailGuest = this.env["mail.guest"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
channels = [...super._build_bus_channel_list(channels)];
const guest = MailGuest._get_guest_from_context();
const authenticatedUserId = this.env.cookie.get("authenticated_user_sid");
const [authenticatedPartner] = authenticatedUserId
? ResPartner.search_read(
[["user_ids", "in", [authenticatedUserId]]],
makeKwArgs({ context: { active_test: false } })
)
: [];
if (!authenticatedPartner && !guest) {
return channels;
}
if (guest) {
channels.push({ model: "mail.guest", id: guest.id });
}
const discussChannelIds = channels
.filter((c) => typeof c === "string" && c.startsWith("discuss.channel_"))
.map((c) => Number(c.split("_")[1]));
channels = channels.filter(
(c) => typeof c !== "string" || !c.startsWith("discuss.channel_")
);
const allChannels = DiscussChannel.search_read([
[
"id",
"in",
DiscussChannelMember.search_read([
"|",
guest
? ["guest_id", "=", guest.id]
: ["partner_id", "=", authenticatedPartner.id],
["channel_id", "in", discussChannelIds],
]).map((member) =>
isIterable(member.channel_id) ? member.channel_id[0] : member.channel_id
),
],
]);
for (const channel of allChannels) {
channels.push(channel);
}
return channels;
}
}

View file

@ -0,0 +1,9 @@
import { fields, models } from "@web/../tests/web_test_helpers";
export class M2xAvatarUser extends models.Model {
_name = "m2x.avatar.user";
user_id = fields.Many2one({ relation: "res.users" });
partner_id = fields.Many2one({ relation: "res.partner" });
user_ids = fields.Many2many({ relation: "res.users", string: "Users" });
}

View file

@ -0,0 +1,286 @@
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import { fields, getKwArgs, makeKwArgs, models, serverState } from "@web/../tests/web_test_helpers";
import { Domain } from "@web/core/domain";
import { deserializeDate, serializeDate, today } from "@web/core/l10n/dates";
import { groupBy, sortBy, unique } from "@web/core/utils/arrays";
const { DateTime } = luxon;
export class MailActivity extends models.ServerModel {
_name = "mail.activity";
activity_type_id = fields.Many2one({
relation: "mail.activity.type",
default() {
return this.env["mail.activity.type"][0].id;
},
});
user_id = fields.Many2one({ relation: "res.users", default: () => serverState.userId });
chaining_type = fields.Generic({ default: "suggest" });
activity_category = fields.Generic({ related: false }); // removes related from server to ease creating activities
res_model = fields.Char({ string: "Related Document Model", related: false }); // removes related from server to ease creating activities
/** @param {number[]} ids */
action_feedback(ids) {
this.write(ids, { active: false, date_done: serializeDate(today()), state: "done" });
}
/** @param {number[]} ids */
action_feedback_schedule_next(ids) {
this._action_done(ids);
return {
name: "Schedule an Activity",
view_mode: "form",
res_model: "mail.activity",
views: [[false, "form"]],
type: "ir.actions.act_window",
};
}
/** @param {number[]} ids */
activity_format(ids) {
return new mailDataHelpers.Store(this.browse(ids)).get_result();
}
/** @param {number[]} ids */
_to_store(store, fields) {
/** @type {import("mock_models").MailActivityType} */
const MailActivityType = this.env["mail.activity.type"];
/** @type {import("mock_models").MailTemplate} */
const MailTemplate = this.env["mail.template"];
store._add_record_fields(
this,
fields.filter((f) => !["activity_type_id"].includes(f))
);
for (const activity of this) {
const [data] = this._read_format(
activity.id,
["activity_type_id", "summary"].filter((f) => fields.includes(f))
);
// simulate computes
const activityType = data.activity_type_id
? MailActivityType.find((r) => r.id === data.activity_type_id[0])
: false;
if (activityType) {
data.display_name = activityType.name;
data.icon = activityType.icon;
data.mail_template_ids = activityType.mail_template_ids.map((template_id) => {
const [template] = MailTemplate.browse(template_id);
return {
id: template.id,
name: template.name,
};
});
}
if (data.summary) {
data.display_name = data.summary;
}
store._add_record_fields(this.browse(activity.id), data);
}
}
get _to_store_defaults() {
return [
"activity_category",
"activity_type_id",
mailDataHelpers.Store.many(
"attachment_ids",
makeKwArgs({
fields: ["name"],
})
),
"can_write",
"chaining_type",
"create_date",
"create_uid",
"date_deadline",
"date_done",
mailDataHelpers.Store.attr("note", (activity) => ["markup", activity.note]),
"res_id",
"res_model",
"state",
"summary",
mailDataHelpers.Store.one(
"user_id",
makeKwArgs({
fields: [mailDataHelpers.Store.one("partner_id")],
})
),
];
}
/**
* @param {string} res_model
* @param {string} domain
* @param {number} limit
* @param {number} offset
* @param {boolean} fetch_done
*/
get_activity_data(res_model, domain, limit = 0, offset = 0, fetch_done) {
const kwargs = getKwArgs(arguments, "res_model", "domain", "limit", "offset", "fetch_done");
res_model = kwargs.res_model;
domain = kwargs.domain;
limit = kwargs.limit || 0;
offset = kwargs.offset || 0;
fetch_done = kwargs.fetch_done ?? false;
/** @type {import("mock_models").IrAttachment} */
const IrAttachment = this.env["ir.attachment"];
/** @type {import("mock_models").MailActivityType} */
const MailActivityType = this.env["mail.activity.type"];
/** @type {import("mock_models").MailTemplate} */
const MailTemplate = this.env["mail.template"];
// 1. Retrieve all ongoing and completed activities according to the parameters
const activityTypes = MailActivityType._filter([
"|",
["res_model", "=", res_model],
["res_model", "=", false],
]);
// Remove domain term used to filter record having "done" activities (not understood by the _filter mock)
domain = Domain.removeDomainLeaves(new Domain(domain ?? []).toList(), [
"activity_ids.active",
]).toList();
const allRecords = this.env[res_model]._filter(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._filter(activityDomain, { active_test: !res_model });
const allOngoing = allActivities.filter((a) => a.active);
const allCompleted = allActivities.filter((a) => !a.active);
// 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(IrAttachment.browse(attachmentIds).map((a) => [a.id, a]))
: {};
} else {
attachmentsById = {};
}
// 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 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 (
dateDone &&
(resIdToDateDone[resId] === undefined || dateDone > resIdToDateDone[resId])
) {
resIdToDateDone[resId] = dateDone;
}
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) =>
this._compute_state_from_date(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 ? this._compute_state_from_date(dateDeadline) : "done",
user_assigned_ids: userAssignedIds,
summaries: ongoing.map((a) => (a.summary ? a.summary : "")),
...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) => {
const templates = (type.mail_template_ids || []).map((template_id) => {
const { id, name } = MailTemplate.browse(template_id)[0];
return { id, name };
});
return {
id: type.id,
name: type.display_name,
template_ids: templates,
};
}),
activity_res_ids: ongoingResIds.concat(completedResIds).map((idStr) => Number(idStr)),
grouped_activities: groupedActivities,
};
}
/** @param {number[]} ids */
_action_done(ids) {
this.action_feedback(ids);
}
/**
* @param {DateTime} date_deadline to convert into state
* @returns {"today" | "planned" | "overdue"}
*/
_compute_state_from_date(date_deadline) {
const now = DateTime.now();
if (date_deadline.hasSame(now, "day")) {
return "today";
} else if (date_deadline > now) {
return "planned";
}
return "overdue";
}
}

View file

@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class MailActivitySchedule extends models.ServerModel {
_name = "mail.activity.schedule";
}

View file

@ -0,0 +1,28 @@
import { fields, models } from "@web/../tests/web_test_helpers";
export class MailActivityType extends models.ServerModel {
_name = "mail.activity.type";
chaining_type = fields.Generic({ default: "suggest" });
_records = [
{
id: 1,
icon: "fa-envelope",
name: "Email",
active: true,
},
{
id: 2,
category: "phonecall",
icon: "fa-phone",
name: "Call",
active: true,
},
{
id: 28,
icon: "fa-upload",
name: "Upload Document",
active: true,
},
];
}

View file

@ -0,0 +1,70 @@
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import { getKwArgs, makeKwArgs, models } from "@web/../tests/web_test_helpers";
export class MailCannedResponse extends models.ServerModel {
_name = "mail.canned.response";
_views = {
list: `
<list>
<field name="source" widget="shortcut"/>
</list>
`,
form: `
<form>
<field name="source" widget="shortcut"/>
</form>
`,
kanban: `
<kanban>
<templates>
<t t-name="card">
<field name="source" widget="shortcut"/>
</t>
</templates>
</kanban>
`,
};
create() {
const cannedReponseIds = super.create(...arguments);
this._broadcast(cannedReponseIds);
return cannedReponseIds;
}
write(ids) {
const res = super.write(...arguments);
this._broadcast(ids);
return res;
}
unlink(ids) {
this._broadcast(ids, makeKwArgs({ delete: true }));
return super.unlink(...arguments);
}
_broadcast(ids, _delete) {
const kwargs = getKwArgs(arguments, "ids", "delete");
_delete = kwargs.delete;
const notifications = [];
const [partner] = this.env["res.partner"].read(this.env.user.partner_id);
for (const cannedResponse of this.browse(ids)) {
notifications.push([
partner,
"mail.record/insert",
new mailDataHelpers.Store(
this.browse(cannedResponse.id),
makeKwArgs({ delete: _delete })
).get_result(),
]);
}
if (notifications.length) {
this.env["bus.bus"]._sendmany(notifications);
}
}
get _to_store_defaults() {
return ["source", "substitution"];
}
}

View file

@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class MailComposeMessage extends models.ServerModel {
_name = "mail.compose.message";
}

View file

@ -0,0 +1,52 @@
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import { getKwArgs, makeKwArgs, models } from "@web/../tests/web_test_helpers";
export class MailFollowers extends models.ServerModel {
_name = "mail.followers";
_compute_display_name() {
for (const record of this) {
const [partner] = this.env["res.partner"].browse(record.partner_id);
record.display_name = partner.display_name;
}
}
_to_store(store, fields) {
const kwargs = getKwArgs(arguments, "store", "fields");
store = kwargs.store;
fields = kwargs.fields;
store._add_record_fields(
this,
fields.filter((field) => field !== "subtype_ids")
);
for (const follower of this) {
const data = {};
if (fields.includes("subtype_ids")) {
data.subtype_ids = mailDataHelpers.Store.many(
this.env["mail.message.subtype"].browse(follower.subtype_ids)
);
}
if (Object.keys(data).length) {
store._add_record_fields(this.browse(follower.id), data);
}
}
}
get _to_store_defaults() {
return [
"display_name",
"email",
"is_active",
"name",
mailDataHelpers.Store.one("partner_id"),
mailDataHelpers.Store.attr("thread", (follower) =>
mailDataHelpers.Store.one(
this.env[follower.res_model].browse(follower.res_id),
makeKwArgs({ as_thread: true, only_id: true })
)
),
];
}
}

View file

@ -0,0 +1,45 @@
import { getKwArgs, models } from "@web/../tests/web_test_helpers";
export class MailGuest extends models.ServerModel {
_name = "mail.guest";
_get_guest_from_context() {
const guestId = this.env.cookie.get("dgid");
return guestId ? this.search_read([["id", "=", guestId]])[0] : null;
}
/**
* @param {Number[]} ids
* @returns {Record<string, ModelRecord>}
*/
_to_store(store, fields) {
const kwargs = getKwArgs(arguments, "store", "fields");
fields = kwargs.fields;
store._add_record_fields(
this,
fields.filter((field) => !["avatar_128"].includes(field))
);
for (const guest of this) {
const data = {};
if (fields.includes("avatar_128")) {
data.avatar_128_access_token = guest.id;
data.write_date = guest.write_date;
}
if (fields.includes("im_status")) {
data.im_status = "offline";
data.im_status_access_token = guest.id;
}
if (Object.keys(data).length) {
store._add_record_fields(this.browse(guest.id), data);
}
}
}
get _to_store_defaults() {
return ["avatar_128", "im_status", "name"];
}
_set_auth_cookie(guestId) {
this.env.cookie.set("dgid", guestId);
}
}

View file

@ -0,0 +1,18 @@
import { models } from "@web/../tests/web_test_helpers";
export class MailLinkPreview extends models.ServerModel {
_name = "mail.link.preview";
get _to_store_defaults() {
return [
"image_mimetype",
"message_id",
"og_description",
"og_image",
"og_mimetype",
"og_title",
"og_type",
"source_url",
];
}
}

View file

@ -0,0 +1,664 @@
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import {
Command,
fields,
getKwArgs,
makeKwArgs,
models,
serverState,
} from "@web/../tests/web_test_helpers";
import { Domain } from "@web/core/domain";
/** @typedef {import("@web/core/domain").DomainListRepr} DomainListRepr */
export class MailMessage extends models.ServerModel {
_name = "mail.message";
author_id = fields.Generic({ default: () => serverState.partnerId });
pinned_at = fields.Generic({ default: false });
/** @param {DomainListRepr} [domain] */
mark_all_as_read(domain) {
({ domain } = getKwArgs(arguments, "domain"));
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").MailNotification} */
const MailNotification = this.env["mail.notification"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
const notifDomain = [
["res_partner_id", "=", this.env.user.partner_id],
["is_read", "=", false],
];
if (domain) {
const messages = this._filter(domain);
const ids = messages.map((messages) => messages.id);
this.set_message_done(ids);
return ids;
}
const notifications = MailNotification._filter(notifDomain);
MailNotification.write(
notifications.map((notification) => notification.id),
{ is_read: true }
);
const messageIds = [];
for (const notification of notifications) {
if (!messageIds.includes(notification.mail_message_id)) {
messageIds.push(notification.mail_message_id);
}
}
const messages = this.browse(messageIds);
// simulate compute that should be done based on notifications
for (const message of messages) {
this.write([message.id], {
needaction: false,
});
}
const [partner] = ResPartner.read(this.env.user.partner_id);
BusBus._sendone(partner, "mail.message/mark_as_read", {
message_ids: messageIds,
needaction_inbox_counter: ResPartner._get_needaction_count(this.env.user.partner_id),
});
return messageIds;
}
/** @param {number[]} ids */
_to_store(store, fields, for_current_user, add_followers) {
const kwargs = getKwArgs(arguments, "store", "fields", "for_current_user", "add_followers");
store = kwargs.store;
fields = kwargs.fields;
for_current_user = kwargs.for_current_user ?? false;
add_followers = kwargs.add_followers ?? false;
/** @type {import("mock_models").MailFollowers} */
const MailFollowers = this.env["mail.followers"];
/** @type {import("mock_models").MailMessageLinkPreview} */
const MailMessageLinkPreview = this.env["mail.message.link.preview"];
/** @type {import("mock_models").MailNotification} */
const MailNotification = this.env["mail.notification"];
/** @type {import("mock_models").MailThread} */
const MailThread = this.env["mail.thread"];
/** @type {import("mock_models").MailTrackingValue} */
const MailTrackingValue = this.env["mail.tracking.value"];
/** @type {import("mock_models").ResFake} */
const ResFake = this.env["res.fake"];
const notifications = MailNotification._filtered_for_web_client(
MailNotification._filter([["mail_message_id", "in", this.map((m) => m.id)]]).map(
(n) => n.id
)
);
store._add_record_fields(
this,
fields.filter((field) => !["notification_ids", "mail_link_preview_ids"].includes(field))
);
for (const message of this) {
const thread = message.model && this.env[message.model].browse(message.res_id)[0];
if (thread) {
const thread_data = {
display_name: thread.name ?? thread.display_name,
module_icon: "/base/static/description/icon.png",
};
if (for_current_user && add_followers) {
thread_data.selfFollower = mailDataHelpers.Store.one(
MailFollowers.browse(
MailFollowers.search([
["res_model", "=", message.model],
["res_id", "=", message.res_id],
["partner_id", "=", this.env.user.partner_id],
])
),
makeKwArgs({
fields: ["is_active", mailDataHelpers.Store.one("partner_id", [])],
})
);
}
store._add_record_fields(
this.env[message.model].browse(message.res_id),
thread_data,
makeKwArgs({ as_thread: true })
);
}
const data = {
default_subject:
message.model &&
message.res_id &&
(message.model === "res.fake"
? ResFake._message_compute_subject([message.res_id])
: MailThread._message_compute_subject([message.res_id])
).get(message.res_id),
record_name: thread?.name ?? thread?.display_name,
scheduledDatetime: false,
thread: mailDataHelpers.Store.one(
message.model && this.env[message.model].browse(message.res_id),
makeKwArgs({ as_thread: true, only_id: true })
),
};
if (fields.includes("message_link_preview_ids")) {
data.message_link_preview_ids = mailDataHelpers.Store.many(
MailMessageLinkPreview.browse(message.message_link_preview_ids).filter(
(lpm) => !lpm.is_hidden
)
);
}
if (fields.includes("notification_ids")) {
data.notification_ids = mailDataHelpers.Store.many(
notifications.filter(
(notification) => notification.mail_message_id == message.id
)
);
}
if (for_current_user) {
data["needaction"] = Boolean(
this.env.user &&
MailNotification.search([
["mail_message_id", "=", message.id],
["is_read", "=", false],
["res_partner_id", "=", this.env.user.partner_id],
]).length
);
data["starred"] = message.starred_partner_ids?.includes(this.env.user?.partner_id);
const trackingValues = MailTrackingValue.browse(message.tracking_value_ids);
const formattedTrackingValues =
MailTrackingValue._tracking_value_format(trackingValues);
data["trackingValues"] = formattedTrackingValues;
}
store._add_record_fields(this.browse(message.id), data);
}
this._author_to_store(store);
this._store_add_linked_messages(store);
}
get _to_store_defaults() {
return [
mailDataHelpers.Store.many(
"attachment_ids",
makeKwArgs({
sort: (a1, a2) => a1.id - a2.id,
})
),
mailDataHelpers.Store.attr("body", (m) => ["markup", m.body]),
"create_date",
"date",
"message_type",
"model",
"message_link_preview_ids",
"notification_ids",
mailDataHelpers.Store.one("parent_id", makeKwArgs({ format_reply: false })),
mailDataHelpers.Store.many("partner_ids", makeKwArgs({ fields: ["name"] })),
"pinned_at",
mailDataHelpers.Store.attr("reactions", (m) =>
mailDataHelpers.Store.many(this.env["mail.message.reaction"].browse(m.reaction_ids))
),
"res_id",
"subject",
"write_date",
mailDataHelpers.Store.one(
"subtype_id",
makeKwArgs({
fields: ["description"],
predicate: (m) => m.subtype_id,
})
),
];
}
_author_to_store(store) {
/** @type {import("mock_models").MailGuest} */
const MailGuest = this.env["mail.guest"];
/** @type {import("mock_models").MailMessage} */
const MailMessage = this.env["mail.message"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
for (const message of this) {
const data = {
author_id: false,
author_guest_id: false,
email_from: message.email_from,
};
if (message.author_guest_id) {
data.author_guest_id = mailDataHelpers.Store.one(
MailGuest.browse(message.author_guest_id),
makeKwArgs({ fields: ["avatar_128", "name"] })
);
} else if (message.author_id) {
data.author_id = mailDataHelpers.Store.one(
ResPartner.browse(message.author_id),
makeKwArgs({ fields: ["avatar_128", "is_company", "name", "user"] })
);
}
store._add_record_fields(MailMessage.browse(message.id), data);
}
}
/**
* Simulates `set_message_done` on `mail.message`, which turns provided
* needaction message to non-needaction (i.e. they are marked as read from
* from the Inbox mailbox). Also notify on the longpoll bus that the
* messages have been marked as read, so that UI is updated.
*
* @param {number[]} ids
*/
set_message_done(ids) {
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").MailNotification} */
const MailNotification = this.env["mail.notification"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
if (!this.env.user) {
return;
}
const messages = this.browse(ids);
const notifications = MailNotification._filter([
["res_partner_id", "=", this.env.user.partner_id],
["is_read", "=", false],
["mail_message_id", "in", messages.map((messages) => messages.id)],
]);
if (notifications.length === 0) {
return;
}
MailNotification.write(
notifications.map((notification) => notification.id),
{ is_read: true }
);
// simulate compute that should be done based on notifications
for (const message of messages) {
this.write([message.id], {
needaction: false,
});
const [partner] = ResPartner.read(this.env.user.partner_id);
BusBus._sendone(partner, "mail.message/mark_as_read", {
message_ids: [message.id],
needaction_inbox_counter: ResPartner._get_needaction_count(
this.env.user.partner_id
),
});
}
}
unlink() {
const messageByPartnerId = {};
for (const message of this) {
for (const partnerId of message.partner_ids) {
messageByPartnerId[partnerId] ??= [];
messageByPartnerId[partnerId].push(message);
}
if (
this.env["mail.notification"]
.browse(message.notification_ids)
.some(({ failure_type }) => Boolean(failure_type))
) {
messageByPartnerId[message.author_id] ??= [];
messageByPartnerId[message.author_id].push(message);
}
}
for (const [partnerId, messages] of Object.entries(messageByPartnerId)) {
const [partner] = this.env["res.partner"].browse(parseInt(partnerId));
this.env["bus.bus"]._sendone(partner, "mail.message/delete", {
message_ids: messages.map(({ id }) => id),
});
}
return super.unlink(...arguments);
}
/** @param {number[]} ids */
toggle_message_starred(ids) {
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
const messages = this.browse(ids);
const store = new mailDataHelpers.Store();
for (const message of messages) {
const wasStarred = message.starred_partner_ids.includes(this.env.user.partner_id);
this.write([message.id], {
starred_partner_ids: [
wasStarred
? Command.unlink(this.env.user.partner_id)
: Command.link(this.env.user.partner_id),
],
});
const [partner] = ResPartner.read(this.env.user.partner_id);
BusBus._sendone(partner, "mail.message/toggle_star", {
message_ids: [message.id],
starred: !wasStarred,
});
store.add(this.browse(message.id), { starred: !wasStarred });
}
return store.get_result();
}
unstar_all() {
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
const messages = this._filter([["starred_partner_ids", "in", this.env.user.partner_id]]);
this.write(
messages.map((message) => message.id),
{ starred_partner_ids: [Command.unlink(this.env.user.partner_id)] }
);
const [partner] = ResPartner.read(this.env.user.partner_id);
BusBus._sendone(partner, "mail.message/toggle_star", {
message_ids: messages.map((message) => message.id),
starred: false,
});
}
/** @param {number} id */
_bus_notification_target(id) {
/** @type {import("mock_models").DiscussChannel} */
const DiscussChannel = this.env["discuss.channel"];
/** @type {import("mock_models").MailGuest} */
const MailGuest = this.env["mail.guest"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
const [message] = this.search_read([["id", "=", id]]);
if (message.model === "discuss.channel") {
return DiscussChannel.search_read([["id", "=", message.res_id]])[0];
}
if (ResUsers._is_public(this.env.uid)) {
MailGuest._get_guest_from_context();
}
return ResPartner.read(this.env.user.partner_id)[0];
}
/**
* @param {number} id
* @param {string} content
* @param {number} partner_id
* @param {number} guest_id
* @param {string} action
* @param {import("@mail/../tests/mock_server/mail_mock_server").mailDataHelpers.Store} store
*/
_message_reaction(id, content, partner_id, guest_id, action, store) {
({ id, content, partner_id, guest_id, action, store } = getKwArgs(
arguments,
"id",
"content",
"partner_id",
"guest_id",
"action",
"store"
));
/** @type {import("mock_models").MailMessageReaction} */
const MailMessageReaction = this.env["mail.message.reaction"];
const [reaction] = MailMessageReaction.search_read([
["content", "=", content],
["message_id", "=", id],
["partner_id", "=", partner_id],
["guest_id", "=", guest_id],
]);
if (action === "add" && !reaction) {
MailMessageReaction.create({
content,
message_id: id,
partner_id,
guest_id,
});
}
if (action === "remove" && reaction) {
MailMessageReaction.unlink(reaction.id);
}
this._reaction_group_to_store(id, store, content);
this._bus_send_reaction_group(id, content);
}
_bus_send_reaction_group(id, content) {
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
const store = new mailDataHelpers.Store();
this._reaction_group_to_store(id, store, content);
BusBus._sendone(
this._bus_notification_target(id),
"mail.record/insert",
store.get_result()
);
}
_reaction_group_to_store(id, store, content) {
/** @type {import("mock_models").MailMessageReaction} */
const MailMessageReaction = this.env["mail.message.reaction"];
const reactions = MailMessageReaction.search([
["message_id", "=", id],
["content", "=", content],
]);
let reaction_group = mailDataHelpers.Store.many(
MailMessageReaction.browse(reactions),
makeKwArgs({ mode: "ADD" })
);
if (reactions.length === 0) {
reaction_group = [["DELETE", { message: this.browse(id), content: content }]];
}
store.add(this.browse(id), { reactions: reaction_group });
}
/**
* @param {DomainListRepr} domain
* @param {number} [before]
* @param {number} [after]
* @param {number} [limit=30]
* @returns {Object[]}
*/
_message_fetch(domain, thread, search_term, is_notification, before, after, around, limit) {
/** @type {import("mock_models").IrAttachment} */
const IrAttachment = this.env["ir.attachment"];
/** @type {import("mock_models").MailMessageSubtype} */
const MailMessageSubtype = this.env["mail.message.subtype"];
/** @type {import("mock_models").MailTrackingValue} */
const MailTrackingValue = this.env["mail.tracking.value"];
({
domain,
thread,
search_term,
is_notification,
before,
after,
around,
limit = 30,
} = getKwArgs(
arguments,
"domain",
"thread",
"search_term",
"is_notification",
"before",
"after",
"around",
"limit"
));
const res = {};
if (thread) {
domain = domain.concat([
["res_id", "=", parseInt(thread[0].id)],
["model", "=", thread._name],
["message_type", "!=", "user_notification"],
]);
}
if (is_notification === true) {
domain.push(["message_type", "=", "notification"]);
} else if (is_notification === false) {
domain.push(["message_type", "!=", "notification"]);
}
if (search_term) {
domain = new Domain(domain || []);
search_term = search_term.replace(" ", "%");
const subtypeIds = MailMessageSubtype.search([["description", "ilike", search_term]]);
const irAttachmentIds = IrAttachment.search([["name", "ilike", search_term]]);
let message_domain = Domain.or([
[["body", "ilike", search_term]],
[["attachment_ids", "in", irAttachmentIds]],
[["subject", "ilike", search_term]],
[["subtype_ids", "in", subtypeIds]],
]);
if (thread && is_notification !== false) {
const messageIds = this.search([
["res_id", "=", parseInt(thread[0].id)],
["model", "=", thread._name],
]);
const trackingValueDomain = Domain.and([
[["mail_message_id", "in", messageIds]],
this._get_tracking_values_domain(search_term),
]).toList();
const trackingValueIds = MailTrackingValue.search(trackingValueDomain);
const trackingMessageIds = this.search([
["tracking_value_ids", "in", trackingValueIds],
]);
message_domain = Domain.or([
message_domain,
new Domain([["id", "in", trackingMessageIds]]),
]);
}
domain = Domain.and([domain, message_domain]).toList();
res.count = this.search_count(domain);
}
if (around !== undefined) {
const messagesBefore = this._filter(domain.concat([["id", "<=", around]])).sort(
(m1, m2) => m2.id - m1.id
);
messagesBefore.length = Math.min(messagesBefore.length, limit / 2);
const messagesAfter = this._filter(domain.concat([["id", ">", around]])).sort(
(m1, m2) => m1.id - m2.id
);
messagesAfter.length = Math.min(messagesAfter.length, limit / 2);
const messages = messagesAfter
.concat(messagesBefore.reverse())
.sort((m1, m2) => m2.id - m1.id);
return { ...res, messages };
}
if (before) {
domain.push(["id", "<", before]);
}
if (after) {
domain.push(["id", ">", after]);
}
const messages = this._filter(domain).sort((m1, m2) => m2.id - m1.id);
// pick at most 'limit' messages
messages.length = Math.min(messages.length, limit);
res.messages = messages;
return res;
}
_get_tracking_values_domain(search_term) {
let numeric_term = false;
const epsilon = 1e-9;
numeric_term = parseFloat(search_term);
const field_names = [
"old_value_char",
"new_value_char",
"old_value_text",
"new_value_text",
"old_value_datetime",
"new_value_datetime",
];
let domain = Domain.or(
field_names.map((field_name) => new Domain([[field_name, "ilike", search_term]]))
);
if (numeric_term) {
const float_domain = Domain.or(
["old_value_float", "new_value_float"].map(
(fieldName) =>
new Domain([
[fieldName, ">=", numeric_term - epsilon],
[fieldName, "<=", numeric_term + epsilon],
])
)
);
domain = Domain.or([domain, float_domain]);
}
if (Number.isInteger(numeric_term)) {
domain = Domain.or([
domain,
new Domain([["old_value_integer", "=", numeric_term]]),
new Domain([["new_value_integer", "=", numeric_term]]),
]);
}
return domain;
}
/**
* @param {import("@mail/../tests/mock_server/mail_mock_server").mailDataHelpers.Store} store
*/
_store_add_linked_messages(store) {
const mids = [];
for (const message of this) {
const body = message?.body || "";
const doc = new DOMParser().parseFromString(body, "text/html");
const anchors = doc.querySelectorAll(
'a.o_message_redirect[data-oe-model="mail.message"][data-oe-id]'
);
for (const a of anchors) {
const idStr = a.getAttribute("data-oe-id");
const id = parseInt(idStr, 10);
if (!Number.isNaN(id)) {
mids.push(id);
}
}
}
for (const message of this.env["mail.message"]._filter([["id", "in", mids]])) {
if (message.model && message.res_id) {
const record = this.env[message.model]._filter([["id", "=", message.res_id]]);
store.add(
this.env["mail.message"].browse(message.id),
makeKwArgs({
fields: [
"model",
"res_id",
mailDataHelpers.Store.attr(
"thread",
mailDataHelpers.Store.one(
this.env[message.model].browse(record.id),
makeKwArgs({ fields: ["display_name"] })
)
),
],
})
);
}
}
}
/**
* @param {number[]} ids
* @param {import("@mail/../tests/mock_server/mail_mock_server").mailDataHelpers.Store} store
*/
_message_notifications_to_store(ids, store) {
/** @type {import("mock_models").MailNotification} */
const MailNotification = this.env["mail.notification"];
for (const message of this.browse(ids)) {
store.add(this.browse(message.id), {
author_id: mailDataHelpers.Store.one(
this.env["res.partner"].browse(message.author_id),
makeKwArgs({ only_id: true })
),
body: message.body,
date: message.date,
message_type: message.message_type,
notification_ids: mailDataHelpers.Store.many(
MailNotification._filtered_for_web_client(
MailNotification.search([["mail_message_id", "=", message.id]])
)
),
thread: mailDataHelpers.Store.one(
message.model ? this.env[message.model].browse(message.res_id) : false,
makeKwArgs({ as_thread: true, fields: ["modelName", "display_name"] })
),
});
}
}
}

View file

@ -0,0 +1,14 @@
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import { fields, models } from "@web/../tests/web_test_helpers";
export class MailMessageLinkPreview extends models.ServerModel {
_name = "mail.message.link.preview";
link_preview_id = fields.Many2one({ relation: "mail.link.preview" });
message_id = fields.Many2one({ relation: "mail.message" });
is_hidden = fields.Generic({ default: false });
get _to_store_defaults() {
return [mailDataHelpers.Store.one("link_preview_id"), "message_id"];
}
}

View file

@ -0,0 +1,39 @@
import { makeKwArgs, models } from "@web/../tests/web_test_helpers";
import { groupBy } from "@web/core/utils/arrays";
import { mailDataHelpers } from "../mail_mock_server";
export class MailMessageReaction extends models.ServerModel {
_name = "mail.message.reaction";
_to_store(store) {
/** @type {import("mock_models").MailGuest} */
const MailGuest = this.env["mail.guest"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
const reactionGroups = groupBy(this, (r) => [r.message_id, r.content]);
for (const groupId in reactionGroups) {
const reactionGroup = reactionGroups[groupId];
const { message_id, content } = reactionGroups[groupId][0];
const guests = MailGuest.browse(reactionGroup.map((reaction) => reaction.guest_id));
const partners = ResPartner.browse(
reactionGroup.map((reaction) => reaction.partner_id)
);
const data = {
content: content,
count: reactionGroup.length,
guests: mailDataHelpers.Store.many(
guests,
makeKwArgs({ fields: ["avatar_128", "name"] })
),
message: message_id,
partners: mailDataHelpers.Store.many(
partners,
makeKwArgs({ fields: ["avatar_128", "name"] })
),
sequence: Math.min(reactionGroup.map((reaction) => reaction.id)),
};
store.add("MessageReactions", data);
}
}
}

View file

@ -0,0 +1,32 @@
import { fields, models } from "@web/../tests/web_test_helpers";
export class MailMessageSubtype extends models.ServerModel {
_name = "mail.message.subtype";
default = fields.Generic({ default: true });
subtype_xmlid = fields.Char();
_records = [
{
default: false,
internal: true,
name: "Activities",
sequence: 90,
subtype_xmlid: "mail.mt_activities",
},
{
default: false,
internal: true,
name: "Note",
sequence: 100,
subtype_xmlid: "mail.mt_note",
track_recipients: true,
},
{
name: "Discussions",
sequence: 0,
subtype_xmlid: "mail.mt_comment",
track_recipients: true,
},
];
}

View file

@ -0,0 +1,43 @@
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import { makeKwArgs, models } from "@web/../tests/web_test_helpers";
export class MailNotification extends models.ServerModel {
_name = "mail.notification";
/** @param {number[]} ids */
_filtered_for_web_client(ids) {
/** @type {import("mock_models").MailMessage} */
const MailMessage = this.env["mail.message"];
/** @type {import("mock_models").MailMessageSubtype} */
const MailMessageSubtype = this.env["mail.message.subtype"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
return this.browse(ids).filter((notification) => {
const [partner] = ResPartner.browse(notification.res_partner_id);
if (
["bounce", "exception", "canceled"].includes(notification.notification_status) ||
(partner && partner.partner_share)
) {
return true;
}
const [message] = MailMessage.browse(notification.mail_message_id);
const subtypes = message.subtype_id
? MailMessageSubtype.browse(message.subtype_id)
: [];
return subtypes.length === 0 || subtypes[0].track_recipients;
});
}
get _to_store_defaults() {
return [
"failure_type",
"mail_email_address",
"mail_message_id",
"notification_status",
"notification_type",
mailDataHelpers.Store.one("res_partner_id", makeKwArgs({ fields: ["name", "email"] })),
];
}
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class MailPushDevice extends models.ServerModel {
_name = "mail.push.device";
get_web_push_vapid_public_key() {
return "BPNWmvXxxCOd-QBNMZeF2pL0CAFcZebRRZJzco-s2C2oadl9kQU59hNJW4IscNmzs9L7q9ID9cLCzSIH1vZpqBY";
}
unregister_devices() {}
}

View file

@ -0,0 +1,29 @@
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import { fields, models, serverState } from "@web/../tests/web_test_helpers";
export class MailScheduledMessage extends models.ServerModel {
_inherit = "mail.scheduled.message";
author_id = fields.Generic({ default: () => serverState.partnerId });
_to_store(store) {
/** @type {import("mock_models").IrAttachment} */
const IrAttachment = this.env["ir.attachment"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
for (const message of this) {
store.add("mail.scheduled.message", {
attachment_ids: mailDataHelpers.Store.many(
IrAttachment.browse(message.attachment_ids)
),
author_id: mailDataHelpers.Store.one(ResPartner.browse(message.author_id)),
body: ["markup", message.body],
id: message.id,
scheduled_date: message.scheduled_date,
subject: message.subject,
is_note: message.is_note,
});
}
}
}

View file

@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class MailTemplate extends models.ServerModel {
_name = "mail.template";
}

View file

@ -0,0 +1,702 @@
import { parseEmail } from "@mail/utils/common/format";
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import {
Command,
getKwArgs,
makeKwArgs,
models,
unmakeKwArgs,
} from "@web/../tests/web_test_helpers";
export class MailThread extends models.ServerModel {
_name = "mail.thread";
_inherit = ["base"];
/**
* @param {number[]} ids
* @param {number} [after]
* @param {number} [limit=100]
* @param {boolean} [filter_recipients]
*/
message_get_followers(ids, after, limit, filter_recipients) {
const kwargs = getKwArgs(arguments, "ids", "after", "limit", "filter_recipients");
ids = kwargs.ids;
after = kwargs.after || 0;
limit = kwargs.limit || 100;
filter_recipients = kwargs.filter_recipients || false;
/** @type {import("mock_models").MailThread} */
const MailThread = this.env["mail.thread"];
const store = new mailDataHelpers.Store();
MailThread._message_followers_to_store.call(
this,
ids,
store,
after,
limit,
filter_recipients
);
return store.get_result();
}
_message_followers_to_store(ids, store, after, limit, filter_recipients, reset) {
const kwargs = getKwArgs(
arguments,
"ids",
"store",
"after",
"limit",
"filter_recipients",
"reset"
);
ids = kwargs.ids;
store = kwargs.store;
after = kwargs.after || 0;
limit = kwargs.limit || 100;
filter_recipients = kwargs.filter_recipients || false;
reset = kwargs.reset || false;
/** @type {import("mock_models").MailFollowers} */
const MailFollowers = this.env["mail.followers"];
const domain = [
["res_id", "=", ids[0]],
["res_model", "=", this._name],
["partner_id", "!=", this.env.user.partner_id],
];
if (after) {
domain.push(["id", ">", after]);
}
if (filter_recipients) {
// not implemented for simplicity
}
const followers = MailFollowers._filter(domain).sort(
(f1, f2) => (f1.id < f2.id ? -1 : 1) // sorted from lowest ID to highest ID (i.e. from oldest to youngest)
);
followers.length = Math.min(followers.length, limit);
store.add(
this.browse(ids[0]),
{
[filter_recipients ? "recipients" : "followers"]: mailDataHelpers.Store.many(
followers,
makeKwArgs({ mode: reset ? "REPLACE" : "ADD" })
),
},
makeKwArgs({ as_thread: true })
);
}
/** @param {number[]} ids */
message_post(ids) {
const kwargs = getKwArgs(arguments, "ids", "subtype_id", "tracking_value_ids");
ids = kwargs.ids;
delete kwargs.ids;
/** @type {import("mock_models").IrAttachment} */
const IrAttachment = this.env["ir.attachment"];
/** @type {import("mock_models").MailGuest} */
const MailGuest = this.env["mail.guest"];
/** @type {import("mock_models").MailMessage} */
const MailMessage = this.env["mail.message"];
/** @type {import("mock_models").MailNotification} */
const MailNotification = this.env["mail.notification"];
/** @type {import("mock_models").MailThread} */
const MailThread = this.env["mail.thread"];
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
/** @type {import("mock_models").MailMessageSubtype} */
const MailMessageSubtype = this.env["mail.message.subtype"];
const id = ids[0]; // ensure_one
if (kwargs.context?.mail_post_autofollow && kwargs.partner_ids?.length) {
MailThread.message_subscribe.call(this, ids, kwargs.partner_ids, []);
}
if (kwargs.attachment_ids) {
const attachments = IrAttachment._filter([
["id", "in", kwargs.attachment_ids],
["res_model", "=", "mail.compose.message"],
["res_id", "=", false],
]);
const attachmentIds = attachments.map((attachment) => attachment.id);
IrAttachment.write(attachmentIds, {
res_id: id,
res_model: this._name,
});
kwargs.attachment_ids = attachmentIds.map((attachmentId) => Command.link(attachmentId));
}
let author_id;
let email_from;
const author_guest_id =
ResUsers._is_public(this.env.uid) && MailGuest._get_guest_from_context()?.id;
if (!author_guest_id) {
[author_id, email_from] = MailThread._message_compute_author.call(
this,
kwargs.author_id,
kwargs.email_from
);
}
email_from ||= false;
const message_type = kwargs.message_type || "notification";
const values = unmakeKwArgs({
...kwargs,
author_id,
author_guest_id,
email_from,
message_type,
subtype_id: MailMessageSubtype._filter([
["subtype_xmlid", "=", kwargs.subtype_xmlid || "mail.mt_note"],
])[0]?.id,
model: this._name,
res_id: id,
});
delete values.context;
delete values.subtype_xmlid;
const messageId = MailMessage.create(values);
for (const partnerId of kwargs.partner_ids || []) {
MailNotification.create({
mail_message_id: messageId,
notification_type: "inbox",
res_partner_id: partnerId,
});
}
MailThread._notify_thread.call(this, ids, messageId, kwargs.context?.temporary_id);
return [messageId];
}
/**
* @param {number[]} ids
* @param {number[]} partner_ids
* @param {number[]} subtype_ids
*/
message_subscribe(ids, partner_ids, subtype_ids) {
const kwargs = getKwArgs(arguments, "ids", "partner_ids", "subtype_ids");
ids = kwargs.ids;
delete kwargs.ids;
partner_ids = kwargs.partner_ids || [];
subtype_ids = kwargs.subtype_ids || [];
/** @type {import("mock_models").MailFollowers} */
const MailFollowers = this.env["mail.followers"];
/** @type {import("mock_models").MailMessageSubtype} */
const MailMessageSubtype = this.env["mail.message.subtype"];
for (const id of ids) {
for (const partner_id of partner_ids) {
let followerId = MailFollowers.search([["partner_id", "=", partner_id]])[0];
if (!followerId) {
if (!subtype_ids?.length) {
subtype_ids = MailMessageSubtype.search([
["default", "=", true],
"|",
["res_model", "=", this._name],
["res_model", "=", false],
]);
}
followerId = MailFollowers.create({
is_active: true,
partner_id,
res_id: id,
res_model: this._name,
subtype_ids: subtype_ids,
});
}
this.env[this._name].write(ids, {
message_follower_ids: [Command.link(followerId)],
});
}
}
}
/**
* @param {number[]} ids
* @param {number[]} partner_ids
*/
message_unsubscribe(ids, partner_ids) {
const kwargs = getKwArgs(arguments, "ids", "partner_ids");
ids = kwargs.ids;
delete kwargs.ids;
partner_ids = kwargs.partner_ids || [];
/** @type {import("mock_models").MailFollowers} */
const MailFollowers = this.env["mail.followers"];
if (!partner_ids.length) {
return true;
}
const followers = MailFollowers.search([
["res_model", "=", this._name],
["res_id", "in", ids],
["partner_id", "in", partner_ids],
]);
MailFollowers.unlink(followers);
}
/**
* Note that this method is overridden by snailmail module but not simulated here.
*
* @param {string} notification_type
*/
notify_cancel_by_type(notification_type) {
const kwargs = getKwArgs(arguments, "notification_type");
notification_type = kwargs.notification_type;
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").MailMessage} */
const MailMessage = this.env["mail.message"];
/** @type {import("mock_models").MailNotification} */
const MailNotification = this.env["mail.notification"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
// Query matching notifications
const notifications = MailNotification._filter([
["notification_type", "=", notification_type],
["notification_status", "in", ["bounce", "exception"]],
]).filter((notification) => {
const [message] = MailMessage.browse(notification.mail_message_id);
return message.model === this._name && message.author_id === this.env.user.partner_id;
});
// Update notification status
MailNotification.write(
notifications.map((notification) => notification.id),
{ notification_status: "canceled" }
);
// Send bus notifications to update status of notifications in the web client
const [partner] = ResPartner.read(this.env.user.partner_id);
const store = new mailDataHelpers.Store();
MailMessage._message_notifications_to_store(
notifications.map((notification) => notification.mail_message_id),
store
);
BusBus._sendone(partner, "mail.record/insert", store.get_result());
}
/**
* @param {number} id
* @param {Object} result
* @param {number} partner
* @param {string} email
* @param {string} lang
* @param {string} reason
* @param {string} name
*/
_message_add_suggested_recipient(id, result, partner, email, lang, reason = "", name) {
const kwargs = getKwArgs(
arguments,
"id",
"result",
"partner",
"email",
"lang",
"reason",
"name"
);
id = kwargs.id;
delete kwargs.id;
result = kwargs.result;
partner = kwargs.partner;
email = kwargs.email;
lang = kwargs.lang;
reason = kwargs.reason;
name = kwargs.name;
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
if (email !== undefined && partner === undefined) {
const partnerInfo = parseEmail(email);
partner = ResPartner._filter([["email", "=", partnerInfo[1]]])[0];
}
if (partner) {
result.push({
partner_id: partner.id,
name: partner.display_name,
email: partner.email,
lang,
reason,
create_values: {},
});
} else {
const partnerCreateValues = this._get_customer_information(id);
result.push({
email,
name,
lang,
reason,
create_values: partnerCreateValues,
});
}
return result;
}
_get_customer_information(id) {
return {};
}
/**
* @param {number} [author_id]
* @param {string} [email_from]
*/
_message_compute_author(author_id, email_from) {
const kwargs = getKwArgs(arguments, "author_id", "email_from");
author_id = kwargs.author_id;
email_from = kwargs.email_from;
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
if (!author_id) {
// For simplicity partner is not guessed from email_from here, but
// that would be the first step on the server.
const [author] = ResPartner.browse(this.env.user.partner_id);
author_id = author.id;
email_from = `${author.display_name} <${author.email}>`;
}
if (!email_from && author_id) {
const [author] = ResPartner.browse(author_id);
email_from = `${author.display_name} <${author.email}>`;
}
if (email_from === undefined) {
if (author_id) {
const [author] = ResPartner.browse(author_id);
email_from = `${author.display_name} <${author.email}>`;
}
}
if (!email_from) {
throw Error("Unable to log message due to missing author email.");
}
return [author_id, email_from];
}
/** @param {number[]} ids */
_message_compute_subject(ids) {
const records = this.browse(ids);
return new Map(records.map((record) => [record.id, record.name || ""]));
}
/** @param {number[]} ids */
_message_get_suggested_recipients(ids, additional_partners = [], primary_email = false) {
/** @type {import("mock_models").MailThread} */
const MailThread = this.env["mail.thread"];
/** @type {import("mock_models").ResFake} */
const ResFake = this.env["res.fake"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
if (this._name === "res.fake") {
return ResFake._message_get_suggested_recipients(
ids,
additional_partners,
primary_email
);
}
const result = ids.reduce((result, id) => (result[id] = []), {});
const model = this.env[this._name];
for (const record in model.browse(ids)) {
if (record.user_id) {
const user = ResUsers.browse(record.user_id);
if (user.partner_id) {
const reason = model._fields["user_id"].string;
const partner = ResPartner.browse(user.partner_id);
MailThread._message_add_suggested_recipient.call(
this,
result,
makeKwArgs({
email: partner.email,
partner: user.partner_id,
reason,
})
);
}
}
}
return result;
}
/**
* Simplified version that sends notification to author and channel.
*
* @param {number[]} ids
* @param {number} message_id
* @param {number} [temporary_id]
*/
_notify_thread(ids, message_id, temporary_id) {
const kwargs = getKwArgs(arguments, "ids", "message_id", "temporary_id");
ids = kwargs.ids;
delete kwargs.ids;
message_id = kwargs.message_id;
temporary_id = kwargs.temporary_id;
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").DiscussChannel} */
const DiscussChannel = this.env["discuss.channel"];
/** @type {import("mock_models").MailMessage} */
const MailMessage = this.env["mail.message"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
const [message] = MailMessage.browse(message_id);
const notifications = [];
if (this._name === "discuss.channel") {
// members
const channels = DiscussChannel.browse(message.res_id);
for (const channel of channels) {
notifications.push([
channel,
"discuss.channel/new_message",
{
data: new mailDataHelpers.Store(
MailMessage.browse(message_id)
).get_result(),
id: channel.id,
temporary_id,
},
]);
const memberOfCurrentUser = this._find_or_create_member_for_self(ids[0]);
if (memberOfCurrentUser) {
this.env["discuss.channel.member"]._set_last_seen_message(
[memberOfCurrentUser.id],
message.id,
false
);
this.env["discuss.channel.member"]._set_new_message_separator(
[memberOfCurrentUser.id],
message.id + 1,
true
);
}
}
}
if (message.partner_ids) {
for (const partner_id of message.partner_ids) {
const [partner] = ResPartner.search_read([["id", "=", partner_id]]);
if (partner.user_ids.length > 0) {
const [user] = ResUsers.search_read([["id", "=", partner.user_ids[0]]]);
if (user.notification_type === "inbox") {
notifications.push([
partner,
"mail.message/inbox",
{
message_id: message.id,
store_data: new mailDataHelpers.Store(
MailMessage.browse(message.id),
makeKwArgs({ for_current_user: true, add_followers: true })
).get_result(),
},
]);
}
}
}
}
BusBus._sendmany(notifications);
}
/**
* @param {string[]} fields_iter
* @param {Object} initial_values_dict
*/
_message_track(fields_iter, initial_values_dict) {
const kwargs = getKwArgs(arguments, "fields_iter", "initial_values_dict");
fields_iter = kwargs.fields_iter;
initial_values_dict = kwargs.initial_values_dict;
/** @type {import("mock_models").Base} */
const Base = this.env["base"];
/** @type {import("mock_models").MailThread} */
const MailThread = this.env["mail.thread"];
const trackFieldNamesToField = this.env[this._name].fields_get(fields_iter);
const tracking = {};
const model = this.env[this._name];
for (const record of model) {
tracking[record.id] = Base._mail_track.call(
this,
trackFieldNamesToField,
initial_values_dict[record.id],
record
);
}
for (const record of model) {
const { trackingValueIds, changedFieldNames } = tracking[record.id] || {};
if (!changedFieldNames || !changedFieldNames.length) {
continue;
}
const changedFieldsInitialValues = {};
const initialFieldValues = initial_values_dict[record.id];
for (const fname in changedFieldNames) {
changedFieldsInitialValues[fname] = initialFieldValues[fname];
}
const subtype = MailThread._track_subtype.call(this, changedFieldsInitialValues);
MailThread.message_post.call(this, [record.id], subtype.id, trackingValueIds);
}
return tracking;
}
/** @param {Object} initial_values */
_track_finalize(initial_values) {
const kwargs = getKwArgs(arguments, "initial_values");
initial_values = kwargs.initial_values;
/** @type {import("mock_models").MailThread} */
const MailThread = this.env["mail.thread"];
MailThread._message_track.call(
this,
MailThread._track_get_fields.call(this),
initial_values
);
}
_track_get_fields() {
return Object.entries(this.env[this._name]._fields).reduce((prev, next) => {
if (next[1].tracking) {
prev.push(next[0]);
}
return prev;
}, []);
}
_track_prepare() {
/** @type {import("mock_models").MailThread} */
const MailThread = this.env["mail.thread"];
const trackedFieldNames = MailThread._track_get_fields.call(this);
if (!trackedFieldNames.length) {
return;
}
const initialTrackedFieldValuesByRecordId = {};
for (const record of this.env[this._name]) {
const values = {};
initialTrackedFieldValuesByRecordId[record.id] = values;
for (const fname of trackedFieldNames) {
values[fname] = record[fname];
}
}
return initialTrackedFieldValuesByRecordId;
}
/** @param {Object} initial_values */
_track_subtype(initial_values) {
return false;
}
_thread_to_store(store, fields, request_list) {
const kwargs = getKwArgs(arguments, "store", "fields", "request_list");
store = kwargs.store;
fields = kwargs.fields;
request_list = kwargs.request_list || [];
/** @type {import("mock_models").IrAttachment} */
const IrAttachment = this.env["ir.attachment"];
/** @type {import("mock_models").MailActivity} */
const MailActivity = this.env["mail.activity"];
/** @type {import("mock_models").MailFollowers} */
const MailFollowers = this.env["mail.followers"];
/** @type {import("mock_models").MailThread} */
const MailThread = this.env["mail.thread"];
/** @type {import("mock_models").MailScheduledMessage} */
const MailScheduledMessage = this.env["mail.scheduled.message"];
if (!fields) {
fields = [];
}
const thread = this[0];
store._add_record_fields(this.env[this._name].browse(thread.id), fields, true);
const res = {};
if (request_list) {
res.hasReadAccess = true;
res.hasWriteAccess = thread.hasWriteAccess ?? true; // mimic user with write access by default
res["canPostOnReadonly"] = this._mail_post_access === "read";
}
const model = this.env[this._name];
if (request_list.includes("activities") && model.has_activities) {
res["activities"] = mailDataHelpers.Store.many(
MailActivity.browse(thread.activity_ids)
);
}
if (request_list.includes("attachments")) {
res["attachments"] = mailDataHelpers.Store.many(
IrAttachment._filter([
["res_id", "=", thread.id],
["res_model", "=", this._name],
]).sort((a1, a2) => a1.id - a2.id)
);
res["areAttachmentsLoaded"] = true;
res["isLoadingAttachments"] = false;
// Specific implementation of mail.thread.main.attachment
if (this.env[this._name]._fields.message_main_attachment_id) {
res["message_main_attachment_id"] = mailDataHelpers.Store.one(
IrAttachment.browse(thread.message_main_attachment_id),
makeKwArgs({ only_id: true })
);
}
}
if (request_list.includes("contact_fields")) {
res.primary_email_field = this.env[this._name]._primary_email;
res.partner_fields = this.env[this._name]._mail_get_partner_fields?.();
}
if (request_list.includes("display_name")) {
res.display_name = thread.display_name;
}
if (fields.includes("display_name")) {
res.name = thread.display_name ?? thread.name;
}
if (request_list.includes("followers")) {
res["followersCount"] = this.env["mail.followers"].search_count([
["res_id", "=", thread.id],
["res_model", "=", this._name],
]);
res["selfFollower"] = mailDataHelpers.Store.one(
MailFollowers.browse(
MailFollowers.search([
["res_id", "=", thread.id],
["res_model", "=", this._name],
["partner_id", "=", this.env.user.partner_id],
])
)
);
MailThread._message_followers_to_store.call(
this,
[thread.id],
store,
makeKwArgs({ reset: true })
);
res["recipientsCount"] = this.env["mail.followers"].search_count([
["res_id", "=", thread.id],
["res_model", "=", this._name],
["partner_id", "!=", this.env.user.partner_id],
// subtype and partner active checks not done here for simplicity
]);
MailThread._message_followers_to_store.call(
this,
[thread.id],
store,
makeKwArgs({ filter_recipients: true, reset: true })
);
}
if (fields.includes("modelName")) {
res.modelName = this._description;
}
if (request_list.includes("suggestedRecipients")) {
res["suggestedRecipients"] = MailThread._message_get_suggested_recipients.call(this, [
thread.id,
]);
}
if (request_list.includes("scheduledMessages")) {
res["scheduledMessages"] = mailDataHelpers.Store.many(
MailScheduledMessage.filter(
(message) => message.model === this._name && message.res_id === thread.id
)
);
}
store._add_record_fields(this.env[this._name].browse(thread.id), res, true);
}
}

View file

@ -0,0 +1,175 @@
import { getKwArgs, models } from "@web/../tests/web_test_helpers";
import { patch } from "@web/core/utils/patch";
import { capitalize } from "@web/core/utils/strings";
patch(models.ServerModel.prototype, {
/**
* @override
* @type {typeof models.Model["prototype"]["write"]}
*/
write() {
/** @type {import("mock_models").MailThread} */
const MailThread = this.env["mail.thread"];
const initialTrackedFieldValuesByRecordId = MailThread._track_prepare.call(this);
const result = super.write(...arguments);
if (initialTrackedFieldValuesByRecordId) {
MailThread._track_finalize.call(this, initialTrackedFieldValuesByRecordId);
}
return result;
},
});
/**
* @typedef {import("@web/../tests/web_test_helpers").ModelRecord} ModelRecord
*/
export class MailTrackingValue extends models.ServerModel {
_name = "mail.tracking.value";
/**
* @param {ModelRecord} initial_value
* @param {ModelRecord} new_value
* @param {string} col_name
* @param {Object} col_info
* @param {models.ServerModel} record
*/
_create_tracking_values(initial_value, new_value, col_name, col_info, record) {
const kwargs = getKwArgs(
arguments,
"initial_value",
"new_value",
"col_name",
"col_info",
"record"
);
initial_value = kwargs.initial_value;
new_value = kwargs.new_value;
col_name = kwargs.col_name;
col_info = kwargs.col_info;
record = kwargs.record;
/** @type {import("mock_models").IrModelFields} */
const IrModelFields = this.env["ir.model.fields"];
let isTracked = true;
const irField = IrModelFields.find(
(field) => field.model === record._name && field.name === col_name
);
if (!irField) {
return;
}
const values = { field_id: irField.id };
switch (irField.ttype) {
case "char":
case "datetime":
case "float":
case "integer":
case "text":
values[`old_value_${irField.ttype}`] = initial_value;
values[`new_value_${irField.ttype}`] = new_value;
break;
case "date":
values["old_value_datetime"] = initial_value;
values["new_value_datetime"] = new_value;
break;
case "boolean":
values["old_value_integer"] = initial_value ? 1 : 0;
values["new_value_integer"] = new_value ? 1 : 0;
break;
case "monetary": {
values["old_value_float"] = initial_value;
values["new_value_float"] = new_value;
let currencyField = col_info.currency_field;
// see get_currency_field in python fields
if (!currencyField && "currency_id" in record._fields) {
currencyField = "currency_id";
}
values[`currency_id`] = record[0][currencyField];
break;
}
case "selection":
values["old_value_char"] = initial_value;
values["new_value_char"] = new_value;
break;
case "many2one":
initial_value = initial_value
? this.env[col_info.relation].search_read([["id", "=", initial_value]])[0]
: initial_value;
new_value = new_value
? this.env[col_info.relation].search_read([["id", "=", new_value]])[0]
: new_value;
values["old_value_integer"] = initial_value ? initial_value.id : 0;
values["new_value_integer"] = new_value ? new_value.id : 0;
values["old_value_char"] = initial_value ? initial_value.display_name : "";
values["new_value_char"] = new_value ? new_value.display_name : "";
break;
default:
isTracked = false;
}
if (isTracked) {
return this.create(values);
}
return false;
}
/** @param {ModelRecord[]} trackingValues */
_tracking_value_format(trackingValues) {
/** @type {import("mock_models").IrModelFields} */
const IrModelFields = this.env["ir.model.fields"];
return trackingValues.map((tracking) => {
const irField = IrModelFields.find((field) => field.id === tracking.field_id);
return {
id: tracking.id,
fieldInfo: {
changedField: capitalize(irField.ttype),
currencyId: tracking.currency_id,
fieldType: irField.ttype,
floatPrecision: this.env[irField.model]._fields[irField.name].digits,
},
newValue: this._format_display_value(tracking, "new"),
oldValue: this._format_display_value(tracking, "old"),
};
});
}
/**
* @param {ModelRecord} record
* @param {"new" | "old"} field_type
*/
_format_display_value(record, field_type) {
const kwargs = getKwArgs(arguments, "record", "field_type");
record = kwargs.record;
field_type = kwargs.field_type;
/** @type {import("mock_models").IrModelFields} */
const IrModelFields = this.env["ir.model.fields"];
const irField = IrModelFields.find((field) => field.id === record.field_id);
switch (irField.ttype) {
case "float":
case "integer":
case "text":
return record[`${field_type}_value_${irField.ttype}`];
case "datetime":
if (record[`${field_type}_value_datetime`]) {
const datetime = record[`${field_type}_value_datetime`];
return `${datetime}Z`;
} else {
return record[`${field_type}_value_datetime`];
}
case "date":
if (record[`${field_type}_value_datetime`]) {
return record[`${field_type}_value_datetime`];
} else {
return record[`${field_type}_value_datetime`];
}
case "boolean":
return !!record[`${field_type}_value_integer`];
case "monetary":
return record[`${field_type}_value_float`];
default:
return record[`${field_type}_value_char`];
}
}
}

View file

@ -0,0 +1,7 @@
import { webModels } from "@web/../tests/web_test_helpers";
export class ResCountry extends webModels.ResCountry {
get _to_store_defaults() {
return ["code"];
}
}

View file

@ -0,0 +1,125 @@
import { parseEmail } from "@mail/utils/common/format";
import { fields, makeKwArgs, models } from "@web/../tests/web_test_helpers";
export class ResFake extends models.Model {
_name = "res.fake";
_primary_email = "email_cc";
_views = {
form: /* xml */ `
<form>
<sheet>
<field name="name"/>
<field name="partner_id" />
<field name="email_cc" />
</sheet>
<chatter/>
</form>`,
};
name = fields.Char({ string: "Name" });
activity_ids = fields.One2many({ relation: "mail.activity", string: "Activities" });
email_from = fields.Char({ string: "Email" });
email_cc = fields.Char();
message_ids = fields.One2many({ relation: "mail.message" });
message_follower_ids = fields.Many2many({ relation: "mail.followers", string: "Followers" });
partner_ids = fields.One2many({ relation: "res.partner", string: "Related partners" });
phone = fields.Char({ string: "Phone number" });
partner_id = fields.Many2one({ relation: "res.partner", string: "contact partner" });
_mail_get_partner_fields() {
return ["partner_id"];
}
/**
* @param {integer[]} ids
* @returns {Object}
*/
_get_customer_information(ids) {
const record = this.browse(ids)[0];
if (!record.email_cc) {
return;
}
const [name, email] = parseEmail(record.email_cc);
return {
name,
email,
phone: record.phone,
};
}
/** @param {number[]} ids */
_message_get_suggested_recipients(ids, additional_partners = [], primary_email = false) {
/** @type {import("mock_models").MailThread} */
const MailThread = this.env["mail.thread"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
const result = [];
const records = this.browse(ids);
for (const id of ids) {
const record = records.find((record) => record.id === id);
if (record.email_cc) {
MailThread._message_add_suggested_recipient.call(
this,
id,
result,
makeKwArgs({
name: record.email_cc,
email: record.email_cc,
partner: undefined,
reason: "CC email",
})
);
}
if (primary_email) {
MailThread._message_add_suggested_recipient.call(
this,
id,
result,
makeKwArgs({
name: primary_email,
email: primary_email,
partner: undefined,
reason: "CC email",
})
);
}
const partners = ResPartner.browse(record.partner_ids);
if (partners.length) {
for (const partner of partners) {
MailThread._message_add_suggested_recipient.call(
this,
id,
result,
makeKwArgs({
email: partner.email,
partner,
reason: "Email partner",
})
);
}
}
const partner_id = additional_partners.length ? additional_partners : record.partner_id;
const [partner] = ResPartner.browse(partner_id);
if (partner) {
MailThread._message_add_suggested_recipient.call(
this,
id,
result,
makeKwArgs({
email: partner.email,
partner,
reason: "contact partner",
})
);
}
}
return result;
}
/** @param {number[]} ids */
_message_compute_subject(ids) {
return new Map(ids.map((id) => [id, "Custom Default Subject"]));
}
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class ResLang extends models.ServerModel {
_name = "res.lang";
get _to_store_defaults() {
return ["name"];
}
}

View file

@ -0,0 +1,453 @@
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import {
fields,
getKwArgs,
makeKwArgs,
serverState,
webModels,
} from "@web/../tests/web_test_helpers";
/** @typedef {import("@web/../tests/web_test_helpers").ModelRecord} ModelRecord */
export class ResPartner extends webModels.ResPartner {
_inherit = ["mail.thread"];
description = fields.Char({ string: "Description" });
hasWriteAccess = fields.Boolean({ default: true });
message_main_attachment_id = fields.Many2one({
relation: "ir.attachment",
string: "Main attachment",
});
is_in_call = fields.Boolean({ compute: "_compute_is_in_call" });
_views = {
form: /* xml */ `
<form>
<sheet>
<field name="name"/>
</sheet>
<chatter/>
</form>`,
};
_compute_is_in_call() {
for (const partner of this) {
partner.is_in_call =
this.env["discuss.channel.member"].search([
["rtc_session_ids", "!=", []],
["partner_id", "=", partner.id],
]).length > 0;
}
}
/**
* @param {string} [search]
* @param {number} [limit]
*/
get_mention_suggestions(search, limit = 8) {
const kwargs = getKwArgs(arguments, "search", "limit");
search = kwargs.search || "";
limit = kwargs.limit || 8;
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
search = search.toLowerCase();
/**
* Returns the given list of partners after filtering it according to
* the logic of the Python method `get_mention_suggestions` for the
* given search term. The result is truncated to the given limit and
* formatted as expected by the original method.
*
* @param {ModelRecord[]} partners
* @param {string} search
* @param {number} limit
*/
const mentionSuggestionsFilter = (partners, search, limit) => {
const matchingPartnerIds = partners
.filter((partner) => {
// no search term is considered as return all
if (!search) {
return true;
}
// otherwise name or email must match search term
if (partner.name && partner.name.toLowerCase().includes(search)) {
return true;
}
if (partner.email && partner.email.toLowerCase().includes(search)) {
return true;
}
return false;
})
.map((partner) => partner.id);
// reduce results to max limit
matchingPartnerIds.length = Math.min(matchingPartnerIds.length, limit);
return matchingPartnerIds;
};
// add main suggestions based on users
const partnersFromUsers = ResUsers._filter([])
.map((user) => this.browse(user.partner_id)[0])
.filter((partner) => partner);
const mainMatchingPartnerIds = mentionSuggestionsFilter(partnersFromUsers, search, limit);
let extraMatchingPartnerIds = [];
// if not enough results add extra suggestions based on partners
const remainingLimit = limit - mainMatchingPartnerIds.length;
if (mainMatchingPartnerIds.length < limit) {
const partners = this._filter([["id", "not in", mainMatchingPartnerIds]]);
extraMatchingPartnerIds = mentionSuggestionsFilter(partners, search, remainingLimit);
}
const store = new mailDataHelpers.Store(
this.browse(mainMatchingPartnerIds.concat(extraMatchingPartnerIds))
);
const roleIds = this.env["res.role"].search(
[["name", "ilike", search || ""]],
makeKwArgs({ limit: limit || 8 })
);
store.add("res.role", this.env["res.role"]._read_format(roleIds, ["name"], false));
return store.get_result();
}
/**
* @param {number} [channel_id]
* @param {string} [search]
* @param {number} [limit]
*/
get_mention_suggestions_from_channel(channel_id, search, limit = 8) {
const kwargs = getKwArgs(arguments, "channel_id", "search", "limit");
channel_id = kwargs.channel_id;
search = kwargs.search || "";
limit = kwargs.limit || 8;
/** @type {import("mock_models").DiscussChannelMember} */
const DiscussChannelMember = this.env["discuss.channel.member"];
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
/** @type {import("mock_models").DiscussChannel} */
const channel = this.env["discuss.channel"].browse(channel_id)[0];
const searchLower = search.toLowerCase();
const extraDomain = [
["user_ids", "!=", false],
["active", "=", true],
["partner_share", "=", false],
];
const parent_channel = this.browse(channel.parent_channel_id);
const allowed_group = parent_channel?.group_public_id ?? channel.group_public_id;
if (allowed_group) {
extraDomain.push(["group_ids", "in", allowed_group]);
}
const baseDomain = search
? ["|", ["name", "ilike", searchLower], ["email", "ilike", searchLower]]
: [];
const partners = this._search_mention_suggestions(
baseDomain,
limit,
channel_id,
extraDomain
);
const store = new mailDataHelpers.Store();
const memberIds = DiscussChannelMember.search([
["channel_id", "in", [channel.id, channel.parent_channel_id]],
["partner_id", "in", partners],
]);
const users = ResUsers.search([["partner_id", "in", partners]]).reduce((map, userId) => {
const [user] = ResUsers.browse(userId);
map[user.partner_id] = user;
return map;
}, {});
for (const memberId of memberIds) {
const [member] = DiscussChannelMember.browse(memberId);
store.add(this.browse(member.partner_id));
store.add(
DiscussChannelMember.browse(member.id),
makeKwArgs({ fields: ["channel", "persona"] })
);
}
for (const partnerId of partners) {
const data = {
name: users[partnerId]?.name,
group_ids: users[partnerId]?.group_ids.includes(allowed_group)
? allowed_group
: undefined,
};
store.add(this.browse(partnerId), data);
}
const roleIds = this.env["res.role"].search(
[["name", "ilike", searchLower || ""]],
makeKwArgs({ limit: limit || 8 })
);
store.add("res.role", this.env["res.role"]._read_format(roleIds, ["name"], false));
return store.get_result();
}
compute_im_status(partner) {
if (partner.im_status) {
return partner.im_status;
}
if (partner.id === serverState.odoobotId) {
return "bot";
}
if (!partner.user_ids.length) {
return "im_status";
}
return "offline";
}
/* override */
_compute_display_name() {
super._compute_display_name();
for (const record of this) {
if (record.parent_id && !record.name) {
const [parent] = this.env["res.partner"].browse(record.parent_id);
const type = this._fields.type.selection.find((item) => item[0] === record.type);
record.display_name = `${parent.name}, ${type[1]}`;
}
}
}
/**
* @param {Array} domain
* @param {number} limit
* @param {number} channel_id
* @param {Array} extraDomain
* @returns {Array}
*/
_search_mention_suggestions(domain, limit, channel_id, extraDomain) {
const DiscussChannelMember = this.env["discuss.channel.member"];
const ResUsers = this.env["res.users"];
const channel = this.env["discuss.channel"].browse(channel_id)[0];
let partnerIds = [];
if (!domain?.length && channel) {
partnerIds = DiscussChannelMember.search([
["channel_id", "in", [channel.id, channel.parent_channel_id]],
]).map((memberId) => DiscussChannelMember.browse(memberId)[0].partner_id);
} else {
partnerIds = ResUsers.search(domain).map(
(userId) => ResUsers.browse(userId)[0].partner_id
);
}
if (extraDomain?.length) {
const usersWithAccess = ResUsers.search(extraDomain).map(
(userId) => ResUsers.browse(userId)[0].partner_id
);
partnerIds.push(...usersWithAccess);
}
return Array.from(new Set(partnerIds)).slice(0, limit);
}
/**
* @param {number[]} ids
* @returns {Record<string, ModelRecord>}
*/
_to_store(store, fields, extra_fields) {
const kwargs = getKwArgs(arguments, "store", "fields", "extra_fields");
fields = kwargs.fields;
extra_fields = kwargs.extra_fields ?? [];
fields = fields.concat(extra_fields);
/** @type {import("mock_models").ResCountry} */
const ResCountry = this.env["res.country"];
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
this._compute_main_user_id(); // compute not automatically triggering when necessary
store._add_record_fields(
this,
fields.filter(
(field) =>
![
"avatar_128",
"country_id",
"display_name",
"is_admin",
"notification_type",
"signature",
"user",
].includes(field)
)
);
for (const partner of this) {
const data = {};
if (fields.includes("avatar_128")) {
data.avatar_128_access_token = partner.id;
data.write_date = partner.write_date;
}
if (fields.includes("country_id")) {
const [country_id] = ResCountry.browse(partner.country_id);
data.country_id = country_id || false;
}
if (fields.includes("display_name")) {
data.displayName = partner.display_name || partner.name;
}
if (fields.includes("im_status")) {
data.im_status = this.compute_im_status(partner);
data.im_status_access_token = partner.id;
}
if (fields.includes("user")) {
data.main_user_id = partner.main_user_id;
if (partner.main_user_id) {
store._add_record_fields(ResUsers.browse(partner.main_user_id), ["share"]);
}
if (partner.main_user_id && fields.includes("is_admin")) {
const users = ResUsers.search([["login", "=", "admin"]]);
store._add_record_fields(ResUsers.browse(partner.main_user_id), {
is_admin:
this.env.cookie.get("authenticated_user_sid") ===
(Number.isInteger(users?.[0]) ? users?.[0] : users?.[0]?.id) ??
false,
}); // mock server simplification
}
if (partner.main_user_id && fields.includes("notification_type")) {
store._add_record_fields(
ResUsers.browse(partner.main_user_id),
makeKwArgs({ fields: ["notification_type"] })
);
}
if (partner.main_user_id && fields.includes("signature")) {
store._add_record_fields(
ResUsers.browse(partner.main_user_id),
makeKwArgs({ fields: ["signature"] })
);
}
}
if (Object.keys(data).length) {
store._add_record_fields(this.browse(partner.id), data);
}
}
}
get _to_store_defaults() {
return [
"avatar_128",
"name",
"email",
"active",
"im_status",
"is_company",
mailDataHelpers.Store.one("main_user_id", ["share"]),
];
}
/**
* @param {string} [search_term]
* @param {number} [channel_id]
* @param {number} [limit]
*/
search_for_channel_invite(search_term, channel_id, limit = 30) {
const kwargs = getKwArgs(arguments, "search_term", "channel_id", "limit");
const store = new mailDataHelpers.Store();
const channel_invites = this._search_for_channel_invite(
store,
kwargs.search_term,
kwargs.channel_id,
kwargs.limit
);
return { store_data: store.get_result(), ...channel_invites };
}
/**
* @param {string} [search_term]
* @param {number} [channel_id]
* @param {number} [limit]
*/
_search_for_channel_invite(store, search_term, channel_id, limit = 30) {
const kwargs = getKwArgs(arguments, "store", "search_term", "channel_id", "limit");
search_term = kwargs.search_term || "";
channel_id = kwargs.channel_id;
limit = kwargs.limit || 30;
/** @type {import("mock_models").DiscussChannelMember} */
const DiscussChannelMember = this.env["discuss.channel.member"];
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
search_term = search_term.toLowerCase(); // simulates ILIKE
let memberPartnerIds;
if (channel_id) {
memberPartnerIds = new Set(
DiscussChannelMember._filter([["channel_id", "=", channel_id]]).map(
(member) => member.partner_id
)
);
}
// simulates domain with relational parts (not supported by mock server)
const matchingPartnersIds = ResUsers._filter([])
.filter((user) => {
const [partner] = this.browse(user.partner_id);
// user must have a partner
if (!partner) {
return false;
}
// not current partner
if (!channel_id && partner.id === this.env.user.partner_id) {
return false;
}
// user should not already be a member of the channel
if (channel_id && memberPartnerIds.has(partner.id)) {
return false;
}
// no name is considered as return all
if (!search_term) {
return true;
}
if (partner.name && partner.name.toLowerCase().includes(search_term)) {
return true;
}
return false;
})
.map((user) => user.partner_id)
.reduce((ids, partnerId) => {
if (!ids.includes(partnerId)) {
ids.push(partnerId);
}
return ids;
}, []);
const count = matchingPartnersIds.length;
matchingPartnersIds.length = Math.min(count, limit);
this._search_for_channel_invite_to_store(matchingPartnersIds, store, channel_id);
return {
count,
partner_ids: matchingPartnersIds,
};
}
_search_for_channel_invite_to_store(ids, store, channel_id) {
store.add(this.browse(ids));
}
/**
* @param {number} id
* @returns {number}
*/
_get_needaction_count(id) {
/** @type {import("mock_models").MailNotification} */
const MailNotification = this.env["mail.notification"];
const [partner] = this.browse(id);
return MailNotification._filter([
["res_partner_id", "=", partner.id],
["is_read", "=", false],
]).length;
}
_get_current_persona() {
/** @type {import("mock_models").MailGuest} */
const MailGuest = this.env["mail.guest"];
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
if (ResUsers._is_public(this.env.uid)) {
return [null, MailGuest._get_guest_from_context()];
}
return [this.browse(this.env.user.partner_id)[0], null];
}
_get_store_avatar_card_fields() {
return ["email", "partner_share", "name", "phone"];
}
}

View file

@ -0,0 +1,7 @@
import { fields, models } from "@web/../tests/web_test_helpers";
export class ResRole extends models.ServerModel {
_name = "res.role";
name = fields.Char();
}

View file

@ -0,0 +1,210 @@
import { DISCUSS_ACTION_ID, mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import { fields, makeKwArgs, serverState, webModels } from "@web/../tests/web_test_helpers";
import { serializeDate, today } from "@web/core/l10n/dates";
export class ResUsers extends webModels.ResUsers {
im_status = fields.Char({ default: "online" });
notification_type = fields.Selection({
selection: [
["email", "Handle by Emails"],
["inbox", "Handle in Odoo"],
],
default: "email",
});
role_ids = fields.Many2many({ relation: "res.role", string: "Roles" });
/** Simulates `_init_store_data` on `res.users`. */
_init_store_data(store) {
/** @type {import("mock_models").DiscussChannel} */
const DiscussChannel = this.env["discuss.channel"];
/** @type {import("mock_models").MailGuest} */
const MailGuest = this.env["mail.guest"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
/** @type {import("mock_models").ResUsersSettings} */
const ResUsersSettings = this.env["res.users.settings"];
/** @type {import("mock_models").MailMessageSubtype} */
const MailMessageSubtype = this.env["mail.message.subtype"];
store.add({
action_discuss_id: DISCUSS_ACTION_ID,
channel_types_with_seen_infos: DiscussChannel._types_allowing_seen_infos(),
hasGifPickerFeature: true,
hasLinkPreviewFeature: true,
hasMessageTranslationFeature: true,
mt_comment: MailMessageSubtype._filter([["subtype_xmlid", "=", "mail.mt_comment"]])[0]
.id,
mt_note: MailMessageSubtype._filter([["subtype_xmlid", "=", "mail.mt_note"]])[0].id,
odoobot: mailDataHelpers.Store.one(ResPartner.browse(serverState.odoobotId)),
});
if (!this._is_public(this.env.uid)) {
const userSettings = ResUsersSettings._find_or_create_for_user(this.env.uid);
store.add({
self_partner: mailDataHelpers.Store.one(
ResPartner.browse(this.env.user.partner_id),
makeKwArgs({
fields: [
"active",
"avatar_128",
"im_status",
"is_admin",
mailDataHelpers.Store.one("main_user_id", ["notification_type"]),
mailDataHelpers.Store.one("main_user_id", ["signature"]),
"name",
"notification_type",
"signature",
"user",
],
})
),
settings: ResUsersSettings.res_users_settings_format(userSettings.id),
});
} else if (this.env.cookie.get("dgid")) {
store.add({
self_guest: mailDataHelpers.Store.one(
MailGuest.browse(this.env.cookie.get("dgid")),
makeKwArgs({ fields: ["avatar_128", "name"] })
),
});
}
}
systray_get_activities() {
/** @type {import("mock_models").MailActivity} */
const MailActivity = this.env["mail.activity"];
const activities = MailActivity.search_read([]);
const userActivitiesByModelName = {};
for (const activity of activities) {
const day = serializeDate(today());
if (day === activity["date_deadline"]) {
activity["states"] = "today";
} else if (day > activity["date_deadline"]) {
activity["states"] = "overdue";
} else {
activity["states"] = "planned";
}
}
for (const activity of activities) {
const modelName = activity["res_model"];
if (!userActivitiesByModelName[modelName]) {
userActivitiesByModelName[modelName] = {
id: modelName, // for simplicity
model: modelName,
name: modelName,
overdue_count: 0,
planned_count: 0,
today_count: 0,
total_count: 0,
type: "activity",
};
}
userActivitiesByModelName[modelName][`${activity["states"]}_count`] += 1;
userActivitiesByModelName[modelName]["total_count"] += 1;
userActivitiesByModelName[modelName].actions = [
{
icon: "fa-clock-o",
name: "Summary",
},
];
}
return Object.values(userActivitiesByModelName);
}
/**
* @param {number[]} ids
* @param {import("@mail/../tests/mock_server/mail_mock_server").mailDataHelpers.Store} store
**/
_init_messaging(ids, store) {
/** @type {import("mock_models").DiscussChannel} */
const DiscussChannel = this.env["discuss.channel"];
/** @type {import("mock_models").DiscussChannelMember} */
const DiscussChannelMember = this.env["discuss.channel.member"];
/** @type {import("mock_models").MailMessage} */
const MailMessage = this.env["mail.message"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
const [user] = ResUsers.browse(ids);
const channels = DiscussChannel._get_channels_as_member();
const members = DiscussChannelMember._filter([
["channel_id", "in", channels.map((channel) => channel.id)],
["partner_id", "=", user.partner_id],
]);
const bus_last_id = this.env["bus.bus"].lastBusNotificationId;
store.add({
inbox: {
counter: ResPartner._get_needaction_count(user.partner_id),
counter_bus_id: bus_last_id,
id: "inbox",
model: "mail.box",
},
starred: {
counter: MailMessage._filter([["starred_partner_ids", "in", user.partner_id]])
.length,
counter_bus_id: bus_last_id,
id: "starred",
model: "mail.box",
},
initChannelsUnreadCounter: members.filter((member) => member.message_unread_counter)
.length,
});
}
_get_activity_groups() {
/** @type {import("mock_models").MailActivity} */
const MailActivity = this.env["mail.activity"];
const activities = MailActivity.search_read([]);
const userActivitiesByModelName = {};
for (const activity of activities) {
const day = serializeDate(today());
if (day === activity["date_deadline"]) {
activity["states"] = "today";
} else if (day > activity["date_deadline"]) {
activity["states"] = "overdue";
} else {
activity["states"] = "planned";
}
}
for (const activity of activities) {
const modelName = activity["res_model"];
if (!userActivitiesByModelName[modelName]) {
userActivitiesByModelName[modelName] = {
id: modelName, // for simplicity
model: modelName,
name: modelName,
domain:
modelName && "active" in this.env[modelName]._fields
? [["active", "in", [true, false]]]
: [],
overdue_count: 0,
planned_count: 0,
today_count: 0,
total_count: 0,
type: "activity",
};
}
userActivitiesByModelName[modelName][`${activity["states"]}_count`] += 1;
userActivitiesByModelName[modelName]["total_count"] += 1;
userActivitiesByModelName[modelName].actions = [
{
icon: "fa-clock-o",
name: "Summary",
},
];
}
return Object.values(userActivitiesByModelName);
}
_get_store_avatar_card_fields() {
return [
"share",
mailDataHelpers.Store.one(
"partner_id",
this.env["res.partner"]._get_store_avatar_card_fields()
),
];
}
}

View file

@ -0,0 +1,107 @@
import { fields, getKwArgs, webModels } from "@web/../tests/web_test_helpers";
import { ensureArray } from "@web/core/utils/arrays";
import { patch } from "@web/core/utils/patch";
/**
* @template T
* @typedef {import("@web/../tests/web_test_helpers").KwArgs<T>} KwArgs
*/
export class ResUsersSettings extends webModels.ResUsersSettings {
is_discuss_sidebar_category_channel_open = fields.Generic({ default: true });
is_discuss_sidebar_category_chat_open = fields.Generic({ default: true });
/**
* @param {number} guest_id
* @param {number} partner_id
* @param {number} volume
*/
set_volume_setting(ids, partner_id, volume, guest_id = false) {
const kwargs = getKwArgs(arguments, "ids", "partner_id", "volume", "guest_id");
ids = kwargs.ids;
partner_id = kwargs.partner_id;
volume = kwargs.volume;
guest_id = kwargs.guest_id;
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
/** @type {import("mock_models").ResUsersSettingsVolumes} */
const ResUsersSettingsVolumes = this.env["res.users.settings.volumes"];
const id = ids[0]; // ensure_one
let [volumeSettings] = ResUsersSettingsVolumes.search_read([
["user_setting_id", "=", id],
partner_id ? ["partner_id", "=", partner_id] : ["guest_id", "=", guest_id],
]);
if (!volumeSettings) {
volumeSettings = ResUsersSettingsVolumes.create({
partner_id,
guest_id,
volume,
});
} else {
ResUsersSettingsVolumes.write(volumeSettings.id, { volume });
}
const [partner] = ResPartner.read(this.env.user.partner_id);
BusBus._sendone(partner, "res.users.settings.volumes", {
...ResUsersSettingsVolumes.discuss_users_settings_volume_format(volumeSettings.id),
});
return volumeSettings;
}
set_custom_notifications(ids, custom_notifications) {
const kwargs = getKwArgs(arguments, "ids", "custom_notifications");
ids = kwargs.ids;
delete kwargs.ids;
custom_notifications = kwargs.custom_notifications;
this.set_res_users_settings(ids, { channel_notifications: custom_notifications });
}
}
patch(webModels.ResUsersSettings.prototype, {
res_users_settings_format(id, fields_to_format) {
const kwargs = getKwArgs(arguments, "id", "fields_to_format");
id = kwargs.id;
delete kwargs.id;
fields_to_format = kwargs.fields_to_format;
const res = super.res_users_settings_format(id, fields_to_format);
/** @type {import("mock_models").ResUsersSettingsVolumes} */
const ResUsersSettingsVolumes = this.env["res.users.settings.volumes"];
const [settings] = this.browse(id);
if (Reflect.ownKeys(res).includes("volume_settings_ids")) {
const volumeSettings = ResUsersSettingsVolumes.discuss_users_settings_volume_format(
settings.volume_settings_ids
);
res.volumes = [["ADD", volumeSettings]];
}
return res;
},
set_res_users_settings(idOrIds, new_settings) {
const kwargs = getKwArgs(arguments, "idOrIds", "new_settings");
idOrIds = kwargs.idOrIds;
delete kwargs.idOrIds;
new_settings = kwargs.new_settings || {};
const changedSettings = super.set_res_users_settings(idOrIds, new_settings);
/** @type {import("mock_models").BusBus} */
const BusBus = this.env["bus.bus"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
/** @type {import("mock_models").ResUsers} */
const ResUsers = this.env["res.users"];
const [id] = ensureArray(idOrIds);
const [oldSettings] = this.browse(id);
const [relatedUser] = ResUsers.search_read([["id", "=", oldSettings.user_id]]);
const [relatedPartner] = ResPartner.search_read([["id", "=", relatedUser.partner_id[0]]]);
BusBus._sendone(relatedPartner, "res.users.settings", {
...changedSettings,
id,
});
return changedSettings;
},
});

View file

@ -0,0 +1,37 @@
import { models } from "@web/../tests/web_test_helpers";
export class ResUsersSettingsVolumes extends models.ServerModel {
_name = "res.users.settings.volumes";
/** @param {number[]} ids */
discuss_users_settings_volume_format(ids) {
/** @type {import("mock_models").MailGuest} */
const MailGuest = this.env["mail.guest"];
/** @type {import("mock_models").ResPartner} */
const ResPartner = this.env["res.partner"];
return this.browse(ids).map((volumeSettingsRecord) => {
const [relatedGuest] = MailGuest.browse(volumeSettingsRecord.guest_id);
const [relatedPartner] = ResPartner.browse(volumeSettingsRecord.partner_id);
let partner_id, guest_id;
if (relatedPartner) {
partner_id = {
id: relatedPartner.id,
name: relatedPartner.name,
};
}
if (relatedGuest) {
guest_id = {
id: relatedGuest.id,
name: relatedGuest.name,
};
}
return {
partner_id,
guest_id,
id: volumeSettingsRecord.id,
volume: volumeSettingsRecord.volume,
};
});
}
}