[FW][MERGE][IMP] mail, web: prepare support of WhatsApp

RATIONALE

Enterprise is adding support of WhatsApp as a mean to communicate
with customers. This require some update in community, notably by
using a new widget and preparing discuss channels to support whatsapp
channels.

See sub commits for more details.

Task-2377154 (Add WhatsApp Support)

closes odoo/odoo#134377

Forward-port-of: odoo/odoo#133973
Forward-port-of: odoo/odoo#131688
Related: odoo/documentation#5730
Related: odoo/enterprise#46942
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
Co-authored-by: Akshat Trivedi <aktr@odoo.com>
Co-authored-by: Aurélien Warnon <awa@odoo.com>
Co-authored-by: Gitashri Mantha <gman@odoo.com>
Co-authored-by: Jigar Vaghela <jva@odoo.com>
Co-authored-by: Kashyap Patel <kasp@odoo.com>
Co-authored-by: Khushi Vakil <khva@odoo.com>
Co-authored-by: Nishant Jain <niai@odoo.com>
Co-authored-by: Prakash Prajapati <ppr@odoo.com>
Co-authored-by: Priyanshi patel <prpa@odoo.com>
Co-authored-by: Rahul Prajapati <rapr@odoo.com>
Co-authored-by: Renaud Thiry <reth@odoo.com>
Co-authored-by: Shreya Patel <shpa@odoo.com>
Co-authored-by: Stéphane Debauche <std@odoo.com>
Co-authored-by: Thibault Delavallee <tde@odoo.com>
Co-authored-by: Zeel Patel <zepa@odoo.com>
This commit is contained in:
Odoo's Mergebot
2023-09-07 11:46:11 +00:00
committed by Odoo Online
co-authored by Akshat Trivedi Aurélien Warnon Gitashri Mantha Jigar Vaghela Kashyap Patel Khushi Vakil Nishant Jain Prakash Prajapati Priyanshi patel Rahul Prajapati Renaud Thiry Shreya Patel Stéphane Debauche Thibault Delavallee Zeel Patel
13 changed files with 178 additions and 20 deletions
@@ -271,7 +271,7 @@ class Channel(models.Model):
if 'channel_type' in vals:
failing_channels = self.sudo().filtered(lambda channel: channel.channel_type != vals.get('channel_type'))
if failing_channels:
raise UserError(_('Cannot change the channel type of: %(channel_names)s'), channel_names=', '.join(failing_channels.mapped('name')))
raise UserError(_('Cannot change the channel type of: %(channel_names)s', channel_names=', '.join(failing_channels.mapped('name'))))
result = super().write(vals)
if vals.get('group_ids'):
self._subscribe_users_automatically()
@@ -1033,7 +1033,8 @@ class Channel(models.Model):
for channel in self:
if not channel.message_ids.ids:
return
if channel.channel_type != 'chat':
# a bit not-modular but helps understanding code
if channel.channel_type not in {'chat', 'whatsapp'}:
return
last_message_id = channel.message_ids.ids[0] # zero is the index of the last message
member = self.env['discuss.channel.member'].search([('channel_id', '=', channel.id), ('partner_id', '=', self.env.user.partner_id.id)], limit=1)
+2 -2
View File
@@ -27,10 +27,10 @@ class ResPartner(models.Model):
("channel_partner_ids", "in", [self.id]),
]
)
# get the pinned direct messages
# get the pinned direct messages (directly include whatsapp to avoid overrides)
channels |= self.env["discuss.channel"].search(
[
("channel_type", "=", "chat"),
("channel_type", "in", ["chat", "whatsapp"]),
(
"channel_member_ids",
"in",
+8 -2
View File
@@ -2570,6 +2570,7 @@ class MailThread(models.AbstractModel):
body='', subject=False,
author_id=None, email_from=None,
message_type='notification',
partner_ids=False,
attachment_ids=False, tracking_value_ids=False):
""" Shortcut allowing to post note on a document. See ``_message_log_batch``
for more details. """
@@ -2579,12 +2580,14 @@ class MailThread(models.AbstractModel):
{self.id: body}, subject=subject,
author_id=author_id, email_from=email_from,
message_type=message_type,
partner_ids=partner_ids,
attachment_ids=attachment_ids, tracking_value_ids=tracking_value_ids
)
def _message_log_batch(self, bodies, subject=False,
author_id=None, email_from=None,
message_type='notification',
partner_ids=False,
attachment_ids=False, tracking_value_ids=False):
""" Shortcut allowing to post notes on a batch of documents. It does not
perform any notification and pre-computes some values to have a short code
@@ -2594,7 +2597,9 @@ class MailThread(models.AbstractModel):
access rights are already granted to avoid privilege escalation.
:param bodies: dict {record_id: body}
:param list partner_ids: optional partners, not used in any notification
mechanism. This is mainly used to link a log to a specific customer
like SMS or WhatsApp log;
:return: created messages (as sudo)
"""
# protect against side-effect prone usage
@@ -2620,6 +2625,7 @@ class MailThread(models.AbstractModel):
# recipients
'email_add_signature': False, # False as no notification -> no need to compute signature
'message_id': tools.generate_tracking_message_id('message-notify'), # why? this is all but a notify
'partner_ids': partner_ids,
'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from)[False],
}
@@ -2713,7 +2719,7 @@ class MailThread(models.AbstractModel):
# Avoid warnings about non-existing fields
for x in ('from', 'to', 'cc', 'canned_response_ids'):
create_values.pop(x, None)
create_values['partner_ids'] = [Command.link(pid) for pid in create_values.get('partner_ids', [])]
create_values['partner_ids'] = [Command.link(pid) for pid in (create_values.get('partner_ids') or [])]
create_values_list.append(create_values)
# remove context, notably for default keys, as this thread method is not
+10 -11
View File
@@ -122,18 +122,9 @@ export class Composer extends Component {
});
this.suggestion = this.store.user ? useSuggestion() : undefined;
this.markEventHandled = markEventHandled;
this.onDropFile = this.onDropFile.bind(this);
if (this.props.dropzoneRef && this.allowUpload) {
useDropzone(
this.props.dropzoneRef,
(ev) => {
if (isDragSourceExternalFile(ev.dataTransfer)) {
for (const file of ev.dataTransfer.files) {
this.attachmentUploader.uploadFile(file);
}
}
},
"o-mail-Composer-dropzone"
);
useDropzone(this.props.dropzoneRef, this.onDropFile, "o-mail-Composer-dropzone");
}
if (this.props.messageEdition) {
this.props.messageEdition.composerOfThread = this;
@@ -354,6 +345,14 @@ export class Composer extends Component {
}
}
onDropFile(ev) {
if (isDragSourceExternalFile(ev.dataTransfer)) {
for (const file of ev.dataTransfer.files) {
this.attachmentUploader.uploadFile(file);
}
}
}
/**
* This doesn't work on firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1699743
*/
+1 -1
View File
@@ -21,7 +21,7 @@
Log note
</button>
<div class="flex-grow-1 d-flex">
<button t-if="props.hasActivities" class="btn btn-secondary text-nowrap" t-att-class="{ 'my-2': !props.compactHeight }" data-hotkey="shift+a" t-on-click="scheduleActivity">
<button t-if="props.hasActivities" class="o-mail-Chatter-activity btn btn-secondary text-nowrap" t-att-class="{ 'my-2': !props.compactHeight }" data-hotkey="shift+a" t-on-click="scheduleActivity">
<i class="fa fa-clock-o me-1"/>
<span>Activities</span>
</button>
@@ -57,7 +57,7 @@ export class VoiceRecorder extends Component {
this.notification.add(_t("Voice recording stopped"), { type: "warning" });
this.stopRecording();
} else {
this.cleanUp();
this.cleanUp({unmounting: true});
}
});
}
@@ -264,6 +264,9 @@ patch(MockServer.prototype, {
const [channel] = this.getRecords("discuss.channel", [["id", "in", ids]]);
const partners = this.getRecords("res.partner", [["id", "in", partner_ids]]);
for (const partner of partners) {
if (partner.id === this.pyEnv.currentPartnerId) {
continue; // adding 'yourself' to the conversation is handled below
}
const body = `<div class="o_mail_notification">invited ${partner.name} to the channel</div>`;
const message_type = "notification";
const subtype_xmlid = "mail.mt_comment";
@@ -283,6 +286,14 @@ patch(MockServer.prototype, {
invited_by_user_id: this.pyEnv.currentUserId,
});
}
const selfPartner = partners.find((partner) => partner.id === this.pyEnv.currentPartnerId);
if (selfPartner) {
// needs to be done after adding 'self' as a member
const body = `<div class="o_mail_notification">${selfPartner.name} joined the channel</div>`;
const message_type = "notification";
const subtype_xmlid = "mail.mt_comment";
this._mockDiscussChannelMessagePost(channel.id, { body, message_type, subtype_xmlid });
}
const isSelfMember =
this.pyEnv["discuss.channel.member"].searchCount([
["partner_id", "=", this.pyEnv.currentPartnerId],
@@ -546,6 +546,7 @@ class TestMessageLog(TestMessagePostCommon):
'message_type': 'notification',
'model': test_record._name,
'notified_partner_ids': self.env['res.partner'],
'partner_ids': self.env['res.partner'],
'reply_to': formataddr((self.company_admin.name, '%s@%s' % (self.alias_catchall, self.alias_domain))),
'res_id': test_record.id,
'subtype_id': self.env.ref('mail.mt_note'),
@@ -573,6 +574,38 @@ class TestMessageLog(TestMessagePostCommon):
'message_type': 'notification',
'model': test_record._name,
'notified_partner_ids': self.env['res.partner'],
'partner_ids': self.env['res.partner'],
'reply_to': formataddr((self.company_admin.name, '%s@%s' % (self.alias_catchall, self.alias_domain))),
'res_id': test_record.id,
'subtype_id': self.env.ref('mail.mt_note'),
}
)
@users('employee')
def test_message_log_batch_with_partners(self):
""" Partners can be given to log, but this should not generate any
notification. """
test_records = self.test_records.with_env(self.env)
test_records.message_subscribe(self.partner_employee_2.ids)
new_notes = test_records._message_log_batch(
bodies={
test_record.id: Markup('<p>Test _message_log_batch</p>')
for test_record in test_records
},
partner_ids=self.test_partners[:5].ids,
)
for test_record, new_note in zip(test_records, new_notes):
self.assertMessageFields(
new_note,
{'author_id': self.partner_employee,
'body': '<p>Test _message_log_batch</p>',
'email_from': formataddr((self.partner_employee.name, self.partner_employee.email_normalized)),
'is_internal': True,
'message_type': 'notification',
'model': test_record._name,
'notified_partner_ids': self.env['res.partner'],
'partner_ids': self.test_partners[:5],
'reply_to': formataddr((self.company_admin.name, '%s@%s' % (self.alias_catchall, self.alias_domain))),
'res_id': test_record.id,
'subtype_id': self.env.ref('mail.mt_note'),
@@ -0,0 +1,12 @@
/** @odoo-module */
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
export class DynamicModelFieldSelector extends ModelFieldSelector {
static props = {
...ModelFieldSelector.props,
record: { type: Object, optional: true },
recordProps: { type: Object, optional: true },
};
}
@@ -0,0 +1,74 @@
/** @odoo-module */
import { CharField, charField } from "@web/views/fields/char/char_field";
import { registry } from "@web/core/registry";
import { archParseBoolean } from "@web/views/utils";
import { DynamicModelFieldSelector } from "./dynamic_model_field_selector";
export class DynamicModelFieldSelectorChar extends CharField {
static components = {
...CharField.components,
DynamicModelFieldSelector
};
static props = {
...CharField.props,
resModel : { type: String, optional: true },
onlySearchable: { type: Boolean, optional: true },
followRelations: { type: Boolean, optional: true },
}
/**
* Update record
*
* @param {string} value
* @private
*/
async _onRecordUpdate(value) {
await this.props.record.update({[this.props.name]: value});
}
//---- Getters ----
get getSelectorProps() {
return {
path: this.props.record.data[this.props.name],
resModel: this.getResModel(),
readonly: this.props.readonly,
record: this.props.record,
recordProps: this.props,
update: this._onRecordUpdate.bind(this),
isDebugMode: !!this.env.debug,
filter: this.filter.bind(this),
followRelations: this.props.followRelations,
};
}
filter(fieldDef) {
return !this.props.onlySearchable || fieldDef.searchable;
}
getResModel(props = this.props) {
const resModel = props.record.data[props.resModel];
if (!resModel) {
return props.record.resModel;
}
return resModel;
}
}
DynamicModelFieldSelectorChar.template = "web.DynamicModelFieldSelectorChar";
export const dynamicModelFieldSelectorChar = {
...charField,
component: DynamicModelFieldSelectorChar,
extractProps({ options }, dynamicInfo) {
return {
followRelations: options.follow_relations ?? true,
onlySearchable: archParseBoolean(options.only_searchable),
resModel: options.model,
};
},
};
registry.category("fields").add("DynamicModelFieldSelectorChar", dynamicModelFieldSelectorChar);
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DynamicModelFieldSelectorChar" t-inherit="web.CharField" t-inherit-mode="primary" owl="1">
<xpath expr="//input" position="attributes">
<attribute name="class" add="d-none" separator=" "/>
</xpath>
<xpath expr="//span" position="attributes">
<attribute name="class" add="d-none" separator=" "/>
</xpath>
<xpath expr="//t[@t-if='props.readonly']" position="before">
<DynamicModelFieldSelector t-props="getSelectorProps" />
</xpath>
</t>
</templates>
+6
View File
@@ -184,6 +184,10 @@ addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js
!addons/website_livechat/
!addons/website_livechat/**/*
# Whitelist whatsapp
!whatsapp
!whatsapp/**/*
# Whitelist point_of_sale
!addons/point_of_sale/
!addons/point_of_sale/**/*
@@ -243,6 +247,8 @@ addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js
!pos_restaurant_iot/**/*
!pos_settle_due
!pos_settle_due/**/*
!whatsapp_pos
!whatsapp_pos/**/*
# Whitelist misc enterprise modules
!sign
+3 -1
View File
@@ -21,6 +21,7 @@
"@bus/*": ["addons/bus/static/src/*"],
"@mail/*": ["addons/mail/static/src/*"],
"@im_livechat/*": ["addons/im_livechat/static/src/*"],
"@whatsapp/*": ["addons/whatsapp/static/src/*"],
"@point_of_sale/*": ["addons/point_of_sale/static/src/*"],
"@l10n_co_pos/*": ["addons/l10n_co_pos/static/src/*"],
@@ -42,7 +43,8 @@
"@pos_sale_product_configurator/*": ["addons/pos_sale_product_configurator/static/src/*"],
"@pos_six/*": ["addons/pos_six/static/src/*"],
"@pos_stripe/*": ["addons/pos_stripe/static/src/*"],
"@test_mail/*": ["addons/test_mail/static/src/*"]
"@test_mail/*": ["addons/test_mail/static/src/*"],
"@whatsapp_pos/*": ["addons/whatsapp_pos/static/src/*"]
}
},
"include": ["**/*.js", "**/*.ts"],