Storage controller implementation (index and show).
New occi.infrastructure.storage and associated tests. Added StorageLinks
This commit is contained in:
parent
65edb4b8c7
commit
fcaab700d3
|
@ -20,6 +20,8 @@ import ooi.api
|
|||
import ooi.api.base
|
||||
from ooi.occi.core import collection
|
||||
from ooi.occi.infrastructure import compute
|
||||
from ooi.occi.infrastructure import storage
|
||||
from ooi.occi.infrastructure import storage_link
|
||||
from ooi.openstack import helpers
|
||||
from ooi.openstack import templates
|
||||
|
||||
|
@ -124,8 +126,13 @@ class Controller(ooi.api.base.Controller):
|
|||
cores=flavor["vcpus"],
|
||||
hostname=s["name"],
|
||||
memory=flavor["ram"],
|
||||
state=helpers.occi_state(s["status"]),
|
||||
state=helpers.vm_state(s["status"]),
|
||||
mixins=[os_tpl, res_tpl])
|
||||
# storage links
|
||||
vols_attached = s.get("os-extended-volumes:volumes_attached", [])
|
||||
for v in vols_attached:
|
||||
st = storage.StorageResource(title="storage", id=v["id"])
|
||||
comp._links.append(storage_link.StorageLink(comp, st))
|
||||
return [comp]
|
||||
|
||||
def delete(self, req, id):
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2015 Spanish National Research Council
|
||||
#
|
||||
# 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.
|
||||
|
||||
from ooi.api import base
|
||||
from ooi.occi.core import collection
|
||||
from ooi.occi.infrastructure import storage
|
||||
from ooi.openstack import helpers
|
||||
|
||||
|
||||
class Controller(base.Controller):
|
||||
def index(self, req):
|
||||
tenant_id = req.environ["keystone.token_auth"].user.project_id
|
||||
req = self._get_req(req, path="/%s/os-volumes" % tenant_id)
|
||||
response = req.get_response(self.app)
|
||||
volumes = self.get_from_response(response, "volumes", [])
|
||||
occi_storage_resources = []
|
||||
if volumes:
|
||||
for v in volumes:
|
||||
s = storage.StorageResource(title=v["displayName"], id=v["id"])
|
||||
occi_storage_resources.append(s)
|
||||
|
||||
return collection.Collection(resources=occi_storage_resources)
|
||||
|
||||
def show(self, id, req):
|
||||
tenant_id = req.environ["keystone.token_auth"].user.project_id
|
||||
|
||||
# get info from server
|
||||
req = self._get_req(req, path="/%s/os-volumes/%s" % (tenant_id, id))
|
||||
response = req.get_response(self.app)
|
||||
v = self.get_from_response(response, "volume", {})
|
||||
|
||||
state = helpers.vol_state(v["status"])
|
||||
st = storage.StorageResource(title=v["displayName"], id=v["id"],
|
||||
size=v["size"], state=state)
|
||||
return [st]
|
|
@ -33,8 +33,8 @@ class Link(entity.Entity):
|
|||
kind = kind.Kind(helpers.build_scheme("core"), 'link', 'link',
|
||||
attributes, 'link/')
|
||||
|
||||
def __init__(self, title, mixins, source, target):
|
||||
super(Link, self).__init__(title, mixins)
|
||||
def __init__(self, title, mixins, source, target, id=None):
|
||||
super(Link, self).__init__(title, mixins, id)
|
||||
self.attributes["occi.core.source"] = attribute.MutableAttribute(
|
||||
"occi.core.source", source)
|
||||
self.attributes["occi.core.target"] = attribute.MutableAttribute(
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2015 Spanish National Research Council
|
||||
#
|
||||
# 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.
|
||||
|
||||
from ooi.occi.core import action
|
||||
from ooi.occi.core import attribute as attr
|
||||
from ooi.occi.core import kind
|
||||
from ooi.occi.core import resource
|
||||
from ooi.occi import helpers
|
||||
|
||||
online = action.Action(helpers.build_scheme('infrastructure/storage/action'),
|
||||
"online", "online storage instance")
|
||||
|
||||
offline = action.Action(helpers.build_scheme('infrastructure/storage/action'),
|
||||
"offline", "offline storage instance")
|
||||
|
||||
backup = action.Action(helpers.build_scheme('infrastructure/storage/action'),
|
||||
"backup", "backup storage instance")
|
||||
|
||||
snapshot = action.Action(helpers.build_scheme('infrastructure/storage/action'),
|
||||
"snapshot", "snapshot storage instance")
|
||||
|
||||
resize = action.Action(helpers.build_scheme('infrastructure/storage/action'),
|
||||
"resize", "resize storage instance")
|
||||
|
||||
|
||||
class StorageResource(resource.Resource):
|
||||
attributes = attr.AttributeCollection(["occi.storage.size",
|
||||
"occi.storage.state"])
|
||||
actions = (online, offline, backup, snapshot, resize)
|
||||
kind = kind.Kind(helpers.build_scheme('infrastructure'), 'storage',
|
||||
'storage resource', attributes, '/storage/',
|
||||
actions=actions,
|
||||
related=[resource.Resource.kind])
|
||||
|
||||
def __init__(self, title, summary=None, id=None, size=None, state=None):
|
||||
mixins = []
|
||||
super(StorageResource, self).__init__(title, mixins, summary=summary,
|
||||
id=id)
|
||||
self.attributes["occi.storage.size"] = attr.MutableAttribute(
|
||||
"occi.storage.size", size)
|
||||
self.attributes["occi.storage.state"] = attr.InmutableAttribute(
|
||||
"occi.storage.state", state)
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return self.attributes["occi.storage.size"].value
|
||||
|
||||
@size.setter
|
||||
def size(self, value):
|
||||
self.attributes["occi.storage.size"].value = value
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return self.attributes["occi.storage.state"].value
|
|
@ -0,0 +1,63 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2015 Spanish National Research Council
|
||||
#
|
||||
# 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.
|
||||
|
||||
from ooi.occi.core import attribute as attr
|
||||
from ooi.occi.core import kind
|
||||
from ooi.occi.core import link
|
||||
from ooi.occi import helpers
|
||||
|
||||
|
||||
class StorageLink(link.Link):
|
||||
attributes = attr.AttributeCollection(["occi.storagelink.deviceid",
|
||||
"occi.storagelink.mountpoint",
|
||||
"occi.storagelink.state"])
|
||||
kind = kind.Kind(helpers.build_scheme('infrastructure'), 'storagelink',
|
||||
'storage link resource', attributes, '/storagelink/',
|
||||
related=[link.Link.kind])
|
||||
|
||||
def __init__(self, source, target, deviceid=None, mountpoint=None,
|
||||
state=None):
|
||||
|
||||
# TODO(enolfc): is this a valid link id?
|
||||
link_id = '_'.join([source.id, target.id])
|
||||
super(StorageLink, self).__init__(None, [], source, target, link_id)
|
||||
|
||||
self.attributes["occi.storagelink.deviceid"] = attr.MutableAttribute(
|
||||
"occi.storagelink.deviceid", deviceid)
|
||||
self.attributes["occi.storagelink.mountpoint"] = attr.MutableAttribute(
|
||||
"occi.storagelink.mountpoint", mountpoint)
|
||||
self.attributes["occi.storagelink.state"] = attr.InmutableAttribute(
|
||||
"occi.storagelink.state", state)
|
||||
|
||||
@property
|
||||
def deviceid(self):
|
||||
return self.attributes["occi.storagelink.deviceid"].value
|
||||
|
||||
@deviceid.setter
|
||||
def deviceid(self, value):
|
||||
self.attributes["occi.storagelink.deviceid"].value = value
|
||||
|
||||
@property
|
||||
def mountpoint(self):
|
||||
return self.attributes["occi.storagelink.mountpoint"].value
|
||||
|
||||
@mountpoint.setter
|
||||
def mountpoint(self, value):
|
||||
self.attributes["occi.storagelink.mountpoint"].value = value
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return self.attributes["occi.storagelink.state"].value
|
|
@ -24,10 +24,15 @@ def build_scheme(category):
|
|||
|
||||
|
||||
# TODO(enolfc): Check the correct names of nova states
|
||||
def occi_state(nova_status):
|
||||
def vm_state(nova_status):
|
||||
if nova_status in ["ACTIVE"]:
|
||||
return "active"
|
||||
elif nova_status in ["PAUSED", "SUSPENDED", "STOPPED"]:
|
||||
return "suspended"
|
||||
else:
|
||||
return "inactive"
|
||||
|
||||
|
||||
# TODO(enolfc): Do really implement this.
|
||||
def vol_state(nova_status):
|
||||
return "online"
|
||||
|
|
|
@ -88,6 +88,30 @@ servers = {
|
|||
tenants["bar"]["id"]: [],
|
||||
}
|
||||
|
||||
volumes = {
|
||||
tenants["foo"]["id"]: [
|
||||
{
|
||||
"id": uuid.uuid4().hex,
|
||||
"displayName": "foo",
|
||||
"size": 2,
|
||||
"status": "in-use",
|
||||
},
|
||||
{
|
||||
"id": uuid.uuid4().hex,
|
||||
"displayName": "bar",
|
||||
"size": 3,
|
||||
"status": "in-use",
|
||||
},
|
||||
{
|
||||
"id": uuid.uuid4().hex,
|
||||
"displayName": "baz",
|
||||
"size": 5,
|
||||
"status": "in-use",
|
||||
},
|
||||
],
|
||||
tenants["bar"]["id"]: [],
|
||||
}
|
||||
|
||||
|
||||
def fake_query_results():
|
||||
cats = []
|
||||
|
@ -201,20 +225,24 @@ class FakeApp(object):
|
|||
path = "/%s" % tenant["id"]
|
||||
|
||||
self._populate(path, "server", servers[tenant["id"]])
|
||||
self._populate(path, "volume", volumes[tenant["id"]], "os-volumes")
|
||||
# NOTE(aloga): dict_values un Py3 is not serializable in JSON
|
||||
self._populate(path, "image", list(images.values()))
|
||||
self._populate(path, "flavor", list(flavors.values()))
|
||||
|
||||
def _populate(self, path_base, obj_name, obj_list):
|
||||
def _populate(self, path_base, obj_name, obj_list, objs_path=None):
|
||||
objs_name = "%ss" % obj_name
|
||||
objs_path = "%s/%s" % (path_base, objs_name)
|
||||
objs_details_path = "%s/%s/detail" % (path_base, objs_name)
|
||||
self.routes[objs_path] = create_fake_json_resp({objs_name: obj_list})
|
||||
if objs_path:
|
||||
path = "%s/%s" % (path_base, objs_path)
|
||||
else:
|
||||
path = "%s/%s" % (path_base, objs_name)
|
||||
objs_details_path = "%s/detail" % path
|
||||
self.routes[path] = create_fake_json_resp({objs_name: obj_list})
|
||||
self.routes[objs_details_path] = create_fake_json_resp(
|
||||
{objs_name: obj_list})
|
||||
|
||||
for o in obj_list:
|
||||
obj_path = "%s/%s" % (objs_path, o["id"])
|
||||
obj_path = "%s/%s" % (path, o["id"])
|
||||
self.routes[obj_path] = create_fake_json_resp({obj_name: o})
|
||||
|
||||
@webob.dec.wsgify()
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2015 Spanish National Research Council
|
||||
#
|
||||
# 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 uuid
|
||||
|
||||
import mock
|
||||
|
||||
from ooi.tests import fakes
|
||||
from ooi.tests.middleware import test_middleware
|
||||
|
||||
|
||||
def build_occi_volume(vol):
|
||||
name = vol["displayName"]
|
||||
vol_id = vol["id"]
|
||||
size = vol["size"]
|
||||
# TODO(enolfc): use proper status!
|
||||
status = "online"
|
||||
|
||||
cats = []
|
||||
cats.append('storage; '
|
||||
'scheme="http://schemas.ogf.org/occi/infrastructure"; '
|
||||
'class="kind"'),
|
||||
attrs = [
|
||||
'occi.core.title="%s"' % name,
|
||||
'occi.storage.size=%s' % size,
|
||||
'occi.storage.state="%s"' % status,
|
||||
'occi.core.id="%s"' % vol_id,
|
||||
]
|
||||
links = []
|
||||
links.append('<%s?action=backup>; rel=http://schemas.ogf.org/occi/'
|
||||
'infrastructure/storage/action#backup' % vol_id)
|
||||
links.append('<%s?action=resize>; rel=http://schemas.ogf.org/occi/'
|
||||
'infrastructure/storage/action#resize' % vol_id)
|
||||
links.append('<%s?action=snapshot>; rel=http://schemas.ogf.org/occi/'
|
||||
'infrastructure/storage/action#snapshot' % vol_id)
|
||||
links.append('<%s?action=offline>; rel=http://schemas.ogf.org/occi/'
|
||||
'infrastructure/storage/action#offline' % vol_id)
|
||||
links.append('<%s?action=online>; rel=http://schemas.ogf.org/occi/'
|
||||
'infrastructure/storage/action#online' % vol_id)
|
||||
|
||||
result = []
|
||||
for c in cats:
|
||||
result.append(("Category", c))
|
||||
for l in links:
|
||||
result.append(("Link", l))
|
||||
for a in attrs:
|
||||
result.append(("X-OCCI-Attribute", a))
|
||||
return result
|
||||
|
||||
|
||||
class TestStorageController(test_middleware.TestMiddleware):
|
||||
"""Test OCCI storage controller."""
|
||||
|
||||
def test_list_vols_empty(self):
|
||||
tenant = fakes.tenants["bar"]
|
||||
app = self.get_app()
|
||||
|
||||
req = self._build_req("/storage", tenant["id"], method="GET")
|
||||
|
||||
m = mock.MagicMock()
|
||||
m.user.project_id = tenant["id"]
|
||||
req.environ["keystone.token_auth"] = m
|
||||
|
||||
resp = req.get_response(app)
|
||||
|
||||
self.assertEqual("/%s/os-volumes" % tenant["id"], req.path_info)
|
||||
|
||||
expected_result = ""
|
||||
self.assertContentType(resp)
|
||||
self.assertExpectedResult(expected_result, resp)
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
def test_list_vols(self):
|
||||
tenant = fakes.tenants["foo"]
|
||||
app = self.get_app()
|
||||
|
||||
req = self._build_req("/storage", tenant["id"], method="GET")
|
||||
|
||||
resp = req.get_response(app)
|
||||
|
||||
self.assertEqual("/%s/os-volumes" % tenant["id"], req.path_info)
|
||||
|
||||
self.assertEqual(200, resp.status_code)
|
||||
expected = []
|
||||
for s in fakes.volumes[tenant["id"]]:
|
||||
expected.append(("X-OCCI-Location", "/storage/%s" % s["id"]))
|
||||
self.assertExpectedResult(expected, resp)
|
||||
|
||||
def test_show_vol(self):
|
||||
tenant = fakes.tenants["foo"]
|
||||
app = self.get_app()
|
||||
|
||||
for volume in fakes.volumes[tenant["id"]]:
|
||||
req = self._build_req("/storage/%s" % volume["id"],
|
||||
tenant["id"], method="GET")
|
||||
|
||||
resp = req.get_response(app)
|
||||
expected = build_occi_volume(volume)
|
||||
self.assertContentType(resp)
|
||||
self.assertExpectedResult(expected, resp)
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
def test_vol_not_found(self):
|
||||
tenant = fakes.tenants["foo"]
|
||||
|
||||
app = self.get_app()
|
||||
req = self._build_req("/storage/%s" % uuid.uuid4().hex,
|
||||
tenant["id"], method="GET")
|
||||
resp = req.get_response(app)
|
||||
self.assertEqual(404, resp.status_code)
|
||||
|
||||
|
||||
class StorageControllerTextPlain(test_middleware.TestMiddlewareTextPlain,
|
||||
TestStorageController):
|
||||
"""Test OCCI compute controller with Accept: text/plain."""
|
||||
|
||||
|
||||
class StorageControllerTextOcci(test_middleware.TestMiddlewareTextOcci,
|
||||
TestStorageController):
|
||||
"""Test OCCI compute controller with Accept: text/occi."""
|
|
@ -19,6 +19,7 @@ import uuid
|
|||
from ooi.occi.core import mixin
|
||||
from ooi.occi.core import resource
|
||||
from ooi.occi.infrastructure import compute
|
||||
from ooi.occi.infrastructure import storage
|
||||
from ooi.occi.infrastructure import templates
|
||||
from ooi.tests import base
|
||||
|
||||
|
@ -58,7 +59,7 @@ class TestOCCICompute(base.TestCase):
|
|||
self.assertIsNone(c.memory)
|
||||
self.assertIsNone(c.speed)
|
||||
|
||||
def test_getters(self):
|
||||
def test_setters(self):
|
||||
c = compute.ComputeResource("foo")
|
||||
c.architecture = "bar"
|
||||
self.assertEqual("bar",
|
||||
|
@ -72,7 +73,7 @@ class TestOCCICompute(base.TestCase):
|
|||
c.memory = 4
|
||||
self.assertEqual(4, c.attributes["occi.compute.memory"].value)
|
||||
|
||||
def test_setters(self):
|
||||
def test_getters(self):
|
||||
c = compute.ComputeResource("foo")
|
||||
c.attributes["occi.compute.architecture"].value = "bar"
|
||||
self.assertEqual("bar", c.architecture)
|
||||
|
@ -86,6 +87,46 @@ class TestOCCICompute(base.TestCase):
|
|||
self.assertEqual(9, c.memory)
|
||||
|
||||
|
||||
class TestOCCIStorage(base.TestCase):
|
||||
def test_storage_class(self):
|
||||
s = storage.StorageResource
|
||||
self.assertIn(storage.online, s.actions)
|
||||
self.assertIn(storage.offline, s.actions)
|
||||
self.assertIn(storage.backup, s.actions)
|
||||
self.assertIn(storage.snapshot, s.actions)
|
||||
self.assertIn(storage.resize, s.actions)
|
||||
self.assertIn("occi.core.id", s.attributes)
|
||||
self.assertIn("occi.core.summary", s.attributes)
|
||||
self.assertIn("occi.core.title", s.attributes)
|
||||
self.assertIn("occi.storage.size", s.attributes)
|
||||
self.assertIn("occi.storage.state", s.attributes)
|
||||
self.assertIn(resource.Resource.kind, s.kind.related)
|
||||
# TODO(aloga): We need to check that the attributes are actually set
|
||||
# after we get an object (we have to check this for this but also for
|
||||
# the other resources)
|
||||
|
||||
def test_storage(self):
|
||||
id = uuid.uuid4().hex
|
||||
s = storage.StorageResource("foo",
|
||||
summary="This is a summary",
|
||||
id=id)
|
||||
self.assertEqual("foo", s.title)
|
||||
self.assertEqual(id, s.id)
|
||||
self.assertEqual("This is a summary", s.summary)
|
||||
self.assertIsNone(s.size)
|
||||
self.assertIsNone(s.state)
|
||||
|
||||
def test_setters(self):
|
||||
s = storage.StorageResource("foo")
|
||||
s.size = 3
|
||||
self.assertEqual(3, s.attributes["occi.storage.size"].value)
|
||||
|
||||
def test_getters(self):
|
||||
s = storage.StorageResource("foo", size=5, state="foobar")
|
||||
self.assertEqual(5, s.size)
|
||||
self.assertEqual("foobar", s.state)
|
||||
|
||||
|
||||
class TestTemplates(base.TestCase):
|
||||
def test_os_tpl(self):
|
||||
self.assertIsInstance(templates.os_tpl,
|
||||
|
|
|
@ -64,12 +64,15 @@ class TestOpenStackResourceTemplate(base.TestCase):
|
|||
|
||||
|
||||
class TestHelpers(base.TestCase):
|
||||
def test_occi_state(self):
|
||||
self.assertEqual("active", helpers.occi_state("ACTIVE"))
|
||||
self.assertEqual("suspended", helpers.occi_state("PAUSED"))
|
||||
self.assertEqual("suspended", helpers.occi_state("SUSPENDED"))
|
||||
self.assertEqual("suspended", helpers.occi_state("STOPPED"))
|
||||
self.assertEqual("inactive", helpers.occi_state("BUILDING"))
|
||||
def test_vm_state(self):
|
||||
self.assertEqual("active", helpers.vm_state("ACTIVE"))
|
||||
self.assertEqual("suspended", helpers.vm_state("PAUSED"))
|
||||
self.assertEqual("suspended", helpers.vm_state("SUSPENDED"))
|
||||
self.assertEqual("suspended", helpers.vm_state("STOPPED"))
|
||||
self.assertEqual("inactive", helpers.vm_state("BUILDING"))
|
||||
|
||||
def test_vol_state(self):
|
||||
self.assertEqual("online", helpers.vol_state("in-use"))
|
||||
|
||||
|
||||
class TestOpenStackUserData(base.TestCase):
|
||||
|
|
|
@ -21,6 +21,7 @@ import webob.dec
|
|||
|
||||
import ooi.api.compute
|
||||
from ooi.api import query
|
||||
import ooi.api.storage
|
||||
from ooi import exception
|
||||
from ooi import utils
|
||||
from ooi.wsgi import serializers
|
||||
|
@ -123,6 +124,11 @@ class OCCIMiddleware(object):
|
|||
action="delete_all",
|
||||
conditions=dict(method=["DELETE"]))
|
||||
|
||||
self.resources["storage"] = self._create_resource(
|
||||
ooi.api.storage.Controller)
|
||||
self.mapper.resource("volume", "storage",
|
||||
controller=self.resources["storage"])
|
||||
|
||||
@webob.dec.wsgify(RequestClass=Request)
|
||||
def __call__(self, req):
|
||||
response = self.process_request(req)
|
||||
|
|
Loading…
Reference in New Issue