diff --git a/addons/mail/models/discuss/discuss_channel.py b/addons/mail/models/discuss/discuss_channel.py index 7097ba61d04..645db447d50 100644 --- a/addons/mail/models/discuss/discuss_channel.py +++ b/addons/mail/models/discuss/discuss_channel.py @@ -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) diff --git a/addons/mail/models/discuss/res_partner.py b/addons/mail/models/discuss/res_partner.py index ea5c086be42..61b77b5caeb 100644 --- a/addons/mail/models/discuss/res_partner.py +++ b/addons/mail/models/discuss/res_partner.py @@ -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", diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 2e424380c37..145065894bd 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -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 diff --git a/addons/mail/static/src/core/common/composer.js b/addons/mail/static/src/core/common/composer.js index 8cf6ddd3281..b8a80bdb854 100644 --- a/addons/mail/static/src/core/common/composer.js +++ b/addons/mail/static/src/core/common/composer.js @@ -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 */ diff --git a/addons/mail/static/src/core/web/chatter.xml b/addons/mail/static/src/core/web/chatter.xml index ea644c09d7e..2a56b5141ed 100644 --- a/addons/mail/static/src/core/web/chatter.xml +++ b/addons/mail/static/src/core/web/chatter.xml @@ -21,7 +21,7 @@ Log note
- diff --git a/addons/mail/static/src/discuss/voice_message/common/voice_recorder.js b/addons/mail/static/src/discuss/voice_message/common/voice_recorder.js index 6a527d5b8e3..d1ea10f8adb 100644 --- a/addons/mail/static/src/discuss/voice_message/common/voice_recorder.js +++ b/addons/mail/static/src/discuss/voice_message/common/voice_recorder.js @@ -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}); } }); } diff --git a/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js b/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js index 60ba2b75c6b..99aa24e87fa 100644 --- a/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js +++ b/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js @@ -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 = `
invited ${partner.name} to the channel
`; 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 = `
${selfPartner.name} joined the channel
`; + 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], diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py index 2d0ead3f7f3..81661445e69 100644 --- a/addons/test_mail/tests/test_message_post.py +++ b/addons/test_mail/tests/test_message_post.py @@ -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('

Test _message_log_batch

') + 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': '

Test _message_log_batch

', + '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'), diff --git a/addons/web/static/src/views/fields/dynamic_widget/dynamic_model_field_selector.js b/addons/web/static/src/views/fields/dynamic_widget/dynamic_model_field_selector.js new file mode 100644 index 00000000000..082528e6783 --- /dev/null +++ b/addons/web/static/src/views/fields/dynamic_widget/dynamic_model_field_selector.js @@ -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 }, + }; +} diff --git a/addons/web/static/src/views/fields/dynamic_widget/dynamic_model_field_selector_char.js b/addons/web/static/src/views/fields/dynamic_widget/dynamic_model_field_selector_char.js new file mode 100644 index 00000000000..e65ac0023d3 --- /dev/null +++ b/addons/web/static/src/views/fields/dynamic_widget/dynamic_model_field_selector_char.js @@ -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); diff --git a/addons/web/static/src/views/fields/dynamic_widget/dynamic_model_field_selector_char.xml b/addons/web/static/src/views/fields/dynamic_widget/dynamic_model_field_selector_char.xml new file mode 100644 index 00000000000..91269be4533 --- /dev/null +++ b/addons/web/static/src/views/fields/dynamic_widget/dynamic_model_field_selector_char.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/web/tooling/_eslintignore b/addons/web/tooling/_eslintignore index 81b309be399..ae2b35a0dac 100644 --- a/addons/web/tooling/_eslintignore +++ b/addons/web/tooling/_eslintignore @@ -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 diff --git a/addons/web/tooling/_jsconfig.json b/addons/web/tooling/_jsconfig.json index 5749dbfab2a..fd14f58da6b 100644 --- a/addons/web/tooling/_jsconfig.json +++ b/addons/web/tooling/_jsconfig.json @@ -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"],