From e8922db3da4964555cba1031d4b70a7097f3c964 Mon Sep 17 00:00:00 2001 From: "Qiuyu (QHO)" Date: Fri, 27 Aug 2021 18:54:27 +0000 Subject: [PATCH] [IMP] mail: add default avatar for channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Channels * "hash tag" avatar with different backgroud colors * only private channels have lock thread icon - DM groups * "users" avatar with different background colors * no thread icon task-2584994 closes odoo/odoo#75653 Signed-off-by: Sébastien Theys (seb) --- addons/mail/models/mail_channel.py | 58 ++++++++++++++----- .../components/discuss/tests/discuss_tests.js | 14 ++--- .../discuss_sidebar_category_item_tests.js | 54 ++++++++++++++++- .../thread_needaction_preview.js | 2 +- .../thread_preview/thread_preview.js | 2 +- .../discuss_sidebar_category_item.js | 5 +- .../mail/static/src/models/thread/thread.js | 8 +++ .../mail/static/tests/helpers/mock_models.js | 2 + .../mail/static/tests/helpers/mock_server.js | 29 ++++++++++ addons/mail/tests/test_mail_channel.py | 38 +++++++++++- addons/mail/views/mail_channel_views.xml | 5 +- .../tests/test_performance.py | 16 ++++- odoo/addons/base/models/avatar_mixin.py | 18 ++++-- 13 files changed, 213 insertions(+), 38 deletions(-) diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 0e2f9f04608..c18bb4418cd 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -3,15 +3,26 @@ import base64 import logging +from hashlib import sha512 from uuid import uuid4 -from odoo import _, api, fields, models, modules, tools, Command +from odoo import _, api, fields, models, tools, Command +from odoo.addons.base.models.avatar_mixin import get_hsl_from_seed from odoo.exceptions import UserError, ValidationError from odoo.osv import expression from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT _logger = logging.getLogger(__name__) +channel_avatar = ''' + + +''' +group_avatar = ''' + + +''' + class Channel(models.Model): """ A mail.channel is a discussion group that may behave like a listener @@ -31,10 +42,6 @@ class Channel(models.Model): res['alias_contact'] = 'everyone' if res.get('public', 'private') == 'public' else 'followers' return res - def _get_default_image(self): - image_path = modules.get_module_resource('mail', 'static/src/img', 'groupdefault.png') - return base64.b64encode(open(image_path, 'rb').read()) - # description name = fields.Char('Name', required=True, translate=True) active = fields.Boolean(default=True, help="Set active to false to hide the channel without removing it.") @@ -45,7 +52,8 @@ class Channel(models.Model): string='Channel Type', default='channel', help="Chat is private and unique between 2 persons. Group is private among invited persons. Channel can be freely joined (depending on its configuration).") is_chat = fields.Boolean(string='Is a chat', compute='_compute_is_chat') description = fields.Text('Description') - image_128 = fields.Image("Image", max_width=128, max_height=128, default=_get_default_image) + image_128 = fields.Image("Image", max_width=128, max_height=128) + avatar_128 = fields.Image("Avatar", max_width=128, max_height=128, compute='_compute_avatar_128') channel_partner_ids = fields.Many2many( 'res.partner', string='Members', compute='_compute_channel_partner_ids', inverse='_inverse_channel_partner_ids', @@ -77,6 +85,19 @@ class Channel(models.Model): for record in self: record.is_chat = record.channel_type == 'chat' + @api.depends('channel_type', 'image_128', 'uuid') + def _compute_avatar_128(self): + for record in self: + record.avatar_128 = record.image_128 or record._generate_avatar() + + def _generate_avatar(self): + if self.channel_type not in ('channel', 'group'): + return False + avatar = group_avatar if self.channel_type == 'group' else channel_avatar + bgcolor = get_hsl_from_seed(self.uuid) + avatar = avatar.replace('fill="#875a7b"', f'fill="{bgcolor}"') + return base64.b64encode(avatar.encode()) + @api.depends('channel_last_seen_partner_ids.partner_id') def _compute_channel_partner_ids(self): for channel in self: @@ -128,7 +149,7 @@ class Channel(models.Model): @api.model_create_multi def create(self, vals_list): - defaults = self.default_get(['image_128', 'public']) + defaults = self.default_get(['public']) access_types = [] for vals in vals_list: @@ -153,10 +174,6 @@ class Channel(models.Model): for pid in partner_ids_to_add if pid not in membership_pids ] - # ensure image at quick create - if not vals.get('image_128'): - vals['image_128'] = defaults['image_128'] - # save visibility, apply public visibility for create then set back after creation # to avoid ACLS issue access_type = vals.pop('public', defaults['public']) @@ -191,10 +208,19 @@ class Channel(models.Model): def write(self, vals): result = super(Channel, self).write(vals) - if vals.get('group_ids'): self._subscribe_users_automatically() - + if 'image_128' in vals: + notifications = [] + for channel in self: + notifications.append([(self._cr.dbname, 'mail.channel', channel.id), { + 'type': 'mail.channel_update', + 'payload': { + 'id': channel.id, + 'avatarCacheKey': channel._get_avatar_cache_key(), + }, + }]) + self.env['bus.bus'].sendmany(notifications) return result def init(self): @@ -557,6 +583,7 @@ class Channel(models.Model): channel_last_message_ids = dict((r['id'], r['message_id']) for r in self._channel_last_message_ids()) for channel in self: info = { + 'avatarCacheKey': channel._get_avatar_cache_key(), 'id': channel.id, 'name': channel.name, 'description': channel.description, @@ -969,6 +996,11 @@ class Channel(models.Model): ) return [('insert', partners)] + def _get_avatar_cache_key(self): + if not self.avatar_128: + return 'no-avatar' + return sha512(self.avatar_128).hexdigest() + # ------------------------------------------------------------ # COMMANDS # ------------------------------------------------------------ diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js index 6664b3fc4eb..7ed03c62fe2 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -434,7 +434,7 @@ QUnit.test('sidebar: add channel', async function (assert) { }); QUnit.test('sidebar: basic channel rendering', async function (assert) { - assert.expect(13); + assert.expect(12); // channel expected to be found in the sidebar, // with a random unique id and name that will be referenced in the test @@ -460,11 +460,6 @@ QUnit.test('sidebar: basic channel rendering', async function (assert) { channel.classList.contains('o-active'), "should not be active by default" ); - assert.strictEqual( - channel.querySelectorAll(`:scope .o_ThreadIcon`).length, - 1, - "should have an icon" - ); assert.strictEqual( channel.querySelectorAll(`:scope .o_DiscussSidebarCategoryItem_name`).length, 1, @@ -623,10 +618,9 @@ QUnit.test('sidebar: public/private channel rendering', async function (assert) }).localId }"] `); - assert.strictEqual( - channel1.querySelectorAll(`:scope .o_ThreadIcon_channelPublic`).length, - 1, - "channel1 (public) has hashtag icon" + assert.notOk( + channel1.querySelectorAll(`:scope .o_ThreadIcon`).length, + "channel1 (public) should not have any icon" ); assert.strictEqual( channel2.querySelectorAll(`:scope .o_ThreadIcon_channelPrivate`).length, diff --git a/addons/mail/static/src/components/discuss_sidebar_category_item/tests/discuss_sidebar_category_item_tests.js b/addons/mail/static/src/components/discuss_sidebar_category_item/tests/discuss_sidebar_category_item_tests.js index ffd59982861..e3536f26a7f 100644 --- a/addons/mail/static/src/components/discuss_sidebar_category_item/tests/discuss_sidebar_category_item_tests.js +++ b/addons/mail/static/src/components/discuss_sidebar_category_item/tests/discuss_sidebar_category_item_tests.js @@ -34,7 +34,11 @@ QUnit.module('discuss_sidebar_category_item_tests.js', { QUnit.test('channel - avatar: should have correct avatar', async function (assert) { assert.expect(2); - this.data['mail.channel'].records.push({ id: 20 }); + this.data['mail.channel'].records.push({ + avatarCacheKey: '100111', + id: 20, + }); + await this.start(); const channelItem = document.querySelector(` @@ -53,9 +57,55 @@ QUnit.test('channel - avatar: should have correct avatar', async function (asser assert.strictEqual( channelItem.querySelector(`:scope .o_DiscussSidebarCategoryItem_image`).dataset.src, - '/web/image/mail.channel/20/image_128', + '/web/image/mail.channel/20/avatar_128?unique=100111', 'should link to the correct picture source' ); + +}); + +QUnit.test('channel - avatar: should update avatar url from bus', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ + avatarCacheKey: '101010', + id: 20, + }); + + await this.start(); + + const channelItemAvatar = document.querySelector(` + .o_DiscussSidebarCategoryItem[data-thread-local-id="${ + this.messaging.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] .o_DiscussSidebarCategoryItem_image + `); + + assert.strictEqual( + channelItemAvatar.dataset.src, + '/web/image/mail.channel/20/avatar_128?unique=101010', + ); + + await afterNextRender(() => { + this.env.services.rpc({ + model: 'mail.channel', + method: 'write', + args: [[20], { image_128: 'This field does not matter' }], + }); + }); + const result = await this.env.services.rpc({ + model: 'mail.channel', + method: 'read', + args: [[20], ['avatarCacheKey']], + }); + const newCacheKey = result[0]['avatarCacheKey']; + + // FIXME: current test framework does not replace `src` with `data-src` during the re-rendering. + assert.strictEqual( + channelItemAvatar.getAttribute('src'), + `/web/image/mail.channel/20/avatar_128?unique=${newCacheKey}`, + ); }); QUnit.test('chat - avatar: should have correct avatar', async function (assert) { diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js index 638f60bf4e2..a5c657c8ee4 100644 --- a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js @@ -38,7 +38,7 @@ export class ThreadNeedactionPreview extends Component { return this.thread.correspondent.avatarUrl; } if (this.thread.model === 'mail.channel') { - return `/web/image/mail.channel/${this.thread.id}/image_128`; + return `/web/image/mail.channel/${this.thread.id}/avatar_128?unique=${this.thread.avatarCacheKey}`; } return '/mail/static/src/img/smiley/avatar.jpg'; } diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.js b/addons/mail/static/src/components/thread_preview/thread_preview.js index e82f878ef7d..5e76f0e91e9 100644 --- a/addons/mail/static/src/components/thread_preview/thread_preview.js +++ b/addons/mail/static/src/components/thread_preview/thread_preview.js @@ -34,7 +34,7 @@ export class ThreadPreview extends Component { if (this.thread.correspondent) { return this.thread.correspondent.avatarUrl; } - return `/web/image/mail.channel/${this.thread.id}/image_128`; + return `/web/image/mail.channel/${this.thread.id}/avatar_128?unique=${this.thread.avatarCacheKey}`; } /** diff --git a/addons/mail/static/src/models/discuss_sidebar_category_item/discuss_sidebar_category_item.js b/addons/mail/static/src/models/discuss_sidebar_category_item/discuss_sidebar_category_item.js index 6def43878e6..8cd75a42df9 100644 --- a/addons/mail/static/src/models/discuss_sidebar_category_item/discuss_sidebar_category_item.js +++ b/addons/mail/static/src/models/discuss_sidebar_category_item/discuss_sidebar_category_item.js @@ -39,7 +39,7 @@ function factory(dependencies) { switch (this.channelType) { case 'channel': case 'group': - return `/web/image/mail.channel/${this.channelId}/image_128`; + return `/web/image/mail.channel/${this.channelId}/avatar_128?unique=${this.channel.avatarCacheKey}`; case 'chat': return this.channel.correspondent.avatarUrl; } @@ -124,9 +124,12 @@ function factory(dependencies) { _computeHasThreadIcon() { switch (this.channelType) { case 'channel': + return this.channel.public === 'private'; case 'chat': case 'group': return true; + case 'group': + return false; } } diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index df5390b80b7..60f3d47af71 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -168,6 +168,9 @@ function factory(dependencies) { */ static convertData(data) { const data2 = {}; + if ('avatarCacheKey' in data) { + data2.avatarCacheKey = data.avatarCacheKey; + } if ('description' in data) { data2.description = data.description; } @@ -1809,6 +1812,11 @@ function factory(dependencies) { attachments: many2many('mail.attachment', { inverse: 'threads', }), + /** + * Cache key to force a reload of the avatar when avatar is changed. + * It only makes sense for channels. + */ + avatarCacheKey: attr(), cache: one2one('mail.thread_cache', { default: create(), inverse: 'thread', diff --git a/addons/mail/static/tests/helpers/mock_models.js b/addons/mail/static/tests/helpers/mock_models.js index 1f22de56bde..5a60ebe85c7 100644 --- a/addons/mail/static/tests/helpers/mock_models.js +++ b/addons/mail/static/tests/helpers/mock_models.js @@ -68,6 +68,8 @@ export class MockModels { }, 'mail.channel': { fields: { + // In python this is not a real field but a compute. Here for simplicity. + avatarCacheKey: { string: "Avatar Cache Key", type: "datetime", default() { moment.utc().format("YYYYMMDDHHmmss"); } }, channel_type: { string: "Channel Type", type: "selection", default: 'channel' }, // In python this belongs to mail.channel.partner. Here for simplicity. custom_channel_name: { string: "Custom channel name", type: 'char' }, diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js index 0c40fe2362f..13cdf04b323 100644 --- a/addons/mail/static/tests/helpers/mock_server.js +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -223,6 +223,10 @@ MockServer.include({ const context = args.kwargs.context; return this._mockMailChannelNotifyTyping(ids, is_typing, context); } + if (args.model === 'mail.channel' && args.method === 'write' && 'image_128' in args.args[1]) { + const ids = args.args[0]; + return this._mockMailChannelWriteImage128(ids[0]); + } // mail.message methods if (args.model === 'mail.message' && args.method === 'mark_all_as_read') { const domain = args.args[0] || args.kwargs.domain; @@ -994,6 +998,31 @@ MockServer.include({ return mentionSuggestions; }, + /** + * Simulates `write` on `mail.channel` when `image_128` changes. + * + * @param {integer} id + */ + _mockMailChannelWriteImage128(id) { + this._mockWrite('mail.channel', [ + [id], + { + avatarCacheKey: moment.utc().format("YYYYMMDDHHmmss"), + }, + ]); + const avatarCacheKey = this._getRecords('mail.channel', [['id', '=', id]])[0].avatarCacheKey; + const notification = [ + ['dbName', 'mail.channel', id], + { + type: 'mail.channel_update', + payload: { + id: 20, + avatarCacheKey: avatarCacheKey, + }, + }, + ]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + }, /** * Simulates `message_post` on `mail.channel`. * diff --git a/addons/mail/tests/test_mail_channel.py b/addons/mail/tests/test_mail_channel.py index 4dde4e7081b..f2f670531a6 100644 --- a/addons/mail/tests/test_mail_channel.py +++ b/addons/mail/tests/test_mail_channel.py @@ -1,16 +1,18 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 from datetime import datetime from unittest.mock import patch from odoo import Command, fields +from odoo.addons.mail.models.mail_channel import channel_avatar, group_avatar from odoo.addons.mail.tests.common import mail_new_test_user from odoo.addons.mail.tests.common import MailCommon from odoo.exceptions import AccessError from odoo.tests import tagged, Form from odoo.tests.common import users -from odoo.tools import mute_logger +from odoo.tools import html_escape, mute_logger from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT @@ -437,6 +439,40 @@ class TestChannelInternals(MailCommon): self.assertEqual(len(messages_2), 1) self.assertEqual(messages_1, messages_2) + def test_channel_should_generate_correct_default_avatar(self): + channel = self.env['mail.channel'].create({'name': '', 'uuid': 'test-uuid'}) + bgcolor = html_escape('hsl(288, 51%, 45%)') # depends on uuid + expceted_avatar_channel = (channel_avatar.replace('fill="#875a7b"', f'fill="{bgcolor}"')).encode() + expected_avatar_group = (group_avatar.replace('fill="#875a7b"', f'fill="{bgcolor}"')).encode() + + channel.channel_type = 'group' + self.assertEqual(base64.b64decode(channel.avatar_128), expected_avatar_group) + + channel.channel_type = 'channel' + self.assertEqual(base64.b64decode(channel.avatar_128), expceted_avatar_channel) + + channel.image_128 = base64.b64encode(("").encode()) + self.assertEqual(channel.avatar_128, channel.image_128) + + def test_channel_write_should_send_notification_if_image_128_changed(self): + channel = self.env['mail.channel'].create({'name': '', 'uuid': 'test-uuid'}) + # do the operation once before the assert to grab the value to expect + channel.image_128 = base64.b64encode(("").encode()) + avatar_cache_key = channel._get_avatar_cache_key() + channel.image_128 = False + self.env['bus.bus'].search([]).unlink() + with self.assertBus( + [(self.cr.dbname, 'mail.channel', channel.id)], + [{ + "type": "mail.channel_update", + "payload": { + "id": channel.id, + "avatarCacheKey": avatar_cache_key, + }, + }] + ): + channel.image_128 = base64.b64encode(("").encode()) + def test_multi_company_chat(self): self._activate_multi_company() self.assertEqual(self.env.user.company_id, self.company_admin) diff --git a/addons/mail/views/mail_channel_views.xml b/addons/mail/views/mail_channel_views.xml index 44580b87918..7e42bc0c7ea 100644 --- a/addons/mail/views/mail_channel_views.xml +++ b/addons/mail/views/mail_channel_views.xml @@ -21,7 +21,7 @@
- Channel + Channel

#

@@ -45,7 +45,8 @@

- + +