[MERGE] mail, hr_holidays, *: add out of office status
*: base, bus, mail_bot, test_mail Before this task, it was not easy to tell when a user is not available (or is on holiday) from conversations, such as from chat windows. With this task, a user can now tell that he is out-of-office from the user menu preferences, by defining an an out-of-office message. This information will be displayed to other users from chat conversations and mention suggestions (i.e. with `@`). Also, the computation of the user `im_status` has been changed when he has an active leave: an out-of-office message can be set on the leave, and this will be displayed on conversations instead of the user's out-of-office message from the user menu preferences. Task: 1856205 closes odoo/odoo#29974
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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
|
||||
from . import mail_channel
|
||||
from . import mail_message_subtype
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()]
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-extend="mail.UserStatus">
|
||||
<t t-jquery="i:last" t-operation="after">
|
||||
<i t-if="status == 'leave_online'" class="o_mail_user_status o_user_online fa fa-plane" title="Online" role="img" aria-label="User is online"/>
|
||||
<i t-if="status == 'leave_offline'" class="o_mail_user_status o_user_idle fa fa-plane" title="Out of office" role="img" aria-label="User is out of office"/>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -246,7 +246,7 @@
|
||||
</div>
|
||||
|
||||
<field name="name" attrs="{'readonly': [('state', 'not in', ('draft', 'confirm'))]}"/>
|
||||
|
||||
<field name="out_of_office_message"/>
|
||||
</group>
|
||||
<group name="col_right">
|
||||
<field name="employee_id" invisible="1"/>
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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 $('<span>').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();
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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 "".
|
||||
|
||||
@@ -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.)
|
||||
*
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
}
|
||||
.fa {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
resize: none;
|
||||
|
||||
@@ -252,6 +252,11 @@
|
||||
&.o_user_idle {
|
||||
color: theme-color('warning');
|
||||
}
|
||||
&.fa-stack {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
line-height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
</t>
|
||||
<t t-name="mail.MentionSuggestions.Partner" t-extend="mail.MentionSuggestions">
|
||||
<t t-jquery=".o_mention_proposition" t-operation="append">
|
||||
<t t-call="mail.UserStatus">
|
||||
<t t-set="status" t-value="suggestion.im_status"/>
|
||||
<t t-set="partnerID" t-value="suggestion.id"/>
|
||||
</t>
|
||||
<span class="o_mention_name"><t t-esc="suggestion.name"/></span>
|
||||
<t t-if="suggestion.email">
|
||||
<span class="o_mention_info">(<t t-esc="suggestion.email"/>)</span>
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
<span t-if="displayStatus">
|
||||
<t t-call="mail.UserStatus">
|
||||
<t t-set="status" t-value="channel.getStatus()"/>
|
||||
<t t-set="partnerID" t-value="channel.getDirectPartnerID()"/>
|
||||
</t>
|
||||
</span>
|
||||
<span t-if="displayHash" class="o_mail_hash">#</span>
|
||||
@@ -176,11 +177,14 @@
|
||||
|
||||
<!--
|
||||
@param {string} status
|
||||
@param {integer|undefined} [partnerID]
|
||||
-->
|
||||
<t t-name="mail.UserStatus">
|
||||
<i t-if="status == 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
|
||||
<i t-if="status == 'away'" class="o_mail_user_status o_user_idle fa fa-circle" title="Idle" role="img" aria-label="User is idle"/>
|
||||
<i t-if="status == 'offline'" class="o_mail_user_status fa fa-circle-o" title="Offline" role="img" aria-label="User is offline"/>
|
||||
<span t-att-class="partnerID ? 'o_updatable_im_status' : ''" t-att-data-partner-id="partnerID">
|
||||
<i t-if="status == 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
|
||||
<i t-if="status == 'away'" class="o_mail_user_status o_user_idle fa fa-circle" title="Idle" role="img" aria-label="User is idle"/>
|
||||
<i t-if="status == 'offline'" class="o_mail_user_status fa fa-circle" title="Offline" role="img" aria-label="User is offline"/>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@@ -300,8 +304,9 @@
|
||||
t-att-data-preview-id="preview.id" t-att-data-document-id="preview.documentID" t-att-data-document-model="preview.documentModel" t-att-data-unread-counter="preview.unreadCounter" t-att-data-message-ids="preview.messageIDs">
|
||||
<div t-attf-class="o_mail_preview_image #{preview.documentModel ? 'o_mail_preview_app' : ''}">
|
||||
<img class="o_mail_preview_image" t-att-src="preview.imageSRC" alt="Preview"/>
|
||||
<i t-if="preview.status === 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
|
||||
<i t-if="preview.status === 'away'" class="o_mail_user_status o_user_idle fa fa-circle" title="Idle" role="img" aria-label="User is idle"/>
|
||||
<t t-call="mail.UserStatus">
|
||||
<t t-set="status" t-value="preview.status"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_preview_info">
|
||||
<div class="o_preview_title">
|
||||
|
||||
@@ -258,12 +258,18 @@
|
||||
<div t-att-class="'o_thread_message ' + (message.getID() === options.selectedMessageID ? 'o_thread_selected_message ' : ' ') + (message.isDiscussion() or message.isNotification() ? ' o_mail_discussion ' : ' o_mail_not_discussion ')" t-att-data-message-id="message.getID()">
|
||||
<div t-if="options.displayAvatars" class="o_thread_message_sidebar">
|
||||
<t t-if="message.hasAuthor()">
|
||||
<img t-if="displayAuthorMessages[message.getID()]"
|
||||
alt=""
|
||||
t-att-src="message.getAvatarSource()"
|
||||
data-oe-model="res.partner"
|
||||
t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
|
||||
t-attf-class="o_thread_message_avatar rounded-circle #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}"/>
|
||||
<div t-if="displayAuthorMessages[message.getID()]" class="o_thread_message_sidebar_image">
|
||||
<img
|
||||
alt=""
|
||||
t-att-src="message.getAvatarSource()"
|
||||
data-oe-model="res.partner"
|
||||
t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
|
||||
t-attf-class="o_thread_message_avatar rounded-circle #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}"/>
|
||||
<t t-call="mail.UserStatus">
|
||||
<t t-set="status" t-value="message.getAuthorImStatus()"/>
|
||||
<t t-set="partnerID" t-value="message.getAuthorID()"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<img t-if="displayAuthorMessages[message.getID()]"
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
-->
|
||||
<t t-name="mail.ThreadWindow" t-extend="mail.AbstractThreadWindow">
|
||||
<t t-jquery=".o_thread_window_header" t-operation="after">
|
||||
<div class="o_out_of_office" t-if="widget.getOutOfOfficeInfo() or widget.getOutOfOfficeMessage()">
|
||||
<div class="o_out_of_office_info" t-if="widget.getOutOfOfficeInfo()" t-esc="widget.getOutOfOfficeInfo()"/>
|
||||
<div class="o_out_of_office_text o_text_wrap" t-if="widget.getOutOfOfficeMessage()">
|
||||
<t t-esc="widget.getOutOfOfficeMessage()"/>
|
||||
<a href="#" class="o_out_of_office_read_more_less_button">Read more</a>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="!widget.hasThread()" class="o_thread_search_input">
|
||||
<span> To: </span>
|
||||
<input type="text" placeholder="User name"/>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-extend="UserMenu">
|
||||
<t t-jquery=".oe_topbar_name" t-operation="before">
|
||||
<t t-if="widget.outOfOfficeMessage">
|
||||
<i class="o_mail_user_status o_user_idle fa fa-minus-circle" title="You have set an out of office message."/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- Services -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/mail_manager.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/mail_document_thread_manager.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/mail_status_manager.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/mail_notification_manager.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/mail_window_manager.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/services/mail_service.js"></script>
|
||||
@@ -65,6 +66,7 @@
|
||||
<script type="text/javascript" src="/mail/static/src/js/systray/systray_activity_menu.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/systray/systray_messaging_menu.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/tour.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/user_menu.js"></script>
|
||||
<!-- tools -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/tools/debug_manager.js"></script>
|
||||
<!-- utils -->
|
||||
@@ -129,8 +131,10 @@
|
||||
<script type="text/javascript" src="/mail/static/tests/models/cc_throttle_function_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/models/timer_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/models/timers_tests.js"></script>
|
||||
<!-- activity view -->
|
||||
<!-- activity view -->
|
||||
<script type="text/javascript" src="/mail/static/tests/activity_tests.js"></script>
|
||||
<!-- services -->
|
||||
<script type="text/javascript" src="/mail/static/tests/mail_status_manager_tests.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
<field name="email" position="before">
|
||||
<field name="notification_type" widget="radio" readonly="0"/>
|
||||
</field>
|
||||
<xpath expr="//group[@name='signature']" position="after">
|
||||
<group string="Out of office" name="out of office">
|
||||
<field name="out_of_office_message" readonly="0"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</data>
|
||||
</field>
|
||||
</record>
|
||||
@@ -33,6 +38,5 @@
|
||||
</data>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-extend="mail.Preview">
|
||||
<t t-jquery="div[t-attf-class*='o_mail_preview_image']" t-operation="append">
|
||||
<i t-if="preview.status == 'bot'" class="o_mail_user_status o_user_online fa fa-heart" title="Bot" role="img" aria-label="User is a bot"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-extend="mail.UserStatus">
|
||||
<t t-jquery="i:last" t-operation="after">
|
||||
<i t-if="status == 'bot'" class="o_mail_user_status o_user_online fa fa-heart" title="Bot" role="img" aria-label="User is a bot"/>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -354,7 +354,7 @@
|
||||
</group>
|
||||
<group name="preference_contact"></group>
|
||||
</group>
|
||||
<group>
|
||||
<group name="signature">
|
||||
<field name="signature" readonly="0"/>
|
||||
</group>
|
||||
<footer>
|
||||
|
||||
Reference in New Issue
Block a user