Merge "Make project_id optional in v2.1 urls"
This commit is contained in:
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"status": "CURRENT",
|
"status": "CURRENT",
|
||||||
"version": "2.17",
|
"version": "2.18",
|
||||||
"min_version": "2.1",
|
"min_version": "2.1",
|
||||||
"updated": "2013-07-23T11:33:21Z"
|
"updated": "2013-07-23T11:33:21Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"status": "CURRENT",
|
"status": "CURRENT",
|
||||||
"version": "2.17",
|
"version": "2.18",
|
||||||
"min_version": "2.1",
|
"min_version": "2.1",
|
||||||
"updated": "2013-07-23T11:33:21Z"
|
"updated": "2013-07-23T11:33:21Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ api_opts = [
|
|||||||
'list. Specify the extension aliases here. '
|
'list. Specify the extension aliases here. '
|
||||||
'This option will be removed in the near future. '
|
'This option will be removed in the near future. '
|
||||||
'After that point you have to run all of the API.',
|
'After that point you have to run all of the API.',
|
||||||
|
deprecated_for_removal=True, deprecated_group='osapi_v21'),
|
||||||
|
cfg.StrOpt('project_id_regex',
|
||||||
|
default=None,
|
||||||
|
help='DEPRECATED: The validation regex for project_ids '
|
||||||
|
'used in urls. This defaults to [0-9a-f\-]+ if not set, '
|
||||||
|
'which matches normal uuids created by keystone.',
|
||||||
deprecated_for_removal=True, deprecated_group='osapi_v21')
|
deprecated_for_removal=True, deprecated_group='osapi_v21')
|
||||||
]
|
]
|
||||||
api_opts_group = cfg.OptGroup(name='osapi_v21', title='API v2.1 Options')
|
api_opts_group = cfg.OptGroup(name='osapi_v21', title='API v2.1 Options')
|
||||||
@@ -196,13 +202,39 @@ class APIMapper(routes.Mapper):
|
|||||||
|
|
||||||
class ProjectMapper(APIMapper):
|
class ProjectMapper(APIMapper):
|
||||||
def resource(self, member_name, collection_name, **kwargs):
|
def resource(self, member_name, collection_name, **kwargs):
|
||||||
|
# NOTE(sdague): project_id parameter is only valid if its hex
|
||||||
|
# or hex + dashes (note, integers are a subset of this). This
|
||||||
|
# is required to hand our overlaping routes issues.
|
||||||
|
project_id_regex = '[0-9a-f\-]+'
|
||||||
|
if CONF.osapi_v21.project_id_regex:
|
||||||
|
project_id_regex = CONF.osapi_v21.project_id_regex
|
||||||
|
|
||||||
|
project_id_token = '{project_id:%s}' % project_id_regex
|
||||||
if 'parent_resource' not in kwargs:
|
if 'parent_resource' not in kwargs:
|
||||||
kwargs['path_prefix'] = '{project_id}/'
|
kwargs['path_prefix'] = '%s/' % project_id_token
|
||||||
else:
|
else:
|
||||||
parent_resource = kwargs['parent_resource']
|
parent_resource = kwargs['parent_resource']
|
||||||
p_collection = parent_resource['collection_name']
|
p_collection = parent_resource['collection_name']
|
||||||
p_member = parent_resource['member_name']
|
p_member = parent_resource['member_name']
|
||||||
kwargs['path_prefix'] = '{project_id}/%s/:%s_id' % (p_collection,
|
kwargs['path_prefix'] = '%s/%s/:%s_id' % (
|
||||||
|
project_id_token,
|
||||||
|
p_collection,
|
||||||
|
p_member)
|
||||||
|
routes.Mapper.resource(
|
||||||
|
self,
|
||||||
|
member_name,
|
||||||
|
collection_name,
|
||||||
|
**kwargs)
|
||||||
|
|
||||||
|
# while we are in transition mode, create additional routes
|
||||||
|
# for the resource that do not include project_id.
|
||||||
|
if 'parent_resource' not in kwargs:
|
||||||
|
del kwargs['path_prefix']
|
||||||
|
else:
|
||||||
|
parent_resource = kwargs['parent_resource']
|
||||||
|
p_collection = parent_resource['collection_name']
|
||||||
|
p_member = parent_resource['member_name']
|
||||||
|
kwargs['path_prefix'] = '%s/:%s_id' % (p_collection,
|
||||||
p_member)
|
p_member)
|
||||||
routes.Mapper.resource(self, member_name,
|
routes.Mapper.resource(self, member_name,
|
||||||
collection_name,
|
collection_name,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ REST_API_VERSION_HISTORY = """REST API Version History:
|
|||||||
* 2.15 - Add soft-affinity and soft-anti-affinity policies
|
* 2.15 - Add soft-affinity and soft-anti-affinity policies
|
||||||
* 2.16 - Exposes host_status for servers/detail and servers/{server_id}
|
* 2.16 - Exposes host_status for servers/detail and servers/{server_id}
|
||||||
* 2.17 - Add trigger_crash_dump to server actions
|
* 2.17 - Add trigger_crash_dump to server actions
|
||||||
|
* 2.18 - Makes project_id optional in v2.1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# The minimum and maximum versions of the API supported
|
# The minimum and maximum versions of the API supported
|
||||||
@@ -67,7 +68,7 @@ REST_API_VERSION_HISTORY = """REST API Version History:
|
|||||||
# Note(cyeoh): This only applies for the v2.1 API once microversions
|
# Note(cyeoh): This only applies for the v2.1 API once microversions
|
||||||
# support is fully merged. It does not affect the V2 API.
|
# support is fully merged. It does not affect the V2 API.
|
||||||
_MIN_API_VERSION = "2.1"
|
_MIN_API_VERSION = "2.1"
|
||||||
_MAX_API_VERSION = "2.17"
|
_MAX_API_VERSION = "2.18"
|
||||||
DEFAULT_API_VERSION = _MIN_API_VERSION
|
DEFAULT_API_VERSION = _MIN_API_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,10 +74,14 @@ class NoAuthMiddleware(NoAuthMiddlewareBase):
|
|||||||
return self.base_call(req, True, always_admin=False)
|
return self.base_call(req, True, always_admin=False)
|
||||||
|
|
||||||
|
|
||||||
# TODO(johnthetubaguy) this should be removed in the M release
|
class NoAuthMiddlewareV2_17(NoAuthMiddlewareBase):
|
||||||
class NoAuthMiddlewareV3(NoAuthMiddlewareBase):
|
"""Return a fake token if one isn't specified.
|
||||||
"""Return a fake token if one isn't specified."""
|
|
||||||
|
This provides a version of the middleware which does not add
|
||||||
|
project_id into server management urls.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
@webob.dec.wsgify(RequestClass=wsgi.Request)
|
@webob.dec.wsgify(RequestClass=wsgi.Request)
|
||||||
def __call__(self, req):
|
def __call__(self, req):
|
||||||
return self.base_call(req, False)
|
return self.base_call(req, False, always_admin=False)
|
||||||
|
|||||||
@@ -154,3 +154,8 @@ class ImageMetadata(extensions.V21APIExtensionBase):
|
|||||||
"/{project_id}/images/{image_id}/metadata",
|
"/{project_id}/images/{image_id}/metadata",
|
||||||
controller=wsgi_resource,
|
controller=wsgi_resource,
|
||||||
action='update_all', conditions={"method": ['PUT']})
|
action='update_all', conditions={"method": ['PUT']})
|
||||||
|
# Also connect the non project_id route
|
||||||
|
mapper.connect("metadata",
|
||||||
|
"/images/{image_id}/metadata",
|
||||||
|
controller=wsgi_resource,
|
||||||
|
action='update_all', conditions={"method": ['PUT']})
|
||||||
|
|||||||
@@ -191,3 +191,8 @@ class ServerMetadata(extensions.V21APIExtensionBase):
|
|||||||
"/{project_id}/servers/{server_id}/metadata",
|
"/{project_id}/servers/{server_id}/metadata",
|
||||||
controller=wsgi_resource,
|
controller=wsgi_resource,
|
||||||
action='update_all', conditions={"method": ['PUT']})
|
action='update_all', conditions={"method": ['PUT']})
|
||||||
|
# Also connect the non project_id routes
|
||||||
|
mapper.connect("metadata",
|
||||||
|
"/servers/{server_id}/metadata",
|
||||||
|
controller=wsgi_resource,
|
||||||
|
action='update_all', conditions={"method": ['PUT']})
|
||||||
|
|||||||
@@ -163,3 +163,7 @@ user documentation.
|
|||||||
|
|
||||||
Add a new API for triggering crash dump in an instance. Different operation
|
Add a new API for triggering crash dump in an instance. Different operation
|
||||||
systems in instance may need different configurations to trigger crash dump.
|
systems in instance may need different configurations to trigger crash dump.
|
||||||
|
|
||||||
|
2.18
|
||||||
|
----
|
||||||
|
Establishes a set of routes that makes project_id an optional construct in v2.1.
|
||||||
|
|||||||
@@ -54,3 +54,14 @@ class ApiPasteLegacyV2Fixture(ApiPasteV21Fixture):
|
|||||||
"/v2: openstack_compute_api_v21_legacy_v2_compatible",
|
"/v2: openstack_compute_api_v21_legacy_v2_compatible",
|
||||||
"/v2: openstack_compute_api_legacy_v2")
|
"/v2: openstack_compute_api_legacy_v2")
|
||||||
target_file.write(line)
|
target_file.write(line)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiPasteNoProjectId(ApiPasteV21Fixture):
|
||||||
|
|
||||||
|
def _replace_line(self, target_file, line):
|
||||||
|
line = line.replace(
|
||||||
|
"paste.filter_factory = nova.api.openstack.auth:"
|
||||||
|
"NoAuthMiddleware.factory",
|
||||||
|
"paste.filter_factory = nova.api.openstack.auth:"
|
||||||
|
"NoAuthMiddlewareV2_17.factory")
|
||||||
|
target_file.write(line)
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class ApiSampleTestBaseV21(testscenarios.WithScenarios,
|
|||||||
sample_dir = None
|
sample_dir = None
|
||||||
extra_extensions_to_load = None
|
extra_extensions_to_load = None
|
||||||
_legacy_v2_code = False
|
_legacy_v2_code = False
|
||||||
|
_project_id = True
|
||||||
|
|
||||||
scenarios = [
|
scenarios = [
|
||||||
# test v2 with the v2.1 compatibility stack
|
# test v2 with the v2.1 compatibility stack
|
||||||
@@ -82,7 +83,13 @@ class ApiSampleTestBaseV21(testscenarios.WithScenarios,
|
|||||||
'api_major_version': 'v2',
|
'api_major_version': 'v2',
|
||||||
'_legacy_v2_code': True,
|
'_legacy_v2_code': True,
|
||||||
'_additional_fixtures': [
|
'_additional_fixtures': [
|
||||||
api_paste_fixture.ApiPasteLegacyV2Fixture]})
|
api_paste_fixture.ApiPasteLegacyV2Fixture]}),
|
||||||
|
# test v2.16 code without project id
|
||||||
|
('v2_1_noproject_id', {
|
||||||
|
'api_major_version': 'v2.1',
|
||||||
|
'_project_id': False,
|
||||||
|
'_additional_fixtures': [
|
||||||
|
api_paste_fixture.ApiPasteNoProjectId]})
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"status": "CURRENT",
|
"status": "CURRENT",
|
||||||
"version": "2.17",
|
"version": "2.18",
|
||||||
"min_version": "2.1",
|
"min_version": "2.1",
|
||||||
"updated": "2013-07-23T11:33:21Z"
|
"updated": "2013-07-23T11:33:21Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"status": "CURRENT",
|
"status": "CURRENT",
|
||||||
"version": "2.17",
|
"version": "2.18",
|
||||||
"min_version": "2.1",
|
"min_version": "2.1",
|
||||||
"updated": "2013-07-23T11:33:21Z"
|
"updated": "2013-07-23T11:33:21Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,9 +250,19 @@ class ApiSampleTestBase(integrated_helpers._IntegratedTestBase):
|
|||||||
|
|
||||||
def _update_links(self, sample_data):
|
def _update_links(self, sample_data):
|
||||||
"""Process sample data and update version specific links."""
|
"""Process sample data and update version specific links."""
|
||||||
url_re = self._get_host() + "/v(2\.1|2)"
|
# replace version urls
|
||||||
|
url_re = self._get_host() + "/v(2|2\.1)/openstack"
|
||||||
new_url = self._get_host() + "/" + self.api_major_version
|
new_url = self._get_host() + "/" + self.api_major_version
|
||||||
|
if self._project_id:
|
||||||
|
new_url += "/openstack"
|
||||||
updated_data = re.sub(url_re, new_url, sample_data)
|
updated_data = re.sub(url_re, new_url, sample_data)
|
||||||
|
|
||||||
|
# replace unversioned urls
|
||||||
|
url_re = self._get_host() + "/openstack"
|
||||||
|
new_url = self._get_host()
|
||||||
|
if self._project_id:
|
||||||
|
new_url += "/openstack"
|
||||||
|
updated_data = re.sub(url_re, new_url, updated_data)
|
||||||
return updated_data
|
return updated_data
|
||||||
|
|
||||||
def _verify_response(self, name, subs, response, exp_code,
|
def _verify_response(self, name, subs, response, exp_code,
|
||||||
@@ -360,13 +370,19 @@ class ApiSampleTestBase(integrated_helpers._IntegratedTestBase):
|
|||||||
def _get_compute_endpoint(self):
|
def _get_compute_endpoint(self):
|
||||||
# NOTE(sdague): "openstack" is stand in for project_id, it
|
# NOTE(sdague): "openstack" is stand in for project_id, it
|
||||||
# should be more generic in future.
|
# should be more generic in future.
|
||||||
|
if self._project_id:
|
||||||
return '%s/%s' % (self._get_host(), 'openstack')
|
return '%s/%s' % (self._get_host(), 'openstack')
|
||||||
|
else:
|
||||||
|
return self._get_host()
|
||||||
|
|
||||||
def _get_vers_compute_endpoint(self):
|
def _get_vers_compute_endpoint(self):
|
||||||
# NOTE(sdague): "openstack" is stand in for project_id, it
|
# NOTE(sdague): "openstack" is stand in for project_id, it
|
||||||
# should be more generic in future.
|
# should be more generic in future.
|
||||||
|
if self._project_id:
|
||||||
return '%s/%s/%s' % (self._get_host(), self.api_major_version,
|
return '%s/%s/%s' % (self._get_host(), self.api_major_version,
|
||||||
'openstack')
|
'openstack')
|
||||||
|
else:
|
||||||
|
return '%s/%s' % (self._get_host(), self.api_major_version)
|
||||||
|
|
||||||
def _get_response(self, url, method, body=None, strip_version=False,
|
def _get_response(self, url, method, body=None, strip_version=False,
|
||||||
api_version=None, headers=None):
|
api_version=None, headers=None):
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
import webob
|
import webob
|
||||||
import webob.dec
|
import webob.dec
|
||||||
|
|
||||||
|
import testscenarios
|
||||||
|
|
||||||
from nova.api import openstack as openstack_api
|
from nova.api import openstack as openstack_api
|
||||||
from nova.api.openstack import auth
|
from nova.api.openstack import auth
|
||||||
from nova.api.openstack import compute
|
from nova.api.openstack import compute
|
||||||
@@ -25,15 +27,29 @@ from nova import test
|
|||||||
from nova.tests.unit.api.openstack import fakes
|
from nova.tests.unit.api.openstack import fakes
|
||||||
|
|
||||||
|
|
||||||
class TestNoAuthMiddlewareV21(test.NoDBTestCase):
|
class TestNoAuthMiddleware(testscenarios.WithScenarios, test.NoDBTestCase):
|
||||||
|
|
||||||
|
scenarios = [
|
||||||
|
('project_id', {
|
||||||
|
'expected_url': 'http://localhost/v2.1/user1_project',
|
||||||
|
'auth_middleware': auth.NoAuthMiddleware}),
|
||||||
|
('no_project_id', {
|
||||||
|
'expected_url': 'http://localhost/v2.1',
|
||||||
|
'auth_middleware': auth.NoAuthMiddlewareV2_17}),
|
||||||
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestNoAuthMiddlewareV21, self).setUp()
|
super(TestNoAuthMiddleware, self).setUp()
|
||||||
fakes.stub_out_rate_limiting(self.stubs)
|
fakes.stub_out_rate_limiting(self.stubs)
|
||||||
fakes.stub_out_networking(self)
|
fakes.stub_out_networking(self)
|
||||||
self.wsgi_app = fakes.wsgi_app_v21(use_no_auth=True)
|
api_v21 = openstack_api.FaultWrapper(
|
||||||
self.req_url = '/v2'
|
self.auth_middleware(
|
||||||
self.expected_url = "http://localhost/v2/user1_project"
|
compute.APIRouterV21()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.wsgi_app = urlmap.URLMap()
|
||||||
|
self.wsgi_app['/v2.1'] = api_v21
|
||||||
|
self.req_url = '/v2.1'
|
||||||
|
|
||||||
def test_authorize_user(self):
|
def test_authorize_user(self):
|
||||||
req = webob.Request.blank(self.req_url)
|
req = webob.Request.blank(self.req_url)
|
||||||
@@ -66,16 +82,3 @@ class TestNoAuthMiddlewareV21(test.NoDBTestCase):
|
|||||||
self.assertEqual(result.status, '204 No Content')
|
self.assertEqual(result.status, '204 No Content')
|
||||||
self.assertNotIn('X-CDN-Management-Url', result.headers)
|
self.assertNotIn('X-CDN-Management-Url', result.headers)
|
||||||
self.assertNotIn('X-Storage-Url', result.headers)
|
self.assertNotIn('X-Storage-Url', result.headers)
|
||||||
|
|
||||||
|
|
||||||
class TestNoAuthMiddlewareV3(TestNoAuthMiddlewareV21):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super(TestNoAuthMiddlewareV3, self).setUp()
|
|
||||||
api_router = compute.APIRouterV3()
|
|
||||||
api_v3 = openstack_api.FaultWrapper(auth.NoAuthMiddlewareV3(
|
|
||||||
api_router))
|
|
||||||
self.wsgi_app = urlmap.URLMap()
|
|
||||||
self.wsgi_app['/v3'] = api_v3
|
|
||||||
self.req_url = '/v3'
|
|
||||||
self.expected_url = "http://localhost/v3"
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ EXP_VERSIONS = {
|
|||||||
"v2.1": {
|
"v2.1": {
|
||||||
"id": "v2.1",
|
"id": "v2.1",
|
||||||
"status": "CURRENT",
|
"status": "CURRENT",
|
||||||
"version": "2.17",
|
"version": "2.18",
|
||||||
"min_version": "2.1",
|
"min_version": "2.1",
|
||||||
"updated": "2013-07-23T11:33:21Z",
|
"updated": "2013-07-23T11:33:21Z",
|
||||||
"links": [
|
"links": [
|
||||||
@@ -128,7 +128,7 @@ class VersionsTestV20(test.NoDBTestCase):
|
|||||||
{
|
{
|
||||||
"id": "v2.1",
|
"id": "v2.1",
|
||||||
"status": "CURRENT",
|
"status": "CURRENT",
|
||||||
"version": "2.17",
|
"version": "2.18",
|
||||||
"min_version": "2.1",
|
"min_version": "2.1",
|
||||||
"updated": "2013-07-23T11:33:21Z",
|
"updated": "2013-07-23T11:33:21Z",
|
||||||
"links": [
|
"links": [
|
||||||
@@ -194,7 +194,7 @@ class VersionsTestV20(test.NoDBTestCase):
|
|||||||
self._test_get_version_2_detail('/', accept=accept)
|
self._test_get_version_2_detail('/', accept=accept)
|
||||||
|
|
||||||
def test_get_version_2_versions_invalid(self):
|
def test_get_version_2_versions_invalid(self):
|
||||||
req = webob.Request.blank('/v2/versions/1234')
|
req = webob.Request.blank('/v2/versions/1234/foo')
|
||||||
req.accept = "application/json"
|
req.accept = "application/json"
|
||||||
res = req.get_response(self.wsgi_app)
|
res = req.get_response(self.wsgi_app)
|
||||||
self.assertEqual(404, res.status_int)
|
self.assertEqual(404, res.status_int)
|
||||||
@@ -483,7 +483,7 @@ class VersionsTestV21(test.NoDBTestCase):
|
|||||||
self.assertEqual(expected, version)
|
self.assertEqual(expected, version)
|
||||||
|
|
||||||
def test_get_version_21_versions_invalid(self):
|
def test_get_version_21_versions_invalid(self):
|
||||||
req = webob.Request.blank('/v2.1/versions/1234')
|
req = webob.Request.blank('/v2.1/versions/1234/foo')
|
||||||
req.accept = "application/json"
|
req.accept = "application/json"
|
||||||
res = req.get_response(fakes.wsgi_app_v21())
|
res = req.get_response(fakes.wsgi_app_v21())
|
||||||
self.assertEqual(404, res.status_int)
|
self.assertEqual(404, res.status_int)
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ class ConfFixture(config_fixture.Config):
|
|||||||
group='api_database')
|
group='api_database')
|
||||||
self.conf.set_default('fatal_exception_format_errors', True)
|
self.conf.set_default('fatal_exception_format_errors', True)
|
||||||
self.conf.set_default('enabled', True, 'osapi_v21')
|
self.conf.set_default('enabled', True, 'osapi_v21')
|
||||||
|
# TODO(sdague): this makes our project_id match 'fake' and
|
||||||
|
# 'openstack' as well. We should fix the tests to use real
|
||||||
|
# UUIDs then drop this work around.
|
||||||
|
self.conf.set_default('project_id_regex',
|
||||||
|
'[0-9a-fopnstk\-]+', 'osapi_v21')
|
||||||
self.conf.set_default('force_dhcp_release', False)
|
self.conf.set_default('force_dhcp_release', False)
|
||||||
self.conf.set_default('periodic_enable', False)
|
self.conf.set_default('periodic_enable', False)
|
||||||
policy_opts.set_defaults(self.conf)
|
policy_opts.set_defaults(self.conf)
|
||||||
|
|||||||
16
releasenotes/notes/optional_project_id-6aebf1cb394d498f.yaml
Normal file
16
releasenotes/notes/optional_project_id-6aebf1cb394d498f.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
|
||||||
|
- Provides API 2.17, which makes the use of project_ids in API urls
|
||||||
|
optional.
|
||||||
|
|
||||||
|
upgrade:
|
||||||
|
|
||||||
|
- In order to make project_id optional in urls, we must constrain
|
||||||
|
the set of allowed values for project_id in our urls. This
|
||||||
|
defaults to a regex of ``[0-9a-f\-]+``, which will match hex uuids
|
||||||
|
(with / without dashes), and integers. This covers all known
|
||||||
|
project_id formats in the wild.
|
||||||
|
|
||||||
|
If your site uses other values for project_id, you can set a site
|
||||||
|
specific validation with ``project_id_regex`` config variable.
|
||||||
Reference in New Issue
Block a user