diff --git a/addons/bus/models/bus.py b/addons/bus/models/bus.py index 7a7f8b497db..8187e2d43e6 100644 --- a/addons/bus/models/bus.py +++ b/addons/bus/models/bus.py @@ -69,7 +69,7 @@ class ImBus(models.Model): self.sendmany([[channel, message]]) @api.model - def poll(self, channels, last=0, options=None, force_status=False): + def poll(self, channels, last=0, options=None): if options is None: options = {} # first poll return the notification in the 'buffer' @@ -89,15 +89,6 @@ class ImBus(models.Model): 'channel': json.loads(notif['channel']), 'message': json.loads(notif['message']), }) - - if result or force_status: - partner_ids = options.get('bus_presence_partner_ids') - if partner_ids: - partners = self.env['res.partner'].browse(partner_ids) - result += [{ - 'id': -1, - 'channel': (self._cr.dbname, 'bus.presence'), - 'message': {'id': r.id, 'im_status': r.im_status}} for r in partners] return result @@ -145,7 +136,7 @@ class ImDispatch(object): event.wait(timeout=timeout) with registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) - notifications = env['bus.bus'].poll(channels, last, options, force_status=True) + notifications = env['bus.bus'].poll(channels, last, options) except Exception: # timeout pass diff --git a/addons/bus/models/res_partner.py b/addons/bus/models/res_partner.py index a476e0c08dc..3f67d2d1c9f 100644 --- a/addons/bus/models/res_partner.py +++ b/addons/bus/models/res_partner.py @@ -15,50 +15,16 @@ class ResPartner(models.Model): self.env.cr.execute(""" SELECT U.partner_id as id, - CASE WHEN age(now() AT TIME ZONE 'UTC', max(B.last_poll)) > interval %s THEN 'offline' - WHEN age(now() AT TIME ZONE 'UTC', max(B.last_presence)) > interval %s THEN 'away' - ELSE 'online' + CASE WHEN max(B.last_poll) IS NULL THEN 'offline' + WHEN age(now() AT TIME ZONE 'UTC', max(B.last_poll)) > interval %s THEN 'offline' + WHEN age(now() AT TIME ZONE 'UTC', max(B.last_presence)) > interval %s THEN 'away' + ELSE 'online' END as status FROM bus_presence B - JOIN res_users U ON B.user_id = U.id + RIGHT JOIN res_users U ON B.user_id = U.id WHERE U.partner_id IN %s AND U.active = 't' GROUP BY U.partner_id """, ("%s seconds" % DISCONNECTION_TIMER, "%s seconds" % AWAY_TIMER, tuple(self.ids))) res = dict(((status['id'], status['status']) for status in self.env.cr.dictfetchall())) for partner in self: - partner.im_status = res.get(partner.id, 'offline') - - @api.model - def im_search(self, name, limit=20): - """ Search partner with a name and return its id, name and im_status. - Note : the user must be logged - :param name : the partner name to search - :param limit : the limit of result to return - """ - # This method is supposed to be used only in the context of channel creation or - # extension via an invite. As both of these actions require the 'create' access - # right, we check this specific ACL. - if self.env['mail.channel'].check_access_rights('create', raise_exception=False): - name = '%' + name + '%' - excluded_partner_ids = [self.env.user.partner_id.id] - self.env.cr.execute(""" - SELECT - U.id as user_id, - P.id as id, - P.name as name, - CASE WHEN B.last_poll IS NULL THEN 'offline' - WHEN age(now() AT TIME ZONE 'UTC', B.last_poll) > interval %s THEN 'offline' - WHEN age(now() AT TIME ZONE 'UTC', B.last_presence) > interval %s THEN 'away' - ELSE 'online' - END as im_status - FROM res_users U - JOIN res_partner P ON P.id = U.partner_id - LEFT JOIN bus_presence B ON B.user_id = U.id - WHERE P.name ILIKE %s - AND P.id NOT IN %s - AND U.active = 't' - LIMIT %s - """, ("%s seconds" % DISCONNECTION_TIMER, "%s seconds" % AWAY_TIMER, name, tuple(excluded_partner_ids), limit)) - return self.env.cr.dictfetchall() - else: - return {} + partner.im_status = res.get(partner.id, 'im_partner') # if not found, it is a partner, useful to avoid to refresh status in js diff --git a/addons/bus/static/src/js/longpolling_bus.js b/addons/bus/static/src/js/longpolling_bus.js index 4764e2e52b0..a62f4897391 100644 --- a/addons/bus/static/src/js/longpolling_bus.js +++ b/addons/bus/static/src/js/longpolling_bus.js @@ -37,7 +37,6 @@ var LongpollingBus = Bus.extend(ServicesMixin, { // bus presence this._lastPresenceTime = new Date().getTime(); - this._lastPartnersPresenceCheck = this._lastPresenceTime; $(window).on("focus." + this._id, this._onFocusChange.bind(this, {focus: true})); $(window).on("blur." + this._id, this._onFocusChange.bind(this, {focus: false})); $(window).on("unload." + this._id, this._onFocusChange.bind(this, {focus: false})); @@ -177,11 +176,6 @@ var LongpollingBus = Bus.extend(ServicesMixin, { var options = _.extend({}, this._options, { bus_inactivity: now - this._getLastPresence(), }); - if (this._lastPartnersPresenceCheck + this.PARTNERS_PRESENCE_CHECK_PERIOD > now) { - options = _.omit(options, 'bus_presence_partner_ids'); - } else { - this._lastPartnersPresenceCheck = now; - } var data = {channels: this._channels, last: this._lastNotificationID, options: options}; // The backend has a maximum cycle time of 50 seconds so give +10 seconds this._pollRpc = this._rpc({route: this.POLL_ROUTE, params: data}, {shadow : true, timeout: 60000}); diff --git a/addons/hr_holidays/models/__init__.py b/addons/hr_holidays/models/__init__.py index d391276412b..2da28fe24fa 100644 --- a/addons/hr_holidays/models/__init__.py +++ b/addons/hr_holidays/models/__init__.py @@ -6,4 +6,7 @@ from . import hr from . import hr_leave from . import hr_leave_allocation from . import hr_leave_type -from . import mail_message_subtype \ No newline at end of file +from . import mail_channel +from . import mail_message_subtype +from . import res_partner +from . import res_users diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index c475dec01c7..5536a80f1b8 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -9,7 +9,7 @@ import math from datetime import datetime from pytz import timezone, UTC -from odoo import api, fields, models +from odoo import api, fields, models, tools from odoo.addons.resource.models.resource import float_to_time, HOURS_PER_DAY from odoo.exceptions import AccessError, UserError, ValidationError from odoo.tools import float_compare @@ -128,6 +128,7 @@ class HolidaysRequest(models.Model): 'hr.department', string='Department', readonly=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}) notes = fields.Text('Reasons', readonly=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}) + out_of_office_message = fields.Char(string='Out of Office Message') # duration date_from = fields.Datetime( 'Start Date', readonly=True, index=True, copy=False, required=True, @@ -250,6 +251,13 @@ class HolidaysRequest(models.Model): ('duration_check', "CHECK ( number_of_days >= 0 )", "If you want to change the number of days you should use the 'period' mode"), ] + @api.model_cr_context + def _auto_init(self): + res = super(HolidaysRequest, self)._auto_init() + tools.create_index(self._cr, 'hr_leave_date_to_date_from_index', + self._table, ['date_to', 'date_from']) + return res + @api.onchange('holiday_status_id') def _onchange_holiday_status_id(self): self.request_unit_half = False diff --git a/addons/hr_holidays/models/mail_channel.py b/addons/hr_holidays/models/mail_channel.py new file mode 100644 index 00000000000..3d5992dd856 --- /dev/null +++ b/addons/hr_holidays/models/mail_channel.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class Channel(models.Model): + _inherit = 'mail.channel' + + @api.multi + def partner_info(self, all_partners, direct_partners): + partner_infos = super(Channel, self).partner_info(all_partners, direct_partners) + # only search for leave out_of_office_message if im_status is on leave + partners_on_leave = [partner_id for partner_id in direct_partners.ids if 'leave' in partner_infos[partner_id]['im_status']] + if partners_on_leave: + now = fields.Datetime.now() + self.env.cr.execute('''SELECT res_users.partner_id as partner_id, hr_leave.out_of_office_message as out_of_office_message, hr_leave.date_to as date_to + FROM res_users + JOIN hr_leave ON hr_leave.user_id = res_users.id + AND hr_leave.state not in ('cancel', 'refuse') + AND res_users.active = 't' + AND hr_leave.date_from <= %s + AND hr_leave.date_to >= %s + AND res_users.partner_id in %s''', (now, now, tuple(partners_on_leave))) + out_of_office_infos = dict(((res['partner_id'], res) for res in self.env.cr.dictfetchall())) + for partner_id, out_of_office_info in out_of_office_infos.items(): + partner_infos[partner_id]['out_of_office_date_end'] = out_of_office_info['date_to'] + partner_infos[partner_id]['out_of_office_message'] = out_of_office_info['out_of_office_message'] + return partner_infos diff --git a/addons/hr_holidays/models/res_partner.py b/addons/hr_holidays/models/res_partner.py new file mode 100644 index 00000000000..dde82f5cc9b --- /dev/null +++ b/addons/hr_holidays/models/res_partner.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + def _compute_im_status(self): + super(ResPartner, self)._compute_im_status() + absent_now = self._get_on_leave_ids() + for partner in self: + if partner.id in absent_now: + if partner.im_status == 'online': + partner.im_status = 'leave_online' + else: + partner.im_status = 'leave_offline' + + @api.model + def _get_on_leave_ids(self): + return self.env['res.users']._get_on_leave_ids(partner=True) diff --git a/addons/hr_holidays/models/res_users.py b/addons/hr_holidays/models/res_users.py new file mode 100644 index 00000000000..3df8f5175f5 --- /dev/null +++ b/addons/hr_holidays/models/res_users.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + def _compute_im_status(self): + super(ResUsers, self)._compute_im_status() + on_leave_user_ids = self._get_on_leave_ids() + for user in self: + if user.id in on_leave_user_ids: + if user.im_status == 'online': + user.im_status = 'leave_online' + else: + user.im_status = 'leave_offline' + + @api.model + def _get_on_leave_ids(self, partner=False): + now = fields.Datetime.now() + field = 'partner_id' if partner else 'id' + self.env.cr.execute('''SELECT res_users.%s FROM res_users + JOIN hr_leave ON hr_leave.user_id = res_users.id + AND state not in ('cancel', 'refuse') + AND res_users.active = 't' + AND date_from <= %%s AND date_to >= %%s''' % field, (now, now)) + return [r[0] for r in self.env.cr.fetchall()] diff --git a/addons/hr_holidays/static/src/xml/discuss.xml b/addons/hr_holidays/static/src/xml/discuss.xml new file mode 100644 index 00000000000..33128c54c3f --- /dev/null +++ b/addons/hr_holidays/static/src/xml/discuss.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/hr_holidays/tests/__init__.py b/addons/hr_holidays/tests/__init__.py index 9510f75b34b..67c8327e999 100644 --- a/addons/hr_holidays/tests/__init__.py +++ b/addons/hr_holidays/tests/__init__.py @@ -7,3 +7,4 @@ from . import test_hr_leave_type from . import test_accrual_allocations from . import test_change_department from . import test_leave_requests +from . import test_out_of_office diff --git a/addons/hr_holidays/tests/test_out_of_office.py b/addons/hr_holidays/tests/test_out_of_office.py new file mode 100644 index 00000000000..05e3e480cc0 --- /dev/null +++ b/addons/hr_holidays/tests/test_out_of_office.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from odoo.tests.common import tagged, users, warmup +from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase + + +@tagged('out_of_office') +class TestOutOfOffice(TestHrHolidaysBase): + + def test_leave_ooo(self): + leave_type = self.env['hr.leave.type'].create({ + 'name': 'Legal Leaves', + 'time_type': 'leave', + 'allocation_type': 'no', + }) + self.assertNotEqual(self.employee_hruser.user_id.im_status, 'leave_offline', 'user should not be on leave') + self.assertNotEqual(self.employee_hruser.user_id.partner_id.im_status, 'leave_offline', 'user should not be on leave') + leave_date_end = (datetime.today() + relativedelta(days=3)) + leave = self.env['hr.leave'].create({ + 'name': 'Christmas', + 'employee_id': self.employee_hruser.id, + 'holiday_status_id': leave_type.id, + 'date_from': (datetime.today() - relativedelta(days=1)), + 'date_to': leave_date_end, + 'out_of_office_message': 'contact tde in case of problems', + 'number_of_days': 4, + }) + leave.action_approve() + self.assertEqual(self.employee_hruser.user_id.im_status, 'leave_offline', 'user should be out (leave_offline)') + self.assertEqual(self.employee_hruser.user_id.partner_id.im_status, 'leave_offline', 'user should be out (leave_offline)') + + partner = self.employee_hruser.user_id.partner_id + partner2 = self.user_employee.partner_id + + channel = self.env['mail.channel'].with_context({ + 'mail_create_nolog': True, + 'mail_create_nosubscribe': True, + 'mail_channel_noautofollow': True, + }).create({ + 'channel_partner_ids': [(4, partner.id), (4, partner2.id)], + 'public': 'private', + 'channel_type': 'chat', + 'email_send': False, + 'name': 'test' + }) + infos = channel.sudo(self.user_employee).channel_info() + self.assertEqual(infos[0]['direct_partner'][0]['out_of_office_date_end'], leave_date_end) + self.assertEqual(infos[0]['direct_partner'][0]['out_of_office_message'], 'contact tde in case of problems') + + +@tagged('out_of_office') +class TestOutOfOfficePerformance(TestHrHolidaysBase): + + def setUp(self): + super(TestOutOfOfficePerformance, self).setUp() + self.leave_type = self.env['hr.leave.type'].create({ + 'name': 'Legal Leaves', + 'time_type': 'leave', + 'allocation_type': 'no', + }) + self.leave_date_end = (datetime.today() + relativedelta(days=3)) + self.leave = self.env['hr.leave'].create({ + 'name': 'Christmas', + 'employee_id': self.employee_hruser_id, + 'holiday_status_id': self.leave_type.id, + 'date_from': (datetime.today() - relativedelta(days=1)), + 'date_to': (datetime.today() + relativedelta(days=3)), + 'out_of_office_message': 'contact tde in case of problems', + 'number_of_days': 4, + }) + + self.hr_user = self.employee_hruser.user_id + self.hr_partner = self.employee_hruser.user_id.partner_id + self.employer_partner = self.user_employee.partner_id + + @users('__system__', 'demo') + @warmup + def test_leave_im_status_performance_partner_offline(self): + with self.assertQueryCount(__system__=2, demo=2): + self.assertEqual(self.employer_partner.im_status, 'offline') + + @users('__system__', 'demo') + @warmup + def test_leave_im_status_performance_user_leave_offline(self): + with self.assertQueryCount(__system__=2, demo=2): + self.assertEqual(self.hr_user.im_status, 'leave_offline') + + @users('__system__', 'demo') + @warmup + def test_leave_im_status_performance_partner_leave_offline(self): + with self.assertQueryCount(__system__=2, demo=2): + self.assertEqual(self.hr_partner.im_status, 'leave_offline') diff --git a/addons/hr_holidays/views/hr_leave_views.xml b/addons/hr_holidays/views/hr_leave_views.xml index f655906d0af..c38c2d9f7dd 100644 --- a/addons/hr_holidays/views/hr_leave_views.xml +++ b/addons/hr_holidays/views/hr_leave_views.xml @@ -246,7 +246,7 @@ - + diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 81b201b98e9..80e5f0fff28 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -57,6 +57,7 @@ 'static/src/xml/abstract_thread_window.xml', 'static/src/xml/thread_window.xml', 'static/src/xml/announcement.xml', + 'static/src/xml/user_menu.xml', 'static/src/xml/web_kanban_activity.xml', ], } diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py index c53d8689082..750446371ee 100644 --- a/addons/mail/models/__init__.py +++ b/addons/mail/models/__init__.py @@ -24,5 +24,6 @@ from . import ir_action_act_window from . import ir_actions from . import ir_attachment from . import ir_autovacuum +from . import ir_http from . import ir_model from . import ir_ui_view diff --git a/addons/mail/models/ir_http.py b/addons/mail/models/ir_http.py new file mode 100644 index 00000000000..b6aa21809a5 --- /dev/null +++ b/addons/mail/models/ir_http.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models +from odoo.http import request + + +class IrHttp(models.AbstractModel): + _inherit = 'ir.http' + + def session_info(self): + user = request.env.user + result = super(IrHttp, self).session_info() + result['out_of_office_message'] = user.out_of_office_message + return result diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index bdc0f8b2160..3a83cd8f9bd 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -540,21 +540,47 @@ class Channel(models.Model): notifications.append([channel.uuid, dict(message_values)]) return notifications + @api.model + def partner_info(self, all_partners, direct_partners): + """ + Return the information needed by channel to display channel members + :param all_partners: list of res.parner(): + :param direct_partners: list of res.parner(): + :returns: a list of {'id', 'name', 'email'} for each partner and adds {im_status, out_of_office_message} for direct_partners. + :rtype : list(dict) + """ + partner_infos = {partner['id']: partner for partner in all_partners.sudo().read(['id', 'name', 'email'])} + # add im _status and out_of_office_message for direct_partners + direct_partners_im_status = {partner['id']: partner for partner in direct_partners.sudo().read(['im_status'])} + partner_infos.update(direct_partners_im_status) + for user in self.env['res.users'].search([('partner_id', 'in', direct_partners.ids), ('out_of_office_message', '!=', False)]): + partner_infos[user.partner_id.id]['out_of_office_message'] = user.out_of_office_message + return partner_infos + @api.multi - def channel_info(self, extra_info = False): + def channel_info(self, extra_info=False): """ Get the informations header for the current channels :returns a list of channels values :rtype : list(dict) """ + if not self: + return [] channel_infos = [] - # all relations partner_channel on those channels all_partner_channel = self.env['mail.channel.partner'].search([('channel_id', 'in', self.ids)]) # all partner infos on those channels - partner_infos = all_partner_channel.mapped('partner_id').read(['id', 'name', 'email']) + channel_dict = {channel.id: channel for channel in self} + all_partners = all_partner_channel.mapped('partner_id') + direct_channel_partners = all_partner_channel.filtered(lambda pc: channel_dict[pc.channel_id.id].channel_type == 'chat') + direct_partners = direct_channel_partners.mapped('partner_id') + partner_infos = self.partner_info(all_partners, direct_partners) + + # add last message preview (only used in mobile) + addPreview = self._context.get('isMobile', False) + if addPreview: + channel_previews = {channel_preview['id']: channel_preview for channel_preview in self.channel_fetch_preview()} - # for each channel, build the information header and include the logged partner information for channel in self: info = { 'id': channel.id, @@ -572,42 +598,39 @@ class Channel(models.Model): } if extra_info: info['info'] = extra_info - # add the partner for 'direct mesage' channel - if channel.channel_type == 'chat': - info['direct_partner'] = (channel.sudo() - .with_context(active_test=False) - .channel_partner_ids - .filtered(lambda p: p.id != self.env.user.partner_id.id) - .read(['id', 'name', 'im_status'])) # add last message preview (only used in mobile) - if self._context.get('isMobile', False): - last_message = channel.channel_fetch_preview() - if last_message: - info['last_message'] = last_message[0].get('last_message') + if addPreview: + if channel in channel_previews: + info['last_message'] = channel_previews[channel] # listeners of the channel channel_partners = all_partner_channel.filtered(lambda pc: channel.id == pc.channel_id.id) # find the channel partner state, if logged user - partner_channel = self.env['mail.channel.partner'] if self.env.user and self.env.user.partner_id: - partner_channel = channel_partners.filtered(lambda pc: pc.partner_id.id == self.env.user.partner_id.id) + # add the partner for 'direct mesage' channel + if channel.channel_type == 'chat': + # direct_partner should be removed from channel info since we can find it from members and channel_type + # we keep it know to avoid change tests and javascript + direct_partner = direct_channel_partners.filtered(lambda pc: pc.partner_id.id != self.env.user.partner_id.id) + info['direct_partner'] = [partner_infos[direct_partner[0].partner_id.id]] # add needaction and unread counter, since the user is logged info['message_needaction_counter'] = channel.message_needaction_counter info['message_unread_counter'] = channel.message_unread_counter - # add user session state, if available and if user is logged - if len(partner_channel.ids): - partner_channel = partner_channel[0] - info['state'] = partner_channel.fold_state or 'open' - info['is_minimized'] = partner_channel.is_minimized - info['seen_message_id'] = partner_channel.seen_message_id.id - info['custom_channel_name'] = partner_channel.custom_channel_name + # add user session state, if available and if user is logged + partner_channel = channel_partners.filtered(lambda pc: pc.partner_id.id == self.env.user.partner_id.id) + if partner_channel: + partner_channel = partner_channel[0] + info['state'] = partner_channel.fold_state or 'open' + info['is_minimized'] = partner_channel.is_minimized + info['seen_message_id'] = partner_channel.seen_message_id.id + info['custom_channel_name'] = partner_channel.custom_channel_name # add members infos partner_ids = channel_partners.mapped('partner_id').ids - info['members'] = [partner_info for partner_info in partner_infos if partner_info['id'] in partner_ids] + info['members'] = [partner_infos[partner] for partner in partner_ids] info['seen_partners_info'] = [{ 'partner_id': cp.partner_id.id, 'fetched_message_id': cp.fetched_message_id.id, @@ -925,6 +948,8 @@ class Channel(models.Model): @api.multi def channel_fetch_preview(self): """ Return the last message of the given channels """ + if not self: + return [] self._cr.execute(""" SELECT mail_channel_id AS id, MAX(mail_message_id) AS message_id FROM mail_message_mail_channel_rel diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index 04be083d8da..b6f65d79eec 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -7,6 +7,8 @@ import threading from odoo.tools.misc import split_every from odoo import _, api, fields, models, registry, SUPERUSER_ID +from odoo.addons.bus.models.bus_presence import AWAY_TIMER +from odoo.addons.bus.models.bus_presence import DISCONNECTION_TIMER from odoo.osv import expression _logger = logging.getLogger(__name__) @@ -252,3 +254,38 @@ class Partner(models.Model): partners = [p for p in partners if not len([u for u in users if u['id'] == p['id']])] return [users, partners] + + @api.model + def im_search(self, name, limit=20): + """ Search partner with a name and return its id, name and im_status. + Note : the user must be logged + :param name : the partner name to search + :param limit : the limit of result to return + """ + # This method is supposed to be used only in the context of channel creation or + # extension via an invite. As both of these actions require the 'create' access + # right, we check this specific ACL. + if self.env['mail.channel'].check_access_rights('create', raise_exception=False): + name = '%' + name + '%' + excluded_partner_ids = [self.env.user.partner_id.id] + self.env.cr.execute(""" + SELECT + U.id as user_id, + P.id as id, + P.name as name, + CASE WHEN B.last_poll IS NULL THEN 'offline' + WHEN age(now() AT TIME ZONE 'UTC', B.last_poll) > interval %s THEN 'offline' + WHEN age(now() AT TIME ZONE 'UTC', B.last_presence) > interval %s THEN 'away' + ELSE 'online' + END as im_status + FROM res_users U + JOIN res_partner P ON P.id = U.partner_id + LEFT JOIN bus_presence B ON B.user_id = U.id + WHERE P.name ILIKE %s + AND P.id NOT IN %s + AND U.active = 't' + LIMIT %s + """, ("%s seconds" % DISCONNECTION_TIMER, "%s seconds" % AWAY_TIMER, name, tuple(excluded_partner_ids), limit)) + return self.env.cr.dictfetchall() + else: + return {} diff --git a/addons/mail/models/res_users.py b/addons/mail/models/res_users.py index 9e04c6372e9..6d03343a33d 100644 --- a/addons/mail/models/res_users.py +++ b/addons/mail/models/res_users.py @@ -38,6 +38,7 @@ class Users(models.Model): moderation_channel_ids = fields.Many2many( 'mail.channel', 'mail_channel_moderator_rel', string='Moderated channels') + out_of_office_message = fields.Char(string='Out of Office Message') @api.depends('moderation_channel_ids.moderation', 'moderation_channel_ids.moderator_ids') @api.multi @@ -74,10 +75,10 @@ GROUP BY channel_moderator.res_users_id""", [tuple(self.ids)]) init_res = super(Users, self).__init__(pool, cr) # duplicate list to avoid modifying the original reference type(self).SELF_WRITEABLE_FIELDS = list(self.SELF_WRITEABLE_FIELDS) - type(self).SELF_WRITEABLE_FIELDS.extend(['notification_type']) + type(self).SELF_WRITEABLE_FIELDS.extend(['notification_type', 'out_of_office_message']) # duplicate list to avoid modifying the original reference type(self).SELF_READABLE_FIELDS = list(self.SELF_READABLE_FIELDS) - type(self).SELF_READABLE_FIELDS.extend(['notification_type']) + type(self).SELF_READABLE_FIELDS.extend(['notification_type', 'out_of_office_message']) return init_res @api.model diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js index 8a045ac2d82..58e334808bf 100644 --- a/addons/mail/static/src/js/composers/basic_composer.js +++ b/addons/mail/static/src/js/composers/basic_composer.js @@ -371,7 +371,15 @@ var BasicComposer = Widget.extend({ { limit: limit, search: search } ); } - return suggestions; + return $.when(suggestions).then(function (suggestions) { + //add im_status on suggestions + _.each(suggestions, function (suggestionsSet) { + _.each(suggestionsSet, function (suggestion) { + suggestion.im_status = self.call('mail_service', 'getImStatus', { partnerID: suggestion.id }); + }); + }); + return suggestions; + }); }); }, /** diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js index 808cd8338ad..073befc5fcd 100644 --- a/addons/mail/static/src/js/discuss.js +++ b/addons/mail/static/src/js/discuss.js @@ -56,7 +56,10 @@ var PartnerInviteDialog = Dialog.extend({ allowClear: true, multiple: true, formatResult: function (item) { - var status = QWeb.render('mail.UserStatus', { status: item.im_status }); + var status = QWeb.render('mail.UserStatus', { + status: self.call('mail_service', 'getImStatus', { partnerID: item.id }), + partnerID: item.id, + }); return $('').text(item.text).prepend(status); }, query: function (query) { @@ -992,10 +995,10 @@ var Discuss = AbstractAction.extend({ .on('new_channel', this, this._onNewChannel) .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible) .on('unsubscribe_from_channel', this, this._onChannelLeft) + .on('updated_im_status', this, this._onUpdatedImStatus) .on('update_needaction', this, this._onUpdateNeedaction) .on('update_starred', this, this._onUpdateStarred) .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter) - .on('update_dm_presence', this, this._onUpdateDmPresence) .on('activity_updated', this, this._onActivityUpdated) .on('update_moderation_counter', this, this._onUpdateModerationCounter) .on('update_typing_partners', this, this._onTypingPartnersUpdated) @@ -1572,7 +1575,7 @@ var Discuss = AbstractAction.extend({ /** * @private */ - _onUpdateDmPresence: function () { + _onUpdatedImStatus: function () { this._throttledUpdateThreads(); }, /** diff --git a/addons/mail/static/src/js/models/messages/abstract_message.js b/addons/mail/static/src/js/models/messages/abstract_message.js index c1ae22b9708..bb2a19cc6ac 100644 --- a/addons/mail/static/src/js/models/messages/abstract_message.js +++ b/addons/mail/static/src/js/models/messages/abstract_message.js @@ -75,6 +75,14 @@ var AbstractMessage = Class.extend({ } return this._serverAuthorID[0]; }, + /** + * Threads do not have an im status by default + * + * @return {undefined} + */ + getAuthorImStatus: function () { + return undefined; + }, /** * Get the relative url of the avatar to display next to the message * diff --git a/addons/mail/static/src/js/models/messages/message.js b/addons/mail/static/src/js/models/messages/message.js index dc98bb7195b..a414a11874b 100644 --- a/addons/mail/static/src/js/models/messages/message.js +++ b/addons/mail/static/src/js/models/messages/message.js @@ -65,6 +65,16 @@ var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin addCustomerEmailData: function (data) { this._customerEmailData.push(data); }, + /** + * @override + * @return {string|undefined} + */ + getAuthorImStatus: function () { + if (!this.hasAuthor()) { + return undefined; + } + return this.call('mail_service', 'getImStatus', { partnerID: this.getAuthorID() }); + }, /** * Get the name of the author of this message * If there are no author, return "". diff --git a/addons/mail/static/src/js/models/threads/abstract_thread.js b/addons/mail/static/src/js/models/threads/abstract_thread.js index d6a37967c68..a515c4d2f8a 100644 --- a/addons/mail/static/src/js/models/threads/abstract_thread.js +++ b/addons/mail/static/src/js/models/threads/abstract_thread.js @@ -74,6 +74,22 @@ var AbstractThread = Class.extend(Mixins.EventDispatcherMixin, { getName: function () { return this._name; }, + /** + * By default, threads do not have out-of-office feature + * + * @return {string|undefined} + */ + getOutOfOfficeInfo: function () { + return undefined; + }, + /** + * By default, threads do not have out-of-office feature + * + * @return {string|undefined} + */ + getOutOfOfficeMessage: function () { + return undefined; + }, /** * Get the status of the thread (e.g. 'online', 'offline', etc.) * diff --git a/addons/mail/static/src/js/models/threads/dm_chat.js b/addons/mail/static/src/js/models/threads/dm_chat.js index 5e69dbeff8c..fb524515193 100644 --- a/addons/mail/static/src/js/models/threads/dm_chat.js +++ b/addons/mail/static/src/js/models/threads/dm_chat.js @@ -3,6 +3,10 @@ odoo.define('mail.model.DMChat', function (require) { var TwoUserChannel = require('mail.model.TwoUserChannel'); +var core = require('web.core'); + +var _t = core._t; + /** * Any piece of code in JS that make use of DMs must ideally interact with * such objects, instead of direct data from the server. @@ -18,6 +22,8 @@ var DMChat = TwoUserChannel.extend({ * @param {integer} params.data.direct_partner[0].id * @param {string} params.data.direct_partner[0].im_status * @param {string} params.data.direct_partner[0].name + * @param {string} [params.data.direct_partner[0].out_of_office_message] + * @param {string} [params.data.direct_partner[0].out_of_office_date_end] */ init: function (params) { this._super.apply(this, arguments); @@ -26,8 +32,14 @@ var DMChat = TwoUserChannel.extend({ this._directPartnerID = data.direct_partner[0].id; this._name = data.custom_channel_name || data.direct_partner[0].name; - this._status = data.direct_partner[0].im_status; + this._outOfOfficeMessage = data.direct_partner[0].out_of_office_message; + this._outOfOfficeDateEnd = data.direct_partner[0].out_of_office_date_end; this._type = 'dm_chat'; + + this.call('mail_service', 'updateImStatus', [{ + id: this._directPartnerID, + im_status: data.direct_partner[0].im_status + }]); }, //-------------------------------------------------------------------------- @@ -44,6 +56,34 @@ var DMChat = TwoUserChannel.extend({ getDirectPartnerID: function () { return this._directPartnerID; }, + /** + * Get the out of office info + * + * @override {mail.model.AbstractThread} + * @returns {string|undefined} + */ + getOutOfOfficeInfo: function () { + if (!this._outOfOfficeDateEnd) { + return undefined; + } + var currentDate = new Date(); + var date = new Date(this._outOfOfficeDateEnd); + var options = { day: 'numeric', month: 'short' }; + if (currentDate.getFullYear() !== date.getFullYear()) { + options.year = 'numeric'; + } + var formattedDate = date.toLocaleDateString(window.navigator.language, options); + return _.str.sprintf(_t("Out of office until %s"), formattedDate); + }, + /** + * Get the out of office message of the thread + * + * @override {mail.model.AbstractThread} + * @returns {string} + */ + getOutOfOfficeMessage: function () { + return this._outOfOfficeMessage; + }, /** * @override */ @@ -53,10 +93,11 @@ var DMChat = TwoUserChannel.extend({ return result; }, /** - * @param {string} newStatus + * @override + * @return {string} */ - setStatus: function (newStatus) { - this._status = newStatus; + getStatus: function () { + return this.call('mail_service', 'getImStatus', { partnerID: this._directPartnerID }); }, }); diff --git a/addons/mail/static/src/js/services/mail_manager.js b/addons/mail/static/src/js/services/mail_manager.js index 777df1df4a6..204a88bb8e9 100644 --- a/addons/mail/static/src/js/services/mail_manager.js +++ b/addons/mail/static/src/js/services/mail_manager.js @@ -443,10 +443,6 @@ var MailManager = AbstractService.extend({ channel = this._makeChannel(data, options); if (channel.getType() === 'dm_chat') { this._pinnedDmPartners.push(channel.getDirectPartnerID()); - this.call('bus_service', 'updateOption', - 'bus_presence_partner_ids', - this._pinnedDmPartners - ); } this._threads.push(channel); if (data.last_message) { @@ -1041,10 +1037,6 @@ var MailManager = AbstractService.extend({ var index = this._pinnedDmPartners.indexOf(channel.getDirectPartnerID()); if (index > -1) { this._pinnedDmPartners.splice(index, 1); - this.call('bus_service', 'updateOption', - 'bus_presence_partner_ids', - this._pinnedDmPartners - ); } } this._threads = _.without(this._threads, channel); diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js index b59d7b91434..b77fa3e65fd 100644 --- a/addons/mail/static/src/js/services/mail_notification_manager.js +++ b/addons/mail/static/src/js/services/mail_notification_manager.js @@ -493,22 +493,6 @@ MailManager.include({ self.call('mail_service', 'openDMChatWindowFromBlankThreadWindow', partnerID); }); }, - /** - * On receiving an update on user status (e.g. becoming 'online', 'offline', - * 'idle', etc.). - * - * @private - * @param {Object} data partner infos - * @param {integer} data.id partner ID - * @param {string} data.im_status partner new 'im status' (e.g. 'online') - */ - _handlePresenceNotification: function (data) { - var dmChat = this.getDMChatFromPartnerID(data.id); - if (dmChat) { - dmChat.setStatus(data.im_status); - this._mailBus.trigger('update_dm_presence', dmChat); - } - }, /** * @override * @private @@ -569,8 +553,6 @@ MailManager.include({ }); } else if (model === 'res.partner') { self._handlePartnerNotification(notif[1]); - } else if (model === 'bus.presence') { - self._handlePresenceNotification(notif[1]); } }); }, diff --git a/addons/mail/static/src/js/services/mail_service.js b/addons/mail/static/src/js/services/mail_service.js index 4f6b0c29dfc..2256e696c2f 100644 --- a/addons/mail/static/src/js/services/mail_service.js +++ b/addons/mail/static/src/js/services/mail_service.js @@ -5,6 +5,7 @@ var core = require('web.core'); var MailManager = require('mail.Manager'); +require('mail.Manager.Status'); require('mail.Manager.Notification'); require('mail.Manager.Window'); require('mail.Manager.DocumentThread'); diff --git a/addons/mail/static/src/js/services/mail_status_manager.js b/addons/mail/static/src/js/services/mail_status_manager.js new file mode 100644 index 00000000000..5ca151d47bf --- /dev/null +++ b/addons/mail/static/src/js/services/mail_status_manager.js @@ -0,0 +1,197 @@ +odoo.define('mail.Manager.Status', function (require) { +"use strict"; + +var core = require('web.core'); +var MailManager = require('mail.Manager'); +var mailUtils = require('mail.utils'); +var QWeb = core.qweb; + +/** + * Mail Manager: IM Status + * + * This component handles im status of partners, which is useful for DM Chats, + * partner mention suggestions, and chatter messages that display the user icon. + */ +MailManager.include({ + _UPDATE_INTERVAL: 50, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the uin cache im status, and triggers an asynchronous throttled + * update of im_status cache for missings + * + * @param {Object} data + * @param {integer} data.partnerID + * @return {String} + */ + getImStatus: function (data) { + var self = this; + var partnerID = data.partnerID; + if (!this._imStatus[partnerID]) { + // Add to list to call it in next bus update or _fetchMissingImStatus + this._imStatus[partnerID] = undefined; + // fetch after some time if no other getImStatus occurs + mailUtils.clearTimeout(this._fetchStatusTimeout); + this._fetchStatusTimeout = mailUtils.setTimeout(function () { + self._fetchMissingImStatus(); + }, 500); + } + return this._imStatus[partnerID]; + }, + /** + * Update status manually, to avoid to do a rpc and an asynchronous update + * after getImStatus. Can be done by any caller knowing the last im_status + * state. + * + * @param {Object[]} statusList, A list of {id, im_status} + */ + updateImStatus: function (statusList) { + var updatedIDs = []; + var self = this; + _.each(statusList, function (status) { + if (self._imStatus[status.id] === status.im_status) { + return; + } + updatedIDs.push(status.id); + self._imStatus[status.id] = status.im_status; + }); + if (! _.isEmpty(updatedIDs)) { + this._mailBus.trigger('updated_im_status', updatedIDs); // useful for thread window header + this._renderImStatus(updatedIDs); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetch the list of im_status for partner with id in ids list and triggers + * an update. + * + * @private + * @param {Object} data + * @param {integer[]} data.partnerIDs + * @return {Deferred} + */ + _fetchImStatus: function (data) { + var self = this; + var partnerIDs = data.partnerIDs; + if (_.isEmpty(partnerIDs)) { + return $.when(); + } + return this._rpc({ + model: 'res.partner', + method: 'read', + args: [partnerIDs, ['id', 'im_status']], + }).then( function (results) { + self.updateImStatus(results); + }); + }, + /** + * Fetch the list of im_status for partner with an unknown im_status and + * triggers an update. + * + * @private + * @return {Deferred} + */ + _fetchMissingImStatus: function () { + var missing = []; + _.each(this._imStatus, function (value, key) { + if (value === undefined) { + missing.push(Number(key)); + } + }); + return this._fetchImStatus({ partnerIDs: missing }); + }, + /** + * @private + * @return {integer[]} a list of partner ids that needs update + */ + _getImStatusToUpdate: function () { + var toUpdate = []; + _.each(this._imStatus, function (status, key) { + //filter on im_partner and bot: useless to update them, status won't change + if (['im_partner', 'bot'].indexOf(status) === -1) { + toUpdate.push(Number(key)); + } + }); + return toUpdate; + }, + /** + * @override + * @private + */ + _initializeInternalState: function () { + this._super.apply(this, arguments); + this._fetchStatusTimeout = undefined; + this._imStatus = {}; + this._isTabFocused = true; + this._updateImStatusLoop(); + }, + /** + * @override + * @private + */ + _listenOnBuses: function () { + this._super.apply(this, arguments); + $(window).on("focus", this._onWindowFocusChange.bind(this, true)); + $(window).on("blur", this._onWindowFocusChange.bind(this, false)); + $(window).on("unload", this._onWindowFocusChange.bind(this, false)); + }, + /** + * @private + * @param {integer[]} updatedIds + */ + _renderImStatus: function (updatedIds) { + var self = this; + $('.o_updatable_im_status').each(function () { + var $this = $(this); + var partnerID = $this.data('partner-id'); + if (partnerID !== undefined && updatedIds.indexOf(partnerID) !== -1) { // todo instead add id on o_updatable_im_status and select only concerned ones + var status = QWeb.render('mail.UserStatus', { + status: self.getImStatus({ partnerID: partnerID }), + partnerID: partnerID, + }); + $this.replaceWith(status); + } + }); + }, + /** + * Once initialised, this loop will update the im_status of registered + * users. + * + * @private + * @param {integer} [counter=0] The recursion loop counter + */ + _updateImStatusLoop: function (counter) { + var self = this; + if (!_.isNumber(counter)) { + counter = 0; + } + mailUtils.setTimeout(function () { + if (counter >= self._UPDATE_INTERVAL && self._isTabFocused) { + self._fetchImStatus({ partnerIDs: self._getImStatusToUpdate() }); + counter = 0; + } + self._updateImStatusLoop(counter+1); + }, 1000); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {boolean} focused + */ + _onWindowFocusChange: function (focused) { + this._isTabFocused = focused; + }, +}); + +}); diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js index 4971e7f3f4b..e047c71ab89 100644 --- a/addons/mail/static/src/js/services/mail_window_manager.js +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -334,8 +334,8 @@ MailManager.include({ .on('new_channel', this, this._onNewChannel) .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible) .on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel) - .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter) - .on('update_dm_presence', this, this._onUpdateDmPresence); + .on('updated_im_status', this, this._onUpdatedImStatus) + .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter); core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100)); }, @@ -663,6 +663,28 @@ MailManager.include({ _onUnsubscribeFromChannel: function (channelID) { this._closeThreadWindow(channelID); }, + /** + * Called when there is a change of the im status of the partner. + * The header of the thread window should be updated accordingly, + * in order to display the correct new im status of this users. + * + * @private + * @param {integer} partnerID + */ + _onUpdatedImStatus: function (partnerIDs) { + var self = this; + _.each(partnerIDs, function (partnerID) { + var thread = self.getDMChatFromPartnerID(partnerID); + if (! thread) { + return; + } + var threadWindow = self._getThreadWindow(thread.getID()); + if (!threadWindow) { + return; + } + threadWindow.renderHeader(); + }); + }, /** * Called when a thread has its unread counter that has changed. * The unread counter on the thread windows should be updated. @@ -690,21 +712,6 @@ MailManager.include({ this._repositionHiddenWindowsDropdown(); } }, - /** - * Called when there is a change of the im status of the user linked to - * DMs. The header of the thread window should be updated accordingly, - * in order to display the correct new im status of this users. - * - * @private - * @param {mail.model.Thread} thread - */ - _onUpdateDmPresence: function (thread) { - _.each(this._threadWindows, function (threadWindow) { - if (thread.getID() === threadWindow.getID()) { - threadWindow.renderHeader(); - } - }); - }, /** * Called when a message has been updated. * diff --git a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js index 3e4e2e16325..300bfb11a3e 100644 --- a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js +++ b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js @@ -147,6 +147,28 @@ var AbstractThreadWindow = Widget.extend({ } return this._thread; }, + /** + *Get out of office info + * + * @returns {string|undefined} + */ + getOutOfOfficeInfo: function () { + if (!this.hasThread()) { + return undefined; + } + return this._thread.getOutOfOfficeInfo(); + }, + /** + * Get out of office user text + * + * @returns {string|undefined} + */ + getOutOfOfficeMessage: function () { + if (!this.hasThread()) { + return undefined; + } + return this._thread.getOutOfOfficeMessage(); + }, /** * Get the status of the thread, such as the im status of a DM chat * ('online', 'offline', etc.). If this window has no thread, returns diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js index 331228c91c8..41b0ef6d36c 100644 --- a/addons/mail/static/src/js/thread_windows/thread_window.js +++ b/addons/mail/static/src/js/thread_windows/thread_window.js @@ -21,6 +21,7 @@ var ThreadWindow = AbstractThreadWindow.extend({ 'click .o_mail_thread': '_onThreadWindowFocus', 'click .o_thread_composer': '_onThreadWindowFocus', 'click .o_thread_window_expand': '_onClickExpand', + 'click .o_out_of_office_read_more_less_button': '_onClickOutOfOfficeReadMoreLess', }), /** * Version of thread window that supports {mail.model.Thread} @@ -80,6 +81,7 @@ var ThreadWindow = AbstractThreadWindow.extend({ self.$input = self.$('.o_composer_text_field'); }); } + this._updateOutOfOfficeReadMoreLessButton(); return $.when(superDef, composerDef); }, @@ -250,6 +252,19 @@ var ThreadWindow = AbstractThreadWindow.extend({ }) .focus(); }, + /** + * @private + */ + _updateOutOfOfficeReadMoreLessButton: function () { + var $readMore = this.$('.o_out_of_office_text'); + var isOverflowing = $readMore.prop('scrollWidth') > $readMore.width(); + var isOverflowShown = !$readMore.hasClass('o_text_wrap'); + if (isOverflowing || isOverflowShown) { + var $button = this.$('.o_out_of_office_read_more_less_button'); + $button.show(); + $button.text(isOverflowing ? _t('Read more') : _t('Read less')); + } + }, //-------------------------------------------------------------------------- // Handlers @@ -288,6 +303,15 @@ var ThreadWindow = AbstractThreadWindow.extend({ }); } }, 1000, true), + /** + * @private + * @param {MouseEvent} ev + */ + _onClickOutOfOfficeReadMoreLess: function (ev) { + ev.preventDefault(); + this.$('.o_out_of_office_text').toggleClass('o_text_wrap'); + this._updateOutOfOfficeReadMoreLessButton(); + }, /** * @override * @private diff --git a/addons/mail/static/src/js/user_menu.js b/addons/mail/static/src/js/user_menu.js new file mode 100644 index 00000000000..77235c1f166 --- /dev/null +++ b/addons/mail/static/src/js/user_menu.js @@ -0,0 +1,22 @@ +odoo.define('mail.UserMenu', function (require) { +"use strict"; + +/** + * This file includes the UserMenu widget defined in Community to add or + * override actions only available in Enterprise. + */ + +var UserMenu = require('web.UserMenu'); + +UserMenu.include({ + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + var session = this.getSession(); + this.outOfOfficeMessage = session.out_of_office_message; + }, +}); + +}); diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js index 1f31a6ead2f..14ac97def76 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -91,6 +91,14 @@ function timeFromNow(date) { return date.fromNow(); } +function o_clearTimeout(id) { + return clearTimeout(id); +} + +function o_setTimeout(func, delay) { + return setTimeout(func, delay); +} + return { addLink: addLink, getTextToHTML: getTextToHTML, @@ -100,6 +108,8 @@ return { parseEmail: parseEmail, stripHTML: stripHTML, timeFromNow: timeFromNow, + clearTimeout: o_clearTimeout, + setTimeout: o_setTimeout, }; }); diff --git a/addons/mail/static/src/scss/abstract_thread_window.scss b/addons/mail/static/src/scss/abstract_thread_window.scss index b148afa111f..ab6b46d4b9a 100644 --- a/addons/mail/static/src/scss/abstract_thread_window.scss +++ b/addons/mail/static/src/scss/abstract_thread_window.scss @@ -79,6 +79,26 @@ $o-mail-thread-window-zindex: $zindex-modal + 1 !default; } + .o_out_of_office { + background-color: #9dd0e8; + padding:8px; + + .o_out_of_office_info { + font-weight: bold; + } + .o_out_of_office_text { + word-wrap: break-word; + overflow: hidden; + text-overflow: ellipsis; + } + .o_text_wrap { + white-space: nowrap; + } + .o_out_of_office_read_more_less_button { + display: none; + } + } + .o_mail_thread { flex: 1 1 100%; overflow: auto; diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss index 096c527aa16..1f2fffcee60 100644 --- a/addons/mail/static/src/scss/composer.scss +++ b/addons/mail/static/src/scss/composer.scss @@ -55,6 +55,9 @@ font-style: italic; color: gray; } + .fa { + font-size: 0.8em; + } } textarea { resize: none; diff --git a/addons/mail/static/src/scss/discuss.scss b/addons/mail/static/src/scss/discuss.scss index 570dc2dedd6..711180d3544 100644 --- a/addons/mail/static/src/scss/discuss.scss +++ b/addons/mail/static/src/scss/discuss.scss @@ -252,6 +252,11 @@ &.o_user_idle { color: theme-color('warning'); } + &.fa-stack { + width: 1em; + height: 1em; + line-height: 1em; + } } diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss index 7c867896150..84a826922ff 100644 --- a/addons/mail/static/src/scss/thread.scss +++ b/addons/mail/static/src/scss/thread.scss @@ -68,6 +68,19 @@ margin-top: 2px; text-align: center; font-size: smaller; + .o_thread_message_sidebar_image { + position: relative; + height: $o-mail-thread-avatar-size; + + .o_updatable_im_status { + width: $o-mail-thread-avatar-size; + } + .o_mail_user_status { + position: absolute; + bottom: 0; + right: 0; + } + } @include media-breakpoint-down(sm) { margin-top: 4px; diff --git a/addons/mail/static/src/xml/composer.xml b/addons/mail/static/src/xml/composer.xml index ff4680d7851..b9de0194e93 100644 --- a/addons/mail/static/src/xml/composer.xml +++ b/addons/mail/static/src/xml/composer.xml @@ -71,6 +71,10 @@ + + + + () diff --git a/addons/mail/static/src/xml/discuss.xml b/addons/mail/static/src/xml/discuss.xml index 98430c81ce1..10fb71a5110 100644 --- a/addons/mail/static/src/xml/discuss.xml +++ b/addons/mail/static/src/xml/discuss.xml @@ -154,6 +154,7 @@ + # @@ -176,11 +177,14 @@ - - - + + + + + +
+
+
+ + Read more +
+
To: diff --git a/addons/mail/static/src/xml/user_menu.xml b/addons/mail/static/src/xml/user_menu.xml new file mode 100644 index 00000000000..b5186c3b056 --- /dev/null +++ b/addons/mail/static/src/xml/user_menu.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js index e75157d3ad7..7aaa35ac4e3 100644 --- a/addons/mail/static/tests/chatter_tests.js +++ b/addons/mail/static/tests/chatter_tests.js @@ -27,6 +27,18 @@ QUnit.module('Chatter', { this.services = mailTestUtils.getMailServices(); this.data = { + 'res.partner': { + fields: { + im_status: { + string: "im_status", + type: "char", + } + }, + records: [{ + id: 1, + im_status: 'online', + }] + }, partner: { fields: { display_name: { string: "Displayed name", type: "char" }, @@ -772,7 +784,7 @@ QUnit.test('chatter: post, receive and star messages', function (assert) { } if (args.method === 'get_mention_suggestions') { getSuggestionsDef.resolve(); - return $.when([{email: "test@odoo.com", id: 1, name: "Test User"}]); + return $.when([[{email: "test@odoo.com", id: 1, name: "Test User"}], []]); } if (args.method === 'message_post') { var lastMessageData = _.max(this.data['mail.message'].records, function (messageData) { @@ -1308,7 +1320,7 @@ QUnit.test('chatter: discard changes on message post with post_refresh "recipien } if (args.method === 'get_mention_suggestions') { getSuggestionsDef.resolve(); - return $.when([{email: "me@odoo.com", id: 42, name: "Me"}]); + return $.when([[{email: "me@odoo.com", id: 42, name: "Me"}], []]); } if (args.method === 'message_format') { var requested_msgs = _.filter(messages, function (msg) { diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js index 5f903898255..4a8dc9c56a3 100644 --- a/addons/mail/static/tests/discuss_tests.js +++ b/addons/mail/static/tests/discuss_tests.js @@ -61,6 +61,18 @@ QUnit.module('Discuss', { }, }, }, + 'res.partner': { + fields: { + im_status: { + string: "status", + type: 'char', + }, + }, + records: [{ + id: 1, + im_status: 'online', + }] + }, }; this.services = mailTestUtils.getMailServices(); }, diff --git a/addons/mail/static/tests/helpers/test_utils.js b/addons/mail/static/tests/helpers/test_utils.js index cf63c281c58..063e3a9eb03 100644 --- a/addons/mail/static/tests/helpers/test_utils.js +++ b/addons/mail/static/tests/helpers/test_utils.js @@ -5,6 +5,7 @@ var BusService = require('bus.BusService'); var Discuss = require('mail.Discuss'); var MailService = require('mail.Service'); +var mailUtils = require('mail.utils'); var AbstractStorageService = require('web.AbstractStorageService'); var Class = require('web.Class'); @@ -79,6 +80,111 @@ var MockMailService = Class.extend({ }, }); +/** + * Patch all the mailUtils.clearTimeout and mailUtils.setTimeout. + * + * @return {Object} helper functions, including unpatch and time management tools. + */ +var patchMailTimeouts = function () { + var currentTime = 0; + var timeouts = {}; + var countTimeout = 0; + + mailUtils.clearTimeout = function (id) { + delete timeouts[id]; + }; + + mailUtils.setTimeout = function (func, duration) { + duration = duration || 0; + var executeTime = currentTime + duration; + countTimeout++; + timeouts[countTimeout] = { + executeTime: executeTime, + func: func + }; + return countTimeout; + }; + /** + * @return {integer|boolean} id of the next timeout in queue, false if queue is empty + */ + function getNextTimeoutId() { + var minKey = false; + _.each(timeouts, function (value, key) { + if (minKey === false) { + minKey = Number(key); + return; + } + var minTime = timeouts[minKey].executeTime; + if (value.executeTime < minTime || (value.executeTime === minTime && key < minKey)) { + minKey = Number(key); + } + }); + return minKey; + } + + /** + * @return {integer|boolean} delay (time interval) before the next timeout in queue is executed. + * Useful to know how much time to advance to execute next timer. + */ + function getNextTimeoutDelay() { + var next = getNextTimeoutId(); + if (next === false) { + return false; + } + return timeouts[next].executeTime - currentTime; + } + + /** + * Set the current time to given time + * + * @param {integer} time + */ + function setTime(time) { + var next = getNextTimeoutId(); + if (next !== false && timeouts[next].executeTime <= time) { + currentTime = timeouts[next].executeTime; + var func = timeouts[next].func; + // watch out setTimeout inside setTimeout (recursive) + delete timeouts[next]; + func(); + setTime(time); + } + else { + currentTime = time; + } + } + + /** + * Add the given time to current time + * + * @param {integer} time + */ + function addTime(time) { + setTime(currentTime + time); + } + + /** + * Set time to the max time in queue and execute all timeouts before this time. + */ + function runPendingTimeouts() { + var maxTimeInQueue = 0; + _.each(timeouts, function (value, key) { + if (value.executeTime > maxTimeInQueue) { + maxTimeInQueue = value.executeTime; + } + }); + setTime(maxTimeInQueue); + } + + return { + addTime: addTime, + getNextTimeoutDelay:getNextTimeoutDelay, + runPendingTimeouts: runPendingTimeouts, + setTime: setTime, + }; +}; + + /** * Returns the list of mail services required by the mail components: a * mail_service, and its two dependencies bus_service and local_storage. @@ -87,6 +193,7 @@ var MockMailService = Class.extend({ * and local_storage, in that order */ function getMailServices() { + patchMailTimeouts(); return new MockMailService().getServices(); } @@ -94,6 +201,7 @@ return { MockMailService: MockMailService, createDiscuss: createDiscuss, getMailServices: getMailServices, + patchMailTimeouts: patchMailTimeouts, }; }); diff --git a/addons/mail/static/tests/mail_status_manager_tests.js b/addons/mail/static/tests/mail_status_manager_tests.js new file mode 100644 index 00000000000..a1ae329c9dc --- /dev/null +++ b/addons/mail/static/tests/mail_status_manager_tests.js @@ -0,0 +1,177 @@ +odoo.define('mail.mailStatusServiceTests', function (require) { +"use strict"; + +var mailTestUtils = require('mail.testUtils'); + +var core = require('web.core'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; + +QUnit.module('mail', {}, function () { +QUnit.module('service', {}, function () { +QUnit.module('Status manager', { + beforeEach: function () { + this.services = mailTestUtils.getMailServices(this); + this.timeoutMock = mailTestUtils.patchMailTimeouts(); + }, +}); +QUnit.test('simple set im_status', function (assert) { + assert.expect(1); + var parent = testUtils.createParent({ + services: this.services, + mockRPC: function (route, args) { + if (route === '/mail/init_messaging') { + return this._super.apply(this, arguments); + } + throw new Error(_.str.sprintf('No rpc call should be performed: %s, %s \n %s', args.model, args.method, route)); + }, + }); + parent.call('mail_service', 'updateImStatus', [{ + id: 1, + im_status: 'online', + }]); + assert.strictEqual(parent.call('mail_service', 'getImStatus', { partnerID: 1 }), 'online'); + + this.timeoutMock.runPendingTimeouts(); + parent.destroy(); +}); + +QUnit.test('multi get_im_status', function (assert) { + assert.expect(9); + var readCount = 0; + var parent = testUtils.createParent({ + //data: this.data, + services: this.services, + mockRPC: function (route, args) { + if (route === '/mail/init_messaging') { + return this._super.apply(this, arguments); + } + if (args.method === 'read' && args.model === 'res.partner') { + assert.deepEqual(args.args[0], [2,3]); + assert.deepEqual(args.args[1], ['id', 'im_status']); + readCount++; + return $.when([ + {id: 2, im_status: 'away'}, + {id: 3, im_status: 'im_partner'} + ]); + } + throw new Error(_.str.sprintf('No rpc call should be performed: %s, %s \n %s', args.model, args.method, route)); + }, + }); + parent.call('mail_service', 'updateImStatus', [{ + id: 1, + im_status: 'online', + }]); + assert.strictEqual(parent.call('mail_service', 'getImStatus', { partnerID: 1 }), 'online'); + assert.strictEqual(parent.call('mail_service', 'getImStatus', { partnerID: 2 }), undefined); + assert.strictEqual(parent.call('mail_service', 'getImStatus', { partnerID: 3 }), undefined); + + this.timeoutMock.runPendingTimeouts(); + assert.strictEqual(parent.call('mail_service', 'getImStatus', { partnerID: 1 }), 'online'); + assert.strictEqual(parent.call('mail_service', 'getImStatus', { partnerID: 2 }), 'away'); + assert.strictEqual(parent.call('mail_service', 'getImStatus', { partnerID: 3 }), 'im_partner'); + + this.timeoutMock.runPendingTimeouts(); + assert.strictEqual(readCount, 1, 'Only one read on partner should have been performed'); + + parent.destroy(); +}); + +QUnit.test('update loop', function (assert) { + assert.expect(12); + var readCount = 0; + var parent = testUtils.createParent({ + services: this.services, + mockRPC: function (route, args) { + if (route === '/mail/init_messaging') { + return this._super.apply(this, arguments); + } + if (args.method === 'read' && args.model === 'res.partner') { + assert.deepEqual(args.args[0], [1, 2]); + assert.deepEqual(args.args[1], ['id', 'im_status']); + readCount++; + return $.when([ + {"id": 1, "im_status": "online"}, + {"id": 2, "im_status": "away"} + ]); + } + throw new Error(_.str.sprintf('No rpc call should be performed: %s, %s \n %s', args.model, args.method, route)); + }, + }); + // set initial status + parent.call('mail_service', 'updateImStatus', [ + { id: 1, im_status: 'offline' }, + { id: 2, im_status: 'offline' }, + { id: 3, im_status: 'im_partner' }, //shouldn't be updated !!!! + ]); + //_updateImStatusLoop should be running at one second per iteration, lets make a minute pass. + assert.strictEqual(readCount, 0); + this.timeoutMock.addTime(50*1000); + assert.strictEqual(readCount, 0); + this.timeoutMock.addTime(1000); + assert.strictEqual(readCount, 1, 'one call should have been made after 50 seconds' ); + assert.strictEqual(parent.call('mail_service', 'getImStatus', { partnerID: 1 }), 'online'); + assert.strictEqual(parent.call('mail_service', 'getImStatus', { partnerID: 2 }), 'away'); + + //simulate change of focus + //original listener: $(window).on("blur", this._onWindowFocusChange.bind(this, false); + unload, ... + parent.call('mail_service', '_onWindowFocusChange', false); // remove focus from tab + + this.timeoutMock.addTime(5*60*1000); // x minutes without focus, no rpc should be done during this time + assert.strictEqual(readCount, 1, 'No more call should have been performed'); + //simulate change of focus + //original listener: $(window).on("focus", this._onWindowFocusChange.bind(this, true); + parent.call('mail_service', '_onWindowFocusChange', true); // give focus to tab + var nextUpdateDelay = this.timeoutMock.getNextTimeoutDelay(); + assert.strictEqual(nextUpdateDelay, 1000, "next update should be done in maximum one second"); + this.timeoutMock.addTime(nextUpdateDelay); // one second should be enough + assert.strictEqual(readCount, 2, 'One more call should have been done once tab focused'); + this.timeoutMock.runPendingTimeouts(); + parent.destroy(); +}); + +QUnit.test('update status', function (assert) { + // the current solution to look for updatable im_status in dom is not perfect, but waiting for + // ability to include widgets in views, this is the most simple solution + assert.expect(2); + var StatusWidget = Widget.extend({ + start: function () { + this.render(); + this._super.apply(this, arguments); + }, + render: function () { + var status = QWeb.render('mail.UserStatus', { + status: 'online', + partnerID: 1, + }); + this.$el.html(status); + } + }); + var statusWidget = new StatusWidget(); + testUtils.mock.addMockEnvironment(statusWidget, { + services: this.services, + mockRPC: function (route, args) { + if (route === '/mail/init_messaging') { + return this._super.apply(this, arguments); + } + throw new Error(_.str.sprintf('No rpc call should be performed: %s, %s \n %s', args.model, args.method, route)); + }, + }); + statusWidget.appendTo($('#qunit-fixture')); + //Render unknow im_status: + // set initial status + assert.ok(statusWidget.$('.o_updatable_im_status i').hasClass('o_user_online')); + + statusWidget.call('mail_service', 'updateImStatus', [ + { id: 1, im_status: 'offline' }, + ]); + assert.notOk(statusWidget.$('.o_updatable_im_status i').hasClass('o_user_online')); + this.timeoutMock.runPendingTimeouts(); + statusWidget.destroy(); +}); + +}); +}); +}); diff --git a/addons/mail/static/tests/thread_window/basic_thread_window_tests.js b/addons/mail/static/tests/thread_window/basic_thread_window_tests.js index 3fc942b506f..5a0bc62f880 100644 --- a/addons/mail/static/tests/thread_window/basic_thread_window_tests.js +++ b/addons/mail/static/tests/thread_window/basic_thread_window_tests.js @@ -632,6 +632,69 @@ QUnit.test('do not auto-focus chat window on receiving new message from new DM', parent.destroy(); }); +QUnit.test('out-of-office status in thread window', function (assert) { + assert.expect(1); + this.data = { + 'mail.message': { + fields: {}, + records: [], + }, + initMessaging: { + channel_slots: { + channel_channel: [{ + id: 1, + name: "DM", + channel_type: "chat", + message_unread_counter: 0, + direct_partner: [{ id: 666, name: 'DemoUser1', im_status: 'online', out_of_office_message: 'Please don\'t disturb'}], + }], + }, + }, + }; + var parent = this.createParent({ + data: this.data, + services: this.services, + }); + // detach channel 1, so that it opens corresponding thread window. + parent.call('mail_service', 'getChannel', 1).detach(); + + var $threadWindow = $('.o_thread_window'); + assert.containsOnce($threadWindow, '.o_out_of_office_text'); + + parent.destroy(); +}); + +QUnit.test('no out-of-office status in thread window', function (assert) { + assert.expect(1); + this.data = { + 'mail.message': { + fields: {}, + records: [], + }, + initMessaging: { + channel_slots: { + channel_channel: [{ + id: 1, + name: "DM", + channel_type: "chat", + message_unread_counter: 0, + direct_partner: [{ id: 666, name: 'DemoUser1', im_status: 'online'}], + }], + }, + }, + }; + var parent = this.createParent({ + data: this.data, + services: this.services, + }); + // detach channel 1, so that it opens corresponding thread window. + parent.call('mail_service', 'getChannel', 1).detach(); + + var $threadWindow = $('.o_thread_window'); + assert.containsNone($threadWindow, '.o_out_of_office_text'); + parent.destroy(); +}); + }); }); }); diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml index e0175e77195..ea742a29fda 100644 --- a/addons/mail/views/mail_templates.xml +++ b/addons/mail/views/mail_templates.xml @@ -14,6 +14,7 @@ + @@ -65,6 +66,7 @@ + @@ -129,8 +131,10 @@ - + + + diff --git a/addons/mail/views/res_users_views.xml b/addons/mail/views/res_users_views.xml index d133fb0424b..8f4a376f0d9 100644 --- a/addons/mail/views/res_users_views.xml +++ b/addons/mail/views/res_users_views.xml @@ -12,6 +12,11 @@ + + + + + @@ -33,6 +38,5 @@ - diff --git a/addons/mail_bot/models/res_partner.py b/addons/mail_bot/models/res_partner.py index c715adccc49..2a6ec5eca55 100644 --- a/addons/mail_bot/models/res_partner.py +++ b/addons/mail_bot/models/res_partner.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, models + class Partner(models.Model): _inherit = 'res.partner' diff --git a/addons/mail_bot/views/discuss.xml b/addons/mail_bot/views/discuss.xml index e7f0650c867..95526e6b11a 100644 --- a/addons/mail_bot/views/discuss.xml +++ b/addons/mail_bot/views/discuss.xml @@ -1,10 +1,5 @@ - - - - - diff --git a/addons/test_mail/tests/test_mail_channel.py b/addons/test_mail/tests/test_mail_channel.py index 79ab747fed5..c95f562332d 100644 --- a/addons/test_mail/tests/test_mail_channel.py +++ b/addons/test_mail/tests/test_mail_channel.py @@ -196,6 +196,19 @@ class TestChannelFeatures(common.BaseFunctionalTest, common.MockEmails): email['email_to'][0], [formataddr((self.user_employee.name, self.user_employee.email)), formataddr((self.test_partner.name, self.test_partner.email))]) + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_channel_out_of_office(self): + self.user_employee.out_of_office_message = 'Out' + test_chat = self.env['mail.channel'].with_context(common.BaseFunctionalTest._test_context).create({ + 'channel_partner_ids': [(4, self.user_employee.partner_id.id), (4, self.user_admin.partner_id.id)], + 'public': 'private', + 'channel_type': 'chat', + 'email_send': False, + 'name': 'test' + }) + infos = test_chat.sudo(self.user_admin).channel_info() + self.assertEqual(infos[0]['direct_partner'][0]['out_of_office_message'], 'Out') + @tagged('moderation') class TestChannelModeration(common.Moderation): diff --git a/odoo/addons/base/views/res_users_views.xml b/odoo/addons/base/views/res_users_views.xml index f75ba79e3ef..ae30ce89dff 100644 --- a/odoo/addons/base/views/res_users_views.xml +++ b/odoo/addons/base/views/res_users_views.xml @@ -354,7 +354,7 @@ - +