Restore backward compatibility for init client
After merged the patch I08c8b753972c27b4e6bbe07a8aa51e0e72fbc56d, blazarclient can't be used with blazar_url and auth_token since this patch only allows sessions for the initiation of client. For backward compatibility, blazarclient should be initiated with blazar_url and auth_token as before. This patch enables using blazarclient with a set of blazar_url and auth_token or session by reviving BaseClientManager class with adding a logic to chose an auth method based on given params. Change-Id: I25a665145b0503cc04e49bc85c39e2f6dca36925 Closes-Bug: #1724757
This commit is contained in:
parent
8d897c5220
commit
2afa290302
|
@ -0,0 +1,135 @@
|
||||||
|
# Copyright (c) 2013 Mirantis Inc.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
# implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from keystoneauth1 import adapter
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from blazarclient import exception
|
||||||
|
from blazarclient.i18n import _
|
||||||
|
|
||||||
|
|
||||||
|
class RequestManager(object):
|
||||||
|
"""Manager to create request from given Blazar URL and auth token."""
|
||||||
|
|
||||||
|
def __init__(self, blazar_url, auth_token, user_agent):
|
||||||
|
self.blazar_url = blazar_url
|
||||||
|
self.auth_token = auth_token
|
||||||
|
self.user_agent = user_agent
|
||||||
|
|
||||||
|
def get(self, url):
|
||||||
|
"""Sends get request to Blazar.
|
||||||
|
|
||||||
|
:param url: URL to the wanted Blazar resource.
|
||||||
|
:type url: str
|
||||||
|
"""
|
||||||
|
return self.request(url, 'GET')
|
||||||
|
|
||||||
|
def post(self, url, body):
|
||||||
|
"""Sends post request to Blazar.
|
||||||
|
|
||||||
|
:param url: URL to the wanted Blazar resource.
|
||||||
|
:type url: str
|
||||||
|
|
||||||
|
:param body: Values resource to be created from.
|
||||||
|
:type body: dict
|
||||||
|
"""
|
||||||
|
return self.request(url, 'POST', body=body)
|
||||||
|
|
||||||
|
def delete(self, url):
|
||||||
|
"""Sends delete request to Blazar.
|
||||||
|
|
||||||
|
:param url: URL to the wanted Blazar resource.
|
||||||
|
:type url: str
|
||||||
|
"""
|
||||||
|
return self.request(url, 'DELETE')
|
||||||
|
|
||||||
|
def put(self, url, body):
|
||||||
|
"""Sends update request to Blazar.
|
||||||
|
|
||||||
|
:param url: URL to the wanted Blazar resource.
|
||||||
|
:type url: str
|
||||||
|
|
||||||
|
:param body: Values resource to be updated from.
|
||||||
|
:type body: dict
|
||||||
|
"""
|
||||||
|
return self.request(url, 'PUT', body=body)
|
||||||
|
|
||||||
|
def request(self, url, method, **kwargs):
|
||||||
|
"""Base request method.
|
||||||
|
|
||||||
|
Adds specific headers and URL prefix to the request.
|
||||||
|
|
||||||
|
:param url: Resource URL.
|
||||||
|
:type url: str
|
||||||
|
|
||||||
|
:param method: Method to be called (GET, POST, PUT, DELETE).
|
||||||
|
:type method: str
|
||||||
|
|
||||||
|
:returns: Response and body.
|
||||||
|
:rtype: tuple
|
||||||
|
"""
|
||||||
|
kwargs.setdefault('headers', kwargs.get('headers', {}))
|
||||||
|
kwargs['headers']['User-Agent'] = self.user_agent
|
||||||
|
kwargs['headers']['Accept'] = 'application/json'
|
||||||
|
kwargs['headers']['x-auth-token'] = self.auth_token
|
||||||
|
|
||||||
|
if 'body' in kwargs:
|
||||||
|
kwargs['headers']['Content-Type'] = 'application/json'
|
||||||
|
kwargs['data'] = json.dumps(kwargs['body'])
|
||||||
|
del kwargs['body']
|
||||||
|
|
||||||
|
resp = requests.request(method, self.blazar_url + url, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = json.loads(resp.text)
|
||||||
|
except ValueError:
|
||||||
|
body = None
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
if body is not None:
|
||||||
|
error_message = body.get('error_message', body)
|
||||||
|
else:
|
||||||
|
error_message = resp.text
|
||||||
|
|
||||||
|
body = _("ERROR: {0}").format(error_message)
|
||||||
|
raise exception.BlazarClientException(body, code=resp.status_code)
|
||||||
|
|
||||||
|
return resp, body
|
||||||
|
|
||||||
|
|
||||||
|
class BaseClientManager(object):
|
||||||
|
"""Base class for managing resources of Blazar."""
|
||||||
|
|
||||||
|
user_agent = 'python-blazarclient'
|
||||||
|
|
||||||
|
def __init__(self, blazar_url, auth_token, session, **kwargs):
|
||||||
|
self.blazar_url = blazar_url
|
||||||
|
self.auth_token = auth_token
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
if self.session:
|
||||||
|
self.request_manager = adapter.LegacyJsonAdapter(
|
||||||
|
session=self.session,
|
||||||
|
user_agent=self.user_agent,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
elif self.blazar_url and self.auth_token:
|
||||||
|
self.request_manager = RequestManager(blazar_url=self.blazar_url,
|
||||||
|
auth_token=self.auth_token,
|
||||||
|
user_agent=self.user_agent)
|
||||||
|
else:
|
||||||
|
raise exception.InsufficientAuthInfomation
|
|
@ -82,3 +82,11 @@ class DuplicatedLeaseParameters(BlazarClientException):
|
||||||
"""Occurs if lease parameters are duplicated."""
|
"""Occurs if lease parameters are duplicated."""
|
||||||
message = _("The lease parameters are duplicated.")
|
message = _("The lease parameters are duplicated.")
|
||||||
code = 400
|
code = 400
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientAuthInfomation(BlazarClientException):
|
||||||
|
"""Occurs if the auth info passed to blazar client is insufficient."""
|
||||||
|
message = _("The passed arguments are insufficient "
|
||||||
|
"for the authentication. The instance of "
|
||||||
|
"keystoneauth1.session.Session class is required.")
|
||||||
|
code = 400
|
||||||
|
|
|
@ -0,0 +1,162 @@
|
||||||
|
# Copyright (c) 2014 Mirantis Inc.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
# implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
import mock
|
||||||
|
|
||||||
|
from keystoneauth1 import adapter
|
||||||
|
|
||||||
|
from blazarclient import base
|
||||||
|
from blazarclient import exception
|
||||||
|
from blazarclient import tests
|
||||||
|
|
||||||
|
|
||||||
|
class RequestManagerTestCase(tests.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(RequestManagerTestCase, self).setUp()
|
||||||
|
|
||||||
|
self.blazar_url = "www.fake.com/reservation"
|
||||||
|
self.auth_token = "aaa-bbb-ccc"
|
||||||
|
self.user_agent = "python-blazarclient"
|
||||||
|
self.manager = base.RequestManager(blazar_url=self.blazar_url,
|
||||||
|
auth_token=self.auth_token,
|
||||||
|
user_agent=self.user_agent)
|
||||||
|
|
||||||
|
@mock.patch('blazarclient.base.RequestManager.request',
|
||||||
|
return_value=(200, {"fake": "FAKE"}))
|
||||||
|
def test_get(self, m):
|
||||||
|
url = '/leases'
|
||||||
|
resp, body = self.manager.get(url)
|
||||||
|
self.assertEqual(resp, 200)
|
||||||
|
self.assertDictEqual(body, {"fake": "FAKE"})
|
||||||
|
m.assert_called_once_with(url, "GET")
|
||||||
|
|
||||||
|
@mock.patch('blazarclient.base.RequestManager.request',
|
||||||
|
return_value=(200, {"fake": "FAKE"}))
|
||||||
|
def test_post(self, m):
|
||||||
|
url = '/leases'
|
||||||
|
req_body = {
|
||||||
|
'start': '2020-07-24 20:00',
|
||||||
|
'end': '2020-08-09 22:30',
|
||||||
|
'before_end': '2020-08-09 21:30',
|
||||||
|
'events': [],
|
||||||
|
'name': 'lease-test',
|
||||||
|
'reservations': [
|
||||||
|
{
|
||||||
|
'min': '1',
|
||||||
|
'max': '2',
|
||||||
|
'hypervisor_properties':
|
||||||
|
'[">=", "$vcpus", "2"]',
|
||||||
|
'resource_properties':
|
||||||
|
'["==", "$extra_key", "extra_value"]',
|
||||||
|
'resource_type': 'physical:host',
|
||||||
|
'before_end': 'default'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
resp, body = self.manager.post(url, req_body)
|
||||||
|
self.assertEqual(resp, 200)
|
||||||
|
self.assertDictEqual(body, {"fake": "FAKE"})
|
||||||
|
m.assert_called_once_with(url, "POST", body=req_body)
|
||||||
|
|
||||||
|
@mock.patch('blazarclient.base.RequestManager.request',
|
||||||
|
return_value=(200, {"fake": "FAKE"}))
|
||||||
|
def test_delete(self, m):
|
||||||
|
url = '/leases/aaa-bbb-ccc'
|
||||||
|
resp, body = self.manager.delete(url)
|
||||||
|
self.assertEqual(resp, 200)
|
||||||
|
self.assertDictEqual(body, {"fake": "FAKE"})
|
||||||
|
m.assert_called_once_with(url, "DELETE")
|
||||||
|
|
||||||
|
@mock.patch('blazarclient.base.RequestManager.request',
|
||||||
|
return_value=(200, {"fake": "FAKE"}))
|
||||||
|
def test_put(self, m):
|
||||||
|
url = '/leases/aaa-bbb-ccc'
|
||||||
|
req_body = {
|
||||||
|
'name': 'lease-test',
|
||||||
|
}
|
||||||
|
resp, body = self.manager.put(url, req_body)
|
||||||
|
self.assertEqual(resp, 200)
|
||||||
|
self.assertDictEqual(body, {"fake": "FAKE"})
|
||||||
|
m.assert_called_once_with(url, "PUT", body=req_body)
|
||||||
|
|
||||||
|
@mock.patch('requests.request')
|
||||||
|
def test_request_ok_with_body(self, m):
|
||||||
|
m.return_value.status_code = 200
|
||||||
|
m.return_value.text = '{"resp_key": "resp_value"}'
|
||||||
|
url = '/leases'
|
||||||
|
kwargs = {"body": {"req_key": "req_value"}}
|
||||||
|
self.assertEqual(self.manager.request(url, "POST", **kwargs),
|
||||||
|
(m(), {"resp_key": "resp_value"}))
|
||||||
|
|
||||||
|
@mock.patch('requests.request')
|
||||||
|
def test_request_ok_without_body(self, m):
|
||||||
|
m.return_value.status_code = 200
|
||||||
|
m.return_value.text = "resp"
|
||||||
|
url = '/leases'
|
||||||
|
kwargs = {"body": {"req_key": "req_value"}}
|
||||||
|
self.assertEqual(self.manager.request(url, "POST", **kwargs),
|
||||||
|
(m(), None))
|
||||||
|
|
||||||
|
@mock.patch('requests.request')
|
||||||
|
def test_request_fail_with_body(self, m):
|
||||||
|
m.return_value.status_code = 400
|
||||||
|
m.return_value.text = '{"resp_key": "resp_value"}'
|
||||||
|
url = '/leases'
|
||||||
|
kwargs = {"body": {"req_key": "req_value"}}
|
||||||
|
self.assertRaises(exception.BlazarClientException,
|
||||||
|
self.manager.request, url, "POST", **kwargs)
|
||||||
|
|
||||||
|
@mock.patch('requests.request')
|
||||||
|
def test_request_fail_without_body(self, m):
|
||||||
|
m.return_value.status_code = 400
|
||||||
|
m.return_value.text = "resp"
|
||||||
|
url = '/leases'
|
||||||
|
kwargs = {"body": {"req_key": "req_value"}}
|
||||||
|
self.assertRaises(exception.BlazarClientException,
|
||||||
|
self.manager.request, url, "POST", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseClientManagerTestCase(tests.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(BaseClientManagerTestCase, self).setUp()
|
||||||
|
|
||||||
|
self.blazar_url = "www.fake.com/reservation"
|
||||||
|
self.auth_token = "aaa-bbb-ccc"
|
||||||
|
self.session = mock.MagicMock()
|
||||||
|
self.user_agent = "python-blazarclient"
|
||||||
|
|
||||||
|
def test_init_with_session(self):
|
||||||
|
manager = base.BaseClientManager(blazar_url=None,
|
||||||
|
auth_token=None,
|
||||||
|
session=self.session)
|
||||||
|
self.assertIsInstance(manager.request_manager,
|
||||||
|
adapter.LegacyJsonAdapter)
|
||||||
|
|
||||||
|
def test_init_with_url_and_token(self):
|
||||||
|
manager = base.BaseClientManager(blazar_url=self.blazar_url,
|
||||||
|
auth_token=self.auth_token,
|
||||||
|
session=None)
|
||||||
|
self.assertIsInstance(manager.request_manager,
|
||||||
|
base.RequestManager)
|
||||||
|
|
||||||
|
def test_init_with_insufficient_info(self):
|
||||||
|
self.assertRaises(exception.InsufficientAuthInfomation,
|
||||||
|
base.BaseClientManager,
|
||||||
|
blazar_url=None,
|
||||||
|
auth_token=self.auth_token,
|
||||||
|
session=None)
|
|
@ -13,6 +13,7 @@
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from blazarclient.v1 import hosts
|
from blazarclient.v1 import hosts
|
||||||
from blazarclient.v1 import leases
|
from blazarclient.v1 import leases
|
||||||
|
@ -31,15 +32,26 @@ class Client(object):
|
||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session, *args, **kwargs):
|
version = '1'
|
||||||
self.session = session
|
|
||||||
self.version = '1'
|
|
||||||
|
|
||||||
self.lease = leases.LeaseClientManager(session=self.session,
|
def __init__(self, blazar_url=None, auth_token=None, session=None,
|
||||||
|
**kwargs):
|
||||||
|
self.blazar_url = blazar_url
|
||||||
|
self.auth_token = auth_token
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
if not self.session:
|
||||||
|
logging.warning('Use a keystoneauth session object for the '
|
||||||
|
'authentication. The authentication with '
|
||||||
|
'blazar_url and auth_token is deprecated.')
|
||||||
|
|
||||||
|
self.lease = leases.LeaseClientManager(blazar_url=self.blazar_url,
|
||||||
|
auth_token=self.auth_token,
|
||||||
|
session=self.session,
|
||||||
version=self.version,
|
version=self.version,
|
||||||
*args,
|
|
||||||
**kwargs)
|
**kwargs)
|
||||||
self.host = hosts.ComputeHostClientManager(session=self.session,
|
self.host = hosts.ComputeHostClientManager(blazar_url=self.blazar_url,
|
||||||
|
auth_token=self.auth_token,
|
||||||
|
session=self.session,
|
||||||
version=self.version,
|
version=self.version,
|
||||||
*args,
|
|
||||||
**kwargs)
|
**kwargs)
|
||||||
|
|
|
@ -13,45 +13,41 @@
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from keystoneauth1 import adapter
|
from blazarclient import base
|
||||||
|
|
||||||
from blazarclient.i18n import _
|
from blazarclient.i18n import _
|
||||||
|
|
||||||
|
|
||||||
class ComputeHostClientManager(adapter.LegacyJsonAdapter):
|
class ComputeHostClientManager(base.BaseClientManager):
|
||||||
"""Manager for the ComputeHost connected requests."""
|
"""Manager for the ComputeHost connected requests."""
|
||||||
|
|
||||||
client_name = 'python-blazarclient'
|
|
||||||
|
|
||||||
def create(self, name, **kwargs):
|
def create(self, name, **kwargs):
|
||||||
"""Creates host from values passed."""
|
"""Creates host from values passed."""
|
||||||
values = {'name': name}
|
values = {'name': name}
|
||||||
values.update(**kwargs)
|
values.update(**kwargs)
|
||||||
resp, body = self.post('/os-hosts', body=values)
|
resp, body = self.request_manager.post('/os-hosts', body=values)
|
||||||
return body['host']
|
return body['host']
|
||||||
|
|
||||||
def get(self, host_id):
|
def get(self, host_id):
|
||||||
"""Describes host specifications such as name and details."""
|
"""Describes host specifications such as name and details."""
|
||||||
resp, body = super(ComputeHostClientManager,
|
resp, body = self.request_manager.get('/os-hosts/%s' % host_id)
|
||||||
self).get('/os-hosts/%s' % host_id)
|
|
||||||
return body['host']
|
return body['host']
|
||||||
|
|
||||||
def update(self, host_id, values):
|
def update(self, host_id, values):
|
||||||
"""Update attributes of the host."""
|
"""Update attributes of the host."""
|
||||||
if not values:
|
if not values:
|
||||||
return _('No values to update passed.')
|
return _('No values to update passed.')
|
||||||
resp, body = self.put('/os-hosts/%s' % host_id, body=values)
|
resp, body = self.request_manager.put(
|
||||||
|
'/os-hosts/%s' % host_id, body=values
|
||||||
|
)
|
||||||
return body['host']
|
return body['host']
|
||||||
|
|
||||||
def delete(self, host_id):
|
def delete(self, host_id):
|
||||||
"""Deletes host with specified ID."""
|
"""Deletes host with specified ID."""
|
||||||
resp, body = super(ComputeHostClientManager,
|
resp, body = self.request_manager.delete('/os-hosts/%s' % host_id)
|
||||||
self).delete('/os-hosts/%s' % host_id)
|
|
||||||
|
|
||||||
def list(self, sort_by=None):
|
def list(self, sort_by=None):
|
||||||
"""List all hosts."""
|
"""List all hosts."""
|
||||||
resp, body = super(ComputeHostClientManager,
|
resp, body = self.request_manager.get('/os-hosts')
|
||||||
self).get('/os-hosts')
|
|
||||||
hosts = body['hosts']
|
hosts = body['hosts']
|
||||||
if sort_by:
|
if sort_by:
|
||||||
hosts = sorted(hosts, key=lambda l: l[sort_by])
|
hosts = sorted(hosts, key=lambda l: l[sort_by])
|
||||||
|
|
|
@ -13,33 +13,30 @@
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from keystoneauth1 import adapter
|
|
||||||
from oslo_utils import timeutils
|
from oslo_utils import timeutils
|
||||||
|
|
||||||
|
from blazarclient import base
|
||||||
from blazarclient.i18n import _
|
from blazarclient.i18n import _
|
||||||
from blazarclient import utils
|
from blazarclient import utils
|
||||||
|
|
||||||
|
|
||||||
class LeaseClientManager(adapter.LegacyJsonAdapter):
|
class LeaseClientManager(base.BaseClientManager):
|
||||||
"""Manager for the lease connected requests."""
|
"""Manager for the lease connected requests."""
|
||||||
|
|
||||||
client_name = 'python-blazarclient'
|
|
||||||
|
|
||||||
def create(self, name, start, end, reservations, events, before_end=None):
|
def create(self, name, start, end, reservations, events, before_end=None):
|
||||||
"""Creates lease from values passed."""
|
"""Creates lease from values passed."""
|
||||||
values = {'name': name, 'start_date': start, 'end_date': end,
|
values = {'name': name, 'start_date': start, 'end_date': end,
|
||||||
'reservations': reservations, 'events': events,
|
'reservations': reservations, 'events': events,
|
||||||
'before_end_date': before_end}
|
'before_end_date': before_end}
|
||||||
|
|
||||||
resp, body = self.post('/leases', body=values)
|
resp, body = self.request_manager.post('/leases', body=values)
|
||||||
return body['lease']
|
return body['lease']
|
||||||
|
|
||||||
def get(self, lease_id):
|
def get(self, lease_id):
|
||||||
"""Describes lease specifications such as name, status and locked
|
"""Describes lease specifications such as name, status and locked
|
||||||
condition.
|
condition.
|
||||||
"""
|
"""
|
||||||
resp, body = super(LeaseClientManager,
|
resp, body = self.request_manager.get('/leases/%s' % lease_id)
|
||||||
self).get('/leases/%s' % lease_id)
|
|
||||||
return body['lease']
|
return body['lease']
|
||||||
|
|
||||||
def update(self, lease_id, name=None, prolong_for=None, reduce_by=None,
|
def update(self, lease_id, name=None, prolong_for=None, reduce_by=None,
|
||||||
|
@ -81,17 +78,17 @@ class LeaseClientManager(adapter.LegacyJsonAdapter):
|
||||||
|
|
||||||
if not values:
|
if not values:
|
||||||
return _('No values to update passed.')
|
return _('No values to update passed.')
|
||||||
resp, body = self.put('/leases/%s' % lease_id, body=values)
|
resp, body = self.request_manager.put('/leases/%s' % lease_id,
|
||||||
|
body=values)
|
||||||
return body['lease']
|
return body['lease']
|
||||||
|
|
||||||
def delete(self, lease_id):
|
def delete(self, lease_id):
|
||||||
"""Deletes lease with specified ID."""
|
"""Deletes lease with specified ID."""
|
||||||
resp, body = super(LeaseClientManager,
|
resp, body = self.request_manager.delete('/leases/%s' % lease_id)
|
||||||
self).delete('/leases/%s' % lease_id)
|
|
||||||
|
|
||||||
def list(self, sort_by=None):
|
def list(self, sort_by=None):
|
||||||
"""List all leases."""
|
"""List all leases."""
|
||||||
resp, body = super(LeaseClientManager, self).get('/leases')
|
resp, body = self.request_manager.get('/leases')
|
||||||
leases = body['leases']
|
leases = body['leases']
|
||||||
if sort_by:
|
if sort_by:
|
||||||
leases = sorted(leases, key=lambda l: l[sort_by])
|
leases = sorted(leases, key=lambda l: l[sort_by])
|
||||||
|
|
Loading…
Reference in New Issue