Merge "Adds the foundation for datastore capabilities"
This commit is contained in:
commit
4e5693d9f7
@ -65,6 +65,16 @@ class NotFound(TroveError):
|
||||
message = _("Resource %(uuid)s cannot be found")
|
||||
|
||||
|
||||
class CapabilityNotFound(NotFound):
|
||||
|
||||
message = _("Capability '%(capability)s' cannot be found.")
|
||||
|
||||
|
||||
class CapabilityDisabled(TroveError):
|
||||
|
||||
message = _("Capability '%(capability)s' is disabled.")
|
||||
|
||||
|
||||
class FlavorNotFound(TroveError):
|
||||
|
||||
message = _("Resource %(uuid)s cannot be found")
|
||||
|
@ -32,7 +32,9 @@ db_api = get_db_api()
|
||||
def persisted_models():
|
||||
return {
|
||||
'datastore': DBDatastore,
|
||||
'capabilities': DBCapabilities,
|
||||
'datastore_version': DBDatastoreVersion,
|
||||
'capability_overrides': DBCapabilityOverrides,
|
||||
}
|
||||
|
||||
|
||||
@ -41,12 +43,284 @@ class DBDatastore(dbmodels.DatabaseModelBase):
|
||||
_data_fields = ['id', 'name', 'default_version_id']
|
||||
|
||||
|
||||
class DBCapabilities(dbmodels.DatabaseModelBase):
|
||||
|
||||
_data_fields = ['id', 'name', 'description', 'enabled']
|
||||
|
||||
|
||||
class DBCapabilityOverrides(dbmodels.DatabaseModelBase):
|
||||
|
||||
_data_fields = ['id', 'capability_id', 'datastore_version_id', 'enabled']
|
||||
|
||||
|
||||
class DBDatastoreVersion(dbmodels.DatabaseModelBase):
|
||||
|
||||
_data_fields = ['id', 'datastore_id', 'name', 'manager', 'image_id',
|
||||
'packages', 'active']
|
||||
|
||||
|
||||
class Capabilities(object):
|
||||
|
||||
def __init__(self, datastore_version_id=None):
|
||||
self.capabilities = []
|
||||
self.datastore_version_id = datastore_version_id
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in [capability.name for capability in self.capabilities]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.capabilities)
|
||||
|
||||
def __iter__(self):
|
||||
for item in self.capabilities:
|
||||
yield item
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s: %s>' % (type(self), self.capabilities)
|
||||
|
||||
def add(self, capability, enabled):
|
||||
"""
|
||||
Add a capability override to a datastore version.
|
||||
"""
|
||||
if self.datastore_version_id is not None:
|
||||
DBCapabilityOverrides.create(
|
||||
capability_id=capability.id,
|
||||
datastore_version_id=self.datastore_version_id,
|
||||
enabled=enabled)
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
"""
|
||||
Bulk load and override default capabilities with configured
|
||||
datastore version specific settings.
|
||||
"""
|
||||
capability_defaults = [Capability(c)
|
||||
for c in DBCapabilities.find_all()]
|
||||
|
||||
capability_overrides = []
|
||||
if self.datastore_version_id is not None:
|
||||
# This should always happen but if there is any future case where
|
||||
# we don't have a datastore version id number it won't stop
|
||||
# defaults from rendering.
|
||||
capability_overrides = [
|
||||
CapabilityOverride(ce)
|
||||
for ce in DBCapabilityOverrides.find_all(
|
||||
datastore_version_id=self.datastore_version_id)
|
||||
]
|
||||
|
||||
def override(cap):
|
||||
# This logic is necessary to apply datastore version specific
|
||||
# capability overrides when they are present in the database.
|
||||
for capability_override in capability_overrides:
|
||||
if cap.id == capability_override.capability_id:
|
||||
# we have a mapped entity that indicates this datastore
|
||||
# version has an override so we honor that.
|
||||
return capability_override
|
||||
|
||||
# There were no overrides for this capability so we just hand it
|
||||
# right back.
|
||||
return cap
|
||||
|
||||
self.capabilities = map(override, capability_defaults)
|
||||
|
||||
LOG.debug('Capabilities for datastore %(ds_id)s: %(capabilities)s' %
|
||||
{'ds_id': self.datastore_version_id,
|
||||
'capabilities': self.capabilities})
|
||||
|
||||
@classmethod
|
||||
def load(cls, datastore_version_id=None):
|
||||
"""
|
||||
Generates a Capabilities object by looking up all capabilities from
|
||||
defaults and overrides and provides the one structure that should be
|
||||
used as the interface to controlling capabilities per datastore.
|
||||
|
||||
:returns Capabilities:
|
||||
"""
|
||||
self = cls(datastore_version_id)
|
||||
self._load()
|
||||
return self
|
||||
|
||||
|
||||
class BaseCapability(object):
|
||||
def __init__(self, db_info):
|
||||
self.db_info = db_info
|
||||
|
||||
def __repr__(self):
|
||||
return ('<%(my_class)s: name: %(name)s, enabled: %(enabled)s>' %
|
||||
{'my_class': type(self), 'name': self.name,
|
||||
'enabled': self.enabled})
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
"""
|
||||
The capability's id
|
||||
|
||||
:returns str:
|
||||
"""
|
||||
return self.db_info.id
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
"""
|
||||
Is the capability/feature enabled?
|
||||
|
||||
:returns bool:
|
||||
"""
|
||||
return self.db_info.enabled
|
||||
|
||||
def enable(self):
|
||||
"""
|
||||
Enable the capability.
|
||||
"""
|
||||
self.db_info.enabled = True
|
||||
self.db_info.save()
|
||||
|
||||
def disable(self):
|
||||
"""
|
||||
Disable the capability
|
||||
"""
|
||||
self.db_info.enabled = False
|
||||
self.db_info.save()
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Delete the capability from the database.
|
||||
"""
|
||||
|
||||
self.db_info.delete()
|
||||
|
||||
|
||||
class CapabilityOverride(BaseCapability):
|
||||
"""
|
||||
A capability override is simply an setting that applies to a
|
||||
specific datastore version that overrides the default setting in the
|
||||
base capability's entry for Trove.
|
||||
"""
|
||||
def __init__(self, db_info):
|
||||
super(CapabilityOverride, self).__init__(db_info)
|
||||
# This *may* be better solved with a join in the SQLAlchemy model but
|
||||
# I was unable to get our query object to work properly for this.
|
||||
parent_capability = Capability.load(db_info.capability_id)
|
||||
if parent_capability:
|
||||
self.parent_name = parent_capability.name
|
||||
self.parent_description = parent_capability.description
|
||||
else:
|
||||
raise exception.CapabilityNotFound(
|
||||
_("Somehow we got a datastore version capability without a "
|
||||
"parent, that shouldn't happen. %s") % db_info.capability_id)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
The name of the capability.
|
||||
|
||||
:returns str:
|
||||
"""
|
||||
return self.parent_name
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""
|
||||
The description of the capability.
|
||||
|
||||
:returns str:
|
||||
"""
|
||||
return self.parent_description
|
||||
|
||||
@property
|
||||
def capability_id(self):
|
||||
"""
|
||||
Because capability overrides is an association table there are times
|
||||
where having the capability id is necessary.
|
||||
|
||||
:returns str:
|
||||
"""
|
||||
return self.db_info.capability_id
|
||||
|
||||
@classmethod
|
||||
def load(cls, capability_id):
|
||||
"""
|
||||
Generates a CapabilityOverride object from the capability_override id.
|
||||
|
||||
:returns CapabilityOverride:
|
||||
"""
|
||||
try:
|
||||
return cls(DBCapabilityOverrides.find_by(
|
||||
capability_id=capability_id))
|
||||
except exception.ModelNotFoundError:
|
||||
raise exception.CapabilityNotFound(
|
||||
_("Capability Override not found for "
|
||||
"capability %s") % capability_id)
|
||||
|
||||
@classmethod
|
||||
def create(cls, capability, datastore_version_id, enabled):
|
||||
"""
|
||||
Create a new CapabilityOverride.
|
||||
|
||||
:param capability: The capability to be overridden for
|
||||
this DS Version
|
||||
:param datastore_version_id: The datastore version to apply the
|
||||
override to.
|
||||
:param enabled: Set enabled to True or False
|
||||
|
||||
:returns CapabilityOverride:
|
||||
"""
|
||||
|
||||
return CapabilityOverride(
|
||||
DBCapabilityOverrides.create(
|
||||
capability_id=capability.id,
|
||||
datastore_version_id=datastore_version_id,
|
||||
enabled=enabled)
|
||||
)
|
||||
|
||||
|
||||
class Capability(BaseCapability):
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
The Capability name
|
||||
|
||||
:returns str:
|
||||
"""
|
||||
return self.db_info.name
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""
|
||||
The Capability description
|
||||
|
||||
:returns str:
|
||||
"""
|
||||
return self.db_info.description
|
||||
|
||||
@classmethod
|
||||
def load(cls, capability_id_or_name):
|
||||
"""
|
||||
Generates a Capability object by looking up the capability first by
|
||||
ID then by name.
|
||||
|
||||
:returns Capability:
|
||||
"""
|
||||
try:
|
||||
return cls(DBCapabilities.find_by(id=capability_id_or_name))
|
||||
except exception.ModelNotFoundError:
|
||||
try:
|
||||
return cls(DBCapabilities.find_by(name=capability_id_or_name))
|
||||
except exception.ModelNotFoundError:
|
||||
raise exception.CapabilityNotFound(
|
||||
capability=capability_id_or_name)
|
||||
|
||||
@classmethod
|
||||
def create(cls, name, description, enabled=False):
|
||||
"""
|
||||
Creates a new capability.
|
||||
|
||||
:returns Capability:
|
||||
"""
|
||||
return Capability(DBCapabilities.create(
|
||||
name=name, description=description, enabled=enabled))
|
||||
|
||||
|
||||
class Datastore(object):
|
||||
|
||||
def __init__(self, db_info):
|
||||
@ -74,6 +348,9 @@ class Datastore(object):
|
||||
def default_version_id(self):
|
||||
return self.db_info.default_version_id
|
||||
|
||||
def delete(self):
|
||||
self.db_info.delete()
|
||||
|
||||
|
||||
class Datastores(object):
|
||||
|
||||
@ -96,6 +373,7 @@ class Datastores(object):
|
||||
class DatastoreVersion(object):
|
||||
|
||||
def __init__(self, db_info):
|
||||
self._capabilities = None
|
||||
self.db_info = db_info
|
||||
self._datastore_name = None
|
||||
|
||||
@ -156,6 +434,13 @@ class DatastoreVersion(object):
|
||||
def manager(self):
|
||||
return self.db_info.manager
|
||||
|
||||
@property
|
||||
def capabilities(self):
|
||||
if self._capabilities is None:
|
||||
self._capabilities = Capabilities.load(self.db_info.id)
|
||||
|
||||
return self._capabilities
|
||||
|
||||
|
||||
class DatastoreVersions(object):
|
||||
|
||||
|
@ -32,6 +32,10 @@ def map(engine, models):
|
||||
Table('datastores', meta, autoload=True))
|
||||
orm.mapper(models['datastore_version'],
|
||||
Table('datastore_versions', meta, autoload=True))
|
||||
orm.mapper(models['capabilities'],
|
||||
Table('capabilities', meta, autoload=True))
|
||||
orm.mapper(models['capability_overrides'],
|
||||
Table('capability_overrides', meta, autoload=True))
|
||||
orm.mapper(models['service_statuses'],
|
||||
Table('service_statuses', meta, autoload=True))
|
||||
orm.mapper(models['dns_records'],
|
||||
|
@ -0,0 +1,61 @@
|
||||
# Copyright (c) 2014 Rackspace Hosting
|
||||
#
|
||||
# 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 sqlalchemy import ForeignKey
|
||||
from sqlalchemy.schema import Column
|
||||
from sqlalchemy.schema import MetaData
|
||||
from sqlalchemy.schema import UniqueConstraint
|
||||
|
||||
from trove.db.sqlalchemy.migrate_repo.schema import create_tables
|
||||
from trove.db.sqlalchemy.migrate_repo.schema import drop_tables
|
||||
from trove.db.sqlalchemy.migrate_repo.schema import String
|
||||
from trove.db.sqlalchemy.migrate_repo.schema import Table
|
||||
from trove.db.sqlalchemy.migrate_repo.schema import Boolean
|
||||
|
||||
|
||||
meta = MetaData()
|
||||
|
||||
capabilities = Table(
|
||||
'capabilities',
|
||||
meta,
|
||||
Column('id', String(36), primary_key=True, nullable=False),
|
||||
Column('name', String(255), unique=True),
|
||||
Column('description', String(255), nullable=False),
|
||||
Column('enabled', Boolean())
|
||||
)
|
||||
|
||||
|
||||
capability_overrides = Table(
|
||||
'capability_overrides',
|
||||
meta,
|
||||
Column('id', String(36), primary_key=True, nullable=False),
|
||||
Column('datastore_version_id', String(36),
|
||||
ForeignKey('datastore_versions.id')),
|
||||
Column('capability_id', String(36), ForeignKey('capabilities.id')),
|
||||
Column('enabled', Boolean()),
|
||||
UniqueConstraint('datastore_version_id', 'capability_id',
|
||||
name='idx_datastore_capabilities_enabled')
|
||||
)
|
||||
|
||||
|
||||
def upgrade(migrate_engine):
|
||||
meta.bind = migrate_engine
|
||||
Table('datastores', meta, autoload=True)
|
||||
Table('datastore_versions', meta, autoload=True)
|
||||
create_tables([capabilities, capability_overrides])
|
||||
|
||||
|
||||
def downgrade(migrate_engine):
|
||||
meta.bind = migrate_engine
|
||||
drop_tables([capabilities, capability_overrides])
|
73
trove/tests/unittests/datastore/base.py
Normal file
73
trove/tests/unittests/datastore/base.py
Normal file
@ -0,0 +1,73 @@
|
||||
# Copyright (c) 2014 Rackspace Hosting
|
||||
#
|
||||
# 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 testtools
|
||||
from trove.datastore import models as datastore_models
|
||||
from trove.datastore.models import Datastore
|
||||
from trove.datastore.models import Capability
|
||||
from trove.datastore.models import DatastoreVersion
|
||||
from trove.datastore.models import DBCapabilityOverrides
|
||||
from trove.tests.unittests.util import util
|
||||
import uuid
|
||||
|
||||
|
||||
class TestDatastoreBase(testtools.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Basic setup and mock/fake structures for testing only
|
||||
super(TestDatastoreBase, self).setUp()
|
||||
util.init_db()
|
||||
self.rand_id = str(uuid.uuid4())
|
||||
self.ds_name = "my-test-datastore" + self.rand_id
|
||||
self.ds_version = "my-test-version" + self.rand_id
|
||||
self.capability_name = "root_on_create" + self.rand_id
|
||||
self.capability_desc = "Enables root on create"
|
||||
self.capability_enabled = True
|
||||
|
||||
datastore_models.update_datastore(self.ds_name, False)
|
||||
self.datastore = Datastore.load(self.ds_name)
|
||||
|
||||
datastore_models.update_datastore_version(
|
||||
self.ds_name, self.ds_version, "mysql", "", "", True)
|
||||
|
||||
self.datastore_version = DatastoreVersion.load(self.datastore,
|
||||
self.ds_version)
|
||||
self.test_id = self.datastore_version.id
|
||||
|
||||
self.cap1 = Capability.create(self.capability_name,
|
||||
self.capability_desc, True)
|
||||
self.cap2 = Capability.create("require_volume" + self.rand_id,
|
||||
"Require external volume", True)
|
||||
self.cap3 = Capability.create("test_capability" + self.rand_id,
|
||||
"Test capability", False)
|
||||
|
||||
def tearDown(self):
|
||||
super(TestDatastoreBase, self).tearDown()
|
||||
capabilities_overridden = DBCapabilityOverrides.find_all(
|
||||
datastore_version_id=self.datastore_version.id).all()
|
||||
|
||||
for ce in capabilities_overridden:
|
||||
ce.delete()
|
||||
|
||||
self.cap1.delete()
|
||||
self.cap2.delete()
|
||||
self.cap3.delete()
|
||||
Datastore.load(self.ds_name).delete()
|
||||
|
||||
def capability_name_filter(self, capabilities):
|
||||
new_capabilities = []
|
||||
for capability in capabilities:
|
||||
if self.rand_id in capability.name:
|
||||
new_capabilities.append(capability)
|
||||
return new_capabilities
|
52
trove/tests/unittests/datastore/test_capability.py
Normal file
52
trove/tests/unittests/datastore/test_capability.py
Normal file
@ -0,0 +1,52 @@
|
||||
# Copyright (c) 2014 Rackspace Hosting
|
||||
#
|
||||
# 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 trove.tests.unittests.datastore.base import TestDatastoreBase
|
||||
from trove.datastore.models import CapabilityOverride
|
||||
from trove.datastore.models import Capability
|
||||
from trove.common.exception import CapabilityNotFound
|
||||
|
||||
|
||||
class TestCapabilities(TestDatastoreBase):
|
||||
def setUp(self):
|
||||
super(TestCapabilities, self).setUp()
|
||||
|
||||
def tearDown(self):
|
||||
super(TestCapabilities, self).tearDown()
|
||||
|
||||
def test_capability(self):
|
||||
cap = Capability.load(self.capability_name)
|
||||
self.assertEqual(cap.name, self.capability_name)
|
||||
self.assertEqual(cap.description, self.capability_desc)
|
||||
self.assertEqual(cap.enabled, self.capability_enabled)
|
||||
|
||||
def test_ds_capability_create_disabled(self):
|
||||
self.ds_cap = CapabilityOverride.create(
|
||||
self.cap1, self.datastore_version.id, enabled=False)
|
||||
self.assertFalse(self.ds_cap.enabled)
|
||||
|
||||
self.ds_cap.delete()
|
||||
|
||||
def test_capability_enabled(self):
|
||||
self.assertTrue(Capability.load(self.capability_name).enabled)
|
||||
|
||||
def test_capability_disabled(self):
|
||||
capability = Capability.load(self.capability_name)
|
||||
capability.disable()
|
||||
self.assertFalse(capability.enabled)
|
||||
|
||||
self.assertFalse(Capability.load(self.capability_name).enabled)
|
||||
|
||||
def test_load_nonexistent_capability(self):
|
||||
self.assertRaises(CapabilityNotFound, Capability.load, "non-existent")
|
@ -13,16 +13,19 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from testtools import TestCase
|
||||
from trove.datastore import models as datastore_models
|
||||
from trove.common.exception import DatastoreDefaultDatastoreNotFound
|
||||
from trove.datastore.models import Datastore
|
||||
from trove.tests.unittests.datastore.base import TestDatastoreBase
|
||||
from trove.common import exception
|
||||
|
||||
|
||||
class TestDatastore(TestCase):
|
||||
def setUp(self):
|
||||
super(TestDatastore, self).setUp()
|
||||
class TestDatastore(TestDatastoreBase):
|
||||
|
||||
def test_create_failure_with_datastore_default_notfound(self):
|
||||
self.assertRaises(
|
||||
DatastoreDefaultDatastoreNotFound,
|
||||
exception.DatastoreDefaultDatastoreNotFound,
|
||||
datastore_models.get_datastore_version)
|
||||
|
||||
def test_load_datastore(self):
|
||||
datastore = Datastore.load(self.ds_name)
|
||||
self.assertEqual(datastore.name, self.ds_name)
|
||||
|
49
trove/tests/unittests/datastore/test_datastore_versions.py
Normal file
49
trove/tests/unittests/datastore/test_datastore_versions.py
Normal file
@ -0,0 +1,49 @@
|
||||
# Copyright (c) 2014 Rackspace Hosting
|
||||
#
|
||||
# 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 trove.datastore.models import DatastoreVersion
|
||||
from trove.tests.unittests.datastore.base import TestDatastoreBase
|
||||
|
||||
|
||||
class TestDatastoreVersions(TestDatastoreBase):
|
||||
|
||||
def test_load_datastore_version(self):
|
||||
datastore_version = DatastoreVersion.load(self.datastore,
|
||||
self.ds_version)
|
||||
self.assertEqual(datastore_version.name, self.ds_version)
|
||||
|
||||
def test_datastore_verison_capabilities(self):
|
||||
self.datastore_version.capabilities.add(self.cap1, enabled=False)
|
||||
test_filtered_capabilities = self.capability_name_filter(
|
||||
self.datastore_version.capabilities)
|
||||
self.assertEqual(len(test_filtered_capabilities), 3,
|
||||
'Capabilities the test thinks it has are: %s, '
|
||||
'Filtered capabilities: %s' %
|
||||
(self.datastore_version.capabilities,
|
||||
test_filtered_capabilities))
|
||||
|
||||
# Test a fresh reloading of the datastore
|
||||
self.datastore_version = DatastoreVersion.load(self.datastore,
|
||||
self.ds_version)
|
||||
test_filtered_capabilities = self.capability_name_filter(
|
||||
self.datastore_version.capabilities)
|
||||
self.assertEqual(len(test_filtered_capabilities), 3,
|
||||
'Capabilities the test thinks it has are: %s, '
|
||||
'Filtered capabilities: %s' %
|
||||
(self.datastore_version.capabilities,
|
||||
test_filtered_capabilities))
|
||||
|
||||
self.assertIn(self.cap2.name, self.datastore_version.capabilities)
|
||||
self.assertNotIn("non-existent", self.datastore_version.capabilities)
|
||||
self.assertIn(self.cap1.name, self.datastore_version.capabilities)
|
Loading…
x
Reference in New Issue
Block a user