Browse Source
[IMP] bus_presence_override: Add new features.
[IMP] bus_presence_override: Add new features.
* Add caching so status change in 1 tab affects other open tabs. * Add automatic polling to update status if away or disconnection timers hitpull/382/head
Brett Wood
7 years ago
committed by
Holger Brunn
19 changed files with 481 additions and 268 deletions
-
1bus_presence_override/__init__.py
-
4bus_presence_override/__manifest__.py
-
5bus_presence_override/controllers/__init__.py
-
21bus_presence_override/controllers/main.py
-
2bus_presence_override/models/__init__.py
-
88bus_presence_override/models/bus_presence.py
-
76bus_presence_override/models/res_partner.py
-
21bus_presence_override/models/res_users.py
-
106bus_presence_override/static/src/js/bus_presence_systray.js
-
59bus_presence_override/static/src/js/systray.js
-
21bus_presence_override/static/src/less/bus_presence_systray.less
-
47bus_presence_override/static/src/less/systray.less
-
25bus_presence_override/static/src/xml/bus_presence_systray.xml
-
2bus_presence_override/tests/__init__.py
-
28bus_presence_override/tests/bus_setup.py
-
120bus_presence_override/tests/test_bus_presence.py
-
91bus_presence_override/tests/test_res_partner.py
-
28bus_presence_override/tests/test_res_users.py
-
4bus_presence_override/views/assets.xml
@ -0,0 +1,5 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2017 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from . import main |
@ -0,0 +1,21 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2017 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from odoo.http import request |
||||
|
from odoo.addons.bus.controllers.main import BusController |
||||
|
|
||||
|
|
||||
|
class BusController(BusController): |
||||
|
|
||||
|
def _poll(self, dbname, channels, last, options): |
||||
|
if request.uid: |
||||
|
partner = request.env.user.partner_id |
||||
|
if 'bus_presence_partner_ids' in options: |
||||
|
options['bus_presence_partner_ids'].append(partner.id) |
||||
|
else: |
||||
|
options['bus_presence_partner_ids'] = [partner.id] |
||||
|
|
||||
|
return super(BusController, self)._poll( |
||||
|
dbname, channels, last, options, |
||||
|
) |
@ -0,0 +1,88 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2017 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from datetime import datetime |
||||
|
|
||||
|
from odoo import api, fields, models |
||||
|
from odoo.addons.bus.models.bus_presence import AWAY_TIMER, DISCONNECTION_TIMER |
||||
|
|
||||
|
from ..status_constants import ONLINE, AWAY, OFFLINE |
||||
|
|
||||
|
|
||||
|
class BusPresence(models.Model): |
||||
|
|
||||
|
_inherit = 'bus.presence' |
||||
|
|
||||
|
status_realtime = fields.Selection( |
||||
|
selection=[ |
||||
|
(ONLINE, 'Online'), |
||||
|
(AWAY, 'Away'), |
||||
|
(OFFLINE, 'Offline') |
||||
|
], |
||||
|
string='Realtime IM Status', |
||||
|
compute='_compute_status_realtime', |
||||
|
help='Status that is affected by disconnection ' |
||||
|
'and away timers. Used to override the bus.presence ' |
||||
|
'status field in _get_partners_statuses or ' |
||||
|
'_get_users_statuses if the timers have been reached. ' |
||||
|
'If wanting to change the user status, write ' |
||||
|
'directly to the status field.', |
||||
|
) |
||||
|
partner_id = fields.Many2one( |
||||
|
string='Partner', |
||||
|
related='user_id.partner_id', |
||||
|
comodel_name='res.partner', |
||||
|
) |
||||
|
|
||||
|
@api.multi |
||||
|
def _get_partners_statuses(self): |
||||
|
self._status_check_disconnection_and_away_timers() |
||||
|
return {rec.partner_id.id: rec.status for rec in self} |
||||
|
|
||||
|
@api.multi |
||||
|
def _get_users_statuses(self): |
||||
|
self._status_check_disconnection_and_away_timers() |
||||
|
return {rec.user_id.id: rec.status for rec in self} |
||||
|
|
||||
|
@api.multi |
||||
|
def _status_check_disconnection_and_away_timers(self): |
||||
|
""" Overrides user-defined status if timers reached """ |
||||
|
for record in self: |
||||
|
|
||||
|
status_realtime = record.status_realtime |
||||
|
status_stored = record.status |
||||
|
|
||||
|
conditions = ( |
||||
|
status_realtime == OFFLINE, |
||||
|
status_realtime == AWAY and status_stored == ONLINE, |
||||
|
) |
||||
|
|
||||
|
if any(conditions): |
||||
|
record.status = status_realtime |
||||
|
|
||||
|
@api.multi |
||||
|
def _compute_status_realtime(self): |
||||
|
|
||||
|
now_dt = datetime.now() |
||||
|
|
||||
|
for record in self: |
||||
|
|
||||
|
last_poll = fields.Datetime.from_string( |
||||
|
record.last_poll |
||||
|
) |
||||
|
last_presence = fields.Datetime.from_string( |
||||
|
record.last_presence |
||||
|
) |
||||
|
|
||||
|
last_poll_s = (now_dt - last_poll).total_seconds() |
||||
|
last_presence_s = (now_dt - last_presence).total_seconds() |
||||
|
|
||||
|
if last_poll_s > DISCONNECTION_TIMER: |
||||
|
record.status_realtime = OFFLINE |
||||
|
|
||||
|
elif last_presence_s > AWAY_TIMER: |
||||
|
record.status_realtime = AWAY |
||||
|
|
||||
|
else: |
||||
|
record.status_realtime = ONLINE |
@ -0,0 +1,21 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2017 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from odoo import api, models |
||||
|
|
||||
|
from ..status_constants import OFFLINE |
||||
|
|
||||
|
|
||||
|
class ResUsers(models.Model): |
||||
|
|
||||
|
_inherit = 'res.users' |
||||
|
|
||||
|
@api.multi |
||||
|
def _compute_im_status(self): |
||||
|
bus_recs = self.env['bus.presence'].search([ |
||||
|
('user_id', 'in', self.ids), |
||||
|
]) |
||||
|
statuses = bus_recs._get_users_statuses() |
||||
|
for record in self: |
||||
|
record.im_status = statuses.get(record.id, OFFLINE) |
@ -0,0 +1,106 @@ |
|||||
|
/* Copyright 2017 LasLabs Inc. |
||||
|
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */
|
||||
|
|
||||
|
odoo.define('bus_presence_systray', function (require) { |
||||
|
"use strict"; |
||||
|
|
||||
|
var Bus = require('bus.bus').bus; |
||||
|
var DataModel = require('web.DataModel'); |
||||
|
var Session = require('web.session'); |
||||
|
var SystrayMenu = require('web.SystrayMenu'); |
||||
|
var Widget = require('web.Widget'); |
||||
|
var Qweb = require('web.core').qweb; |
||||
|
var LocalStorage = require('web.local_storage'); |
||||
|
|
||||
|
function on(type, listener) { |
||||
|
if (window.addEventListener) { |
||||
|
window.addEventListener(type, listener); |
||||
|
} else { |
||||
|
// IE8
|
||||
|
window.attachEvent('on' + type, listener); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
var BusPresenceSystray = Widget.extend({ |
||||
|
template: 'bus_presence_systray', |
||||
|
events: { |
||||
|
'click .o-user-status-select': 'onClickUserStatusSelect', |
||||
|
}, |
||||
|
init: function() { |
||||
|
this._super.apply(this, arguments); |
||||
|
this.resPartnerMod = new DataModel('res.partner'); |
||||
|
this.busPresenceMod = new DataModel('bus.presence'); |
||||
|
Bus.on('notification', this, _.throttle(this.notificationsUpdateCurrentUserStatus.bind(this), 100, {leading: false})); |
||||
|
on('storage', this.onStorage.bind(this)); |
||||
|
}, |
||||
|
start: function () { |
||||
|
this.startDetermineUserStatus(); |
||||
|
Bus.start_polling(); |
||||
|
return this._super(); |
||||
|
}, |
||||
|
startDetermineUserStatus: function () { |
||||
|
if (Bus.is_master === true) { |
||||
|
this.writeBusPresenceStatus('online'); |
||||
|
this.updateUserStatusIcon('online'); |
||||
|
LocalStorage.setItem('user.partner_im_status', 'online'); |
||||
|
} else { |
||||
|
var statusVal = LocalStorage.getItem('user.partner_im_status'); |
||||
|
this.updateUserStatusIcon(statusVal); |
||||
|
} |
||||
|
}, |
||||
|
onStorage: function (event) { |
||||
|
if (event.key === 'user.partner_im_status') { |
||||
|
this.updateUserStatusIcon(event.newValue); |
||||
|
} |
||||
|
}, |
||||
|
notificationsUpdateCurrentUserStatus: function (notifications) { |
||||
|
_.each(notifications, $.proxy( |
||||
|
function (notification) { |
||||
|
var model = notification[0][1]; |
||||
|
var partnerId = notification[1].id; |
||||
|
if (model === 'bus.presence' && partnerId === Session.partner_id) { |
||||
|
var status = notification[1].im_status; |
||||
|
this.updateUserStatusIcon(status); |
||||
|
LocalStorage.setItem('user.partner_im_status', status); |
||||
|
} |
||||
|
}, this) |
||||
|
); |
||||
|
}, |
||||
|
queryUpdateCurrentUserStatus: function () { |
||||
|
this.resPartnerMod.query(['im_status']) |
||||
|
.filter([['id', '=', Session.partner_id]]) |
||||
|
.first() |
||||
|
.then($.proxy( |
||||
|
function (result) { |
||||
|
this.updateUserStatusIcon(result.im_status); |
||||
|
LocalStorage.setItem('user.partner_im_status', status); |
||||
|
}, this) |
||||
|
); |
||||
|
}, |
||||
|
updateUserStatusIcon: function (status) { |
||||
|
var options = {'status': status}; |
||||
|
var $icon = this.$('.o-user-systray-status'); |
||||
|
$icon.empty().append($(Qweb.render('mail.chat.UserStatus', options))); |
||||
|
}, |
||||
|
onClickUserStatusSelect: function (event) { |
||||
|
var status = $(event.currentTarget).attr('name'); |
||||
|
this.updateUserStatusIcon(status); |
||||
|
this.writeBusPresenceStatus(status); |
||||
|
LocalStorage.setItem('user.partner_im_status', status); |
||||
|
}, |
||||
|
writeBusPresenceStatus: function (status) { |
||||
|
this.busPresenceMod.query(['id']) |
||||
|
.filter([['partner_id', '=', Session.partner_id]]) |
||||
|
.first() |
||||
|
.then($.proxy( |
||||
|
function (result) { |
||||
|
this.busPresenceMod.call('write', [[result.id], {'status': status}]); |
||||
|
}, this) |
||||
|
); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
SystrayMenu.Items.push(BusPresenceSystray); |
||||
|
|
||||
|
}); |
||||
|
|
@ -1,59 +0,0 @@ |
|||||
/* Copyright 2017 LasLabs Inc. |
|
||||
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */
|
|
||||
|
|
||||
odoo.define('bus_presence_override.systray', function (require) { |
|
||||
"use strict"; |
|
||||
|
|
||||
var DataModel = require('web.DataModel'); |
|
||||
var session = require('web.session'); |
|
||||
var SystrayMenu = require('web.SystrayMenu'); |
|
||||
var Widget = require('web.Widget'); |
|
||||
|
|
||||
var Systray = Widget.extend({ |
|
||||
template:'systray', |
|
||||
events: { |
|
||||
'click .o_user_presence_status': 'on_click_user_presence_status', |
|
||||
}, |
|
||||
init: function() { |
|
||||
this._super.apply(this, arguments); |
|
||||
this.Partners = new DataModel('res.partner'); |
|
||||
this.status_icons = { |
|
||||
'online': 'fa fa-circle o_user_online', |
|
||||
'away': 'fa fa-circle o_user_idle', |
|
||||
'offline': 'fa fa-circle-o', |
|
||||
} |
|
||||
}, |
|
||||
start: function () { |
|
||||
this._update_im_status_custom(status='online'); |
|
||||
return this._super() |
|
||||
}, |
|
||||
on_click_user_presence_status: function (event) { |
|
||||
var status = $(event.target).attr('name'); |
|
||||
this._update_im_status_custom(status); |
|
||||
}, |
|
||||
_get_im_status: function () { |
|
||||
var self = this; |
|
||||
this.Partners.query(['im_status']) |
|
||||
.filter([['id', '=', session.partner_id]]) |
|
||||
.first() |
|
||||
.then(function (result) { |
|
||||
self._update_systray_status_icon(result['im_status']); |
|
||||
}); |
|
||||
}, |
|
||||
_update_systray_status_icon: function (status) { |
|
||||
$('#userStatus i').removeClass() |
|
||||
.addClass('o_mail_user_status ' + this.status_icons[status]); |
|
||||
}, |
|
||||
_update_im_status_custom: function (status) { |
|
||||
var self = this; |
|
||||
this.Partners.call('write', [[session.partner_id], {'im_status_custom': status}]) |
|
||||
.then(function () { |
|
||||
self._update_systray_status_icon(status); |
|
||||
}); |
|
||||
}, |
|
||||
|
|
||||
}); |
|
||||
|
|
||||
SystrayMenu.Items.push(Systray); |
|
||||
|
|
||||
}); |
|
@ -0,0 +1,21 @@ |
|||||
|
/* Copyright 2017 LasLabs Inc. |
||||
|
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */ |
||||
|
|
||||
|
.o-user-systray-status { |
||||
|
i { |
||||
|
margin-top: 2px; |
||||
|
&:hover { |
||||
|
color: @gray-lighter; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
.o-user-presence-dropdown { |
||||
|
min-width: 85px; |
||||
|
.o-user-status-select { |
||||
|
margin-top: 2px; |
||||
|
padding: 4px 8px; |
||||
|
width: 100%; |
||||
|
display: block; |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
} |
@ -1,47 +0,0 @@ |
|||||
/* Copyright 2017 LasLabs Inc. |
|
||||
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */ |
|
||||
|
|
||||
#userStatus { |
|
||||
&:hover i { |
|
||||
color: #d3d3d3 !important; |
|
||||
} |
|
||||
|
|
||||
i { |
|
||||
padding-top: 2px; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
.o_user_presence_dropdown { |
|
||||
min-width: 85px; |
|
||||
|
|
||||
.o_user_presence_status { |
|
||||
margin-top: 2px; |
|
||||
padding: 4px 8px; |
|
||||
width: 100%; |
|
||||
display: block; |
|
||||
font-size: 13px; |
|
||||
} |
|
||||
|
|
||||
&:hover { |
|
||||
cursor: pointer; |
|
||||
} |
|
||||
|
|
||||
} |
|
||||
|
|
||||
@media (max-width: @screen-xs-max) { |
|
||||
|
|
||||
.o_user_presence_status { |
|
||||
color: #9d9d9d; |
|
||||
&:hover { |
|
||||
color: #ffffff; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@media (min-width: @screen-sm-min) { |
|
||||
|
|
||||
.o_user_presence_status:hover { |
|
||||
background-color: #e0e0e0; |
|
||||
} |
|
||||
|
|
||||
} |
|
@ -0,0 +1,28 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2017 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from odoo.tests.common import TransactionCase |
||||
|
|
||||
|
|
||||
|
class BusSetup(TransactionCase): |
||||
|
|
||||
|
def setUp(self): |
||||
|
super(BusSetup, self).setUp() |
||||
|
self.u_admin = self.env.ref('base.user_root') |
||||
|
self.p_admin = self.u_admin.partner_id |
||||
|
|
||||
|
self.u_demo = self.env.ref('base.user_demo') |
||||
|
self.p_demo = self.u_demo.partner_id |
||||
|
|
||||
|
self.pres_admin = self._get_bus_presence(self.u_admin) |
||||
|
self.pres_demo = self._get_bus_presence(self.u_demo) |
||||
|
|
||||
|
# AWAY_TIMER = 55 seconds |
||||
|
# DISCONNECTION_TIMER = 1800 seconds (30 minutes) |
||||
|
|
||||
|
def _get_bus_presence(self, user): |
||||
|
pres = self.env['bus.presence'].search([('user_id', '=', user.id)]) |
||||
|
if not pres: |
||||
|
pres = self.env['bus.presence'].create({'user_id': user.id}) |
||||
|
return pres |
@ -0,0 +1,120 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2017 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from mock import patch |
||||
|
|
||||
|
from .bus_setup import BusSetup |
||||
|
from ..status_constants import ONLINE, AWAY, OFFLINE |
||||
|
|
||||
|
AWAY_TIMER = 'odoo.addons.bus_presence_override.models.' \ |
||||
|
'bus_presence.AWAY_TIMER' |
||||
|
|
||||
|
DISCONNECTION_TIMER = 'odoo.addons.bus_presence_override.models.' \ |
||||
|
'bus_presence.DISCONNECTION_TIMER' |
||||
|
|
||||
|
|
||||
|
class TestBusPresence(BusSetup): |
||||
|
|
||||
|
@patch(AWAY_TIMER, 10000000) |
||||
|
@patch(DISCONNECTION_TIMER, 10000000) |
||||
|
def test_compute_status_realtime_online(self): |
||||
|
""" It should be computed to online """ |
||||
|
self.assertEquals( |
||||
|
self.pres_admin.status_realtime, |
||||
|
ONLINE, |
||||
|
) |
||||
|
|
||||
|
@patch(AWAY_TIMER, 10000000) |
||||
|
@patch(DISCONNECTION_TIMER, 0) |
||||
|
def test_compute_status_realtime_offline(self): |
||||
|
""" It should be computed to offline """ |
||||
|
self.assertEquals( |
||||
|
self.pres_admin.status_realtime, |
||||
|
OFFLINE, |
||||
|
) |
||||
|
|
||||
|
@patch(AWAY_TIMER, 0) |
||||
|
@patch(DISCONNECTION_TIMER, 10000000) |
||||
|
def test_compute_status_realtime_away(self): |
||||
|
""" It should be computed to away """ |
||||
|
self.assertEquals( |
||||
|
self.pres_admin.status_realtime, |
||||
|
AWAY, |
||||
|
) |
||||
|
|
||||
|
@patch(AWAY_TIMER, 0) |
||||
|
@patch(DISCONNECTION_TIMER, 0) |
||||
|
def test_compute_status_realtime_both(self): |
||||
|
""" It should be computed to offline even though away as well """ |
||||
|
self.assertEquals( |
||||
|
self.pres_admin.status_realtime, |
||||
|
OFFLINE, |
||||
|
) |
||||
|
|
||||
|
@patch(AWAY_TIMER, 0) |
||||
|
@patch(DISCONNECTION_TIMER, 0) |
||||
|
def test_status_check_timers_offline(self): |
||||
|
""" It should be changed to offline from online """ |
||||
|
self.pres_admin.status = ONLINE |
||||
|
self.assertEquals( |
||||
|
self.pres_admin.status, |
||||
|
ONLINE, |
||||
|
) |
||||
|
self.pres_admin._status_check_disconnection_and_away_timers() |
||||
|
self.assertEquals( |
||||
|
self.pres_admin.status, |
||||
|
OFFLINE, |
||||
|
) |
||||
|
|
||||
|
@patch(AWAY_TIMER, 0) |
||||
|
@patch(DISCONNECTION_TIMER, 10000000) |
||||
|
def test_status_check_timers_away(self): |
||||
|
""" It should be changed to away from online """ |
||||
|
self.pres_admin.status = ONLINE |
||||
|
self.assertEquals( |
||||
|
self.pres_admin.status, |
||||
|
ONLINE, |
||||
|
) |
||||
|
self.pres_admin._status_check_disconnection_and_away_timers() |
||||
|
self.assertEquals( |
||||
|
self.pres_admin.status, |
||||
|
AWAY, |
||||
|
) |
||||
|
|
||||
|
@patch(AWAY_TIMER, 0) |
||||
|
@patch(DISCONNECTION_TIMER, 10000000) |
||||
|
def test_status_check_timers_unchanged(self): |
||||
|
""" It should remain at offline even if status_realtime away """ |
||||
|
self.pres_admin.status = OFFLINE |
||||
|
self.assertEquals( |
||||
|
self.pres_admin.status, |
||||
|
OFFLINE, |
||||
|
) |
||||
|
self.pres_admin._status_check_disconnection_and_away_timers() |
||||
|
self.assertEquals( |
||||
|
self.pres_admin.status, |
||||
|
OFFLINE, |
||||
|
) |
||||
|
|
||||
|
def test_get_partners_im_statuses(self): |
||||
|
""" It should include demo and admin partner statuses """ |
||||
|
recs = self.env['bus.presence'].search([( |
||||
|
'partner_id', 'in', [self.p_admin.id, self.p_demo.id])] |
||||
|
) |
||||
|
statuses = recs._get_partners_statuses() |
||||
|
self.assertIn( |
||||
|
self.p_admin.id, |
||||
|
statuses, |
||||
|
) |
||||
|
|
||||
|
def test_get_users_im_statuses(self): |
||||
|
""" It should include demo and admin user statuses """ |
||||
|
recs = self.env['bus.presence'].search([( |
||||
|
'user_id', 'in', [self.u_admin.id, self.u_demo.id])] |
||||
|
) |
||||
|
statuses = recs._get_users_statuses() |
||||
|
self.assertIn( |
||||
|
self.u_admin.id, |
||||
|
statuses, |
||||
|
) |
@ -0,0 +1,28 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2017 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from mock import patch |
||||
|
|
||||
|
from .bus_setup import BusSetup |
||||
|
from ..status_constants import ONLINE |
||||
|
|
||||
|
|
||||
|
AWAY_TIMER = 'odoo.addons.bus_presence_override.models.' \ |
||||
|
'bus_presence.AWAY_TIMER' |
||||
|
|
||||
|
DISCONNECTION_TIMER = 'odoo.addons.bus_presence_override.models.' \ |
||||
|
'bus_presence.DISCONNECTION_TIMER' |
||||
|
|
||||
|
|
||||
|
class TestResUsers(BusSetup): |
||||
|
|
||||
|
@patch(AWAY_TIMER, 10000000) |
||||
|
@patch(DISCONNECTION_TIMER, 10000000) |
||||
|
def test_compute_im_status_online(self): |
||||
|
""" It should be computed to online """ |
||||
|
self.pres_admin.status = ONLINE |
||||
|
self.assertEquals( |
||||
|
self.u_admin.im_status, |
||||
|
ONLINE, |
||||
|
) |
Write
Preview
Loading…
Cancel
Save
Reference in new issue