Merge "Plugin-based panel group configuration"
This commit is contained in:
commit
d7f33e1fcb
@ -539,7 +539,7 @@ create a file ``openstack_dashboard/local/enabled/_50_tuskar.py`` with::
|
|||||||
}
|
}
|
||||||
|
|
||||||
Pluggable Settings for Panels
|
Pluggable Settings for Panels
|
||||||
=================================
|
=============================
|
||||||
|
|
||||||
Panels customization can be made by providing a custom python module that
|
Panels customization can be made by providing a custom python module that
|
||||||
contains python code to add or remove panel to/from the dashboard. This
|
contains python code to add or remove panel to/from the dashboard. This
|
||||||
@ -625,3 +625,59 @@ following content::
|
|||||||
PANEL_DASHBOARD = 'admin'
|
PANEL_DASHBOARD = 'admin'
|
||||||
PANEL_GROUP = 'admin'
|
PANEL_GROUP = 'admin'
|
||||||
DEFAULT_PANEL = 'instances'
|
DEFAULT_PANEL = 'instances'
|
||||||
|
|
||||||
|
Pluggable Settings for Panel Groups
|
||||||
|
===================================
|
||||||
|
|
||||||
|
To organize the panels created from the pluggable settings, there is also
|
||||||
|
a way to create panel group though configuration file. This creates an empty
|
||||||
|
panel group to act as placeholder for the panels that can be created later.
|
||||||
|
|
||||||
|
The default location for the panel group configuration files is
|
||||||
|
``openstack_dashboard/enabled``, with another directory,
|
||||||
|
``openstack_dashboard/local/enabled`` for local overrides. Both sets of files
|
||||||
|
will be loaded, but the settings in ``openstack_dashboard/local/enabled`` will
|
||||||
|
overwrite the default ones. The settings are applied in alphabetical order of
|
||||||
|
the filenames. If the same panel has configuration files in ``enabled`` and
|
||||||
|
``local/enabled``, the local name will be used. Note, that since names of
|
||||||
|
python modules can't start with a digit, the files are usually named with a
|
||||||
|
leading underscore and a number, so that you can control their order easily.
|
||||||
|
|
||||||
|
When writing configuration files to create panels and panels group, make sure
|
||||||
|
that the panel group configuration file is loaded first because the panel
|
||||||
|
configuration might be referencing it. This can be achieved by providing a file
|
||||||
|
name that will go before the panel configuration file when the files are sorted
|
||||||
|
alphabetically.
|
||||||
|
|
||||||
|
The files contain following keys:
|
||||||
|
|
||||||
|
``PANEL_GROUP``
|
||||||
|
-------------
|
||||||
|
|
||||||
|
The name of the panel group to be added to ``HORIZON_CONFIG``. Required.
|
||||||
|
|
||||||
|
``PANEL_GROUP_NAME``
|
||||||
|
-------------
|
||||||
|
|
||||||
|
The display name of the PANEL_GROUP. Required.
|
||||||
|
|
||||||
|
``PANEL_GROUP_DASHBOARD``
|
||||||
|
-------------
|
||||||
|
|
||||||
|
The name of the dashboard the ``PANEL_GROUP`` associated with. Required.
|
||||||
|
|
||||||
|
``DISABLED``
|
||||||
|
------------
|
||||||
|
|
||||||
|
If set to ``True``, this panel configuration will be skipped.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
|
||||||
|
To add a new panel group to the Admin dashboard, create a file
|
||||||
|
``openstack_dashboard/local/enabled/_90_admin_add_panel_group.py`` with the
|
||||||
|
following content::
|
||||||
|
|
||||||
|
PANEL_GROUP = 'plugin_panel_group'
|
||||||
|
PANEL_GROUP_NAME = 'Plugin Panel Group'
|
||||||
|
PANEL_GROUP_DASHBOARD = 'admin'
|
||||||
|
@ -798,17 +798,27 @@ class Site(Registry, HorizonComponent):
|
|||||||
and make changes to the dashboard accordingly.
|
and make changes to the dashboard accordingly.
|
||||||
|
|
||||||
It supports adding, removing and setting default panels on the
|
It supports adding, removing and setting default panels on the
|
||||||
dashboard.
|
dashboard. It also support registering a panel group.
|
||||||
"""
|
"""
|
||||||
panel_customization = self._conf.get("panel_customization", [])
|
panel_customization = self._conf.get("panel_customization", [])
|
||||||
|
|
||||||
for config in panel_customization:
|
for config in panel_customization:
|
||||||
|
if config.get('PANEL'):
|
||||||
|
self._process_panel_configuration(config)
|
||||||
|
elif config.get('PANEL_GROUP'):
|
||||||
|
self._process_panel_group_configuration(config)
|
||||||
|
else:
|
||||||
|
LOG.warning("Skipping %s because it doesn't have PANEL or "
|
||||||
|
"PANEL_GROUP defined.", config.__name__)
|
||||||
|
|
||||||
|
def _process_panel_configuration(self, config):
|
||||||
|
"""Add, remove and set default panels on the dashboard."""
|
||||||
|
try:
|
||||||
dashboard = config.get('PANEL_DASHBOARD')
|
dashboard = config.get('PANEL_DASHBOARD')
|
||||||
if not dashboard:
|
if not dashboard:
|
||||||
LOG.warning("Skipping %s because it doesn't have "
|
LOG.warning("Skipping %s because it doesn't have "
|
||||||
"PANEL_DASHBOARD defined.", config.__name__)
|
"PANEL_DASHBOARD defined.", config.__name__)
|
||||||
continue
|
return
|
||||||
try:
|
|
||||||
panel_slug = config.get('PANEL')
|
panel_slug = config.get('PANEL')
|
||||||
dashboard_cls = self.get_dashboard(dashboard)
|
dashboard_cls = self.get_dashboard(dashboard)
|
||||||
panel_group = config.get('PANEL_GROUP')
|
panel_group = config.get('PANEL_GROUP')
|
||||||
@ -831,7 +841,7 @@ class Site(Registry, HorizonComponent):
|
|||||||
mod = import_module(mod_path)
|
mod = import_module(mod_path)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
LOG.warning("Could not load panel: %s", mod_path)
|
LOG.warning("Could not load panel: %s", mod_path)
|
||||||
continue
|
return
|
||||||
|
|
||||||
panel = getattr(mod, panel_cls)
|
panel = getattr(mod, panel_cls)
|
||||||
dashboard_cls.register(panel)
|
dashboard_cls.register(panel)
|
||||||
@ -846,6 +856,39 @@ class Site(Registry, HorizonComponent):
|
|||||||
LOG.warning('Could not process panel %(panel)s: %(exc)s',
|
LOG.warning('Could not process panel %(panel)s: %(exc)s',
|
||||||
{'panel': panel_slug, 'exc': e})
|
{'panel': panel_slug, 'exc': e})
|
||||||
|
|
||||||
|
def _process_panel_group_configuration(self, config):
|
||||||
|
"""Adds a panel group to the dashboard."""
|
||||||
|
panel_group_slug = config.get('PANEL_GROUP')
|
||||||
|
try:
|
||||||
|
dashboard = config.get('PANEL_GROUP_DASHBOARD')
|
||||||
|
if not dashboard:
|
||||||
|
LOG.warning("Skipping %s because it doesn't have "
|
||||||
|
"PANEL_GROUP_DASHBOARD defined.", config.__name__)
|
||||||
|
return
|
||||||
|
dashboard_cls = self.get_dashboard(dashboard)
|
||||||
|
|
||||||
|
panel_group_name = config.get('PANEL_GROUP_NAME')
|
||||||
|
if not panel_group_name:
|
||||||
|
LOG.warning("Skipping %s because it doesn't have "
|
||||||
|
"PANEL_GROUP_NAME defined.", config.__name__)
|
||||||
|
return
|
||||||
|
# Create the panel group class
|
||||||
|
panel_group = type(panel_group_slug,
|
||||||
|
(PanelGroup, ),
|
||||||
|
{'slug': panel_group_slug,
|
||||||
|
'name': panel_group_name},)
|
||||||
|
# Add the panel group to dashboard
|
||||||
|
panels = list(dashboard_cls.panels)
|
||||||
|
panels.append(panel_group)
|
||||||
|
dashboard_cls.panels = tuple(panels)
|
||||||
|
# Trigger the autodiscovery to completely load the new panel group
|
||||||
|
dashboard_cls._autodiscover_complete = False
|
||||||
|
dashboard_cls._autodiscover()
|
||||||
|
except Exception as e:
|
||||||
|
LOG.warning('Could not process panel group %(panel_group)s: '
|
||||||
|
'%(exc)s',
|
||||||
|
{'panel_group': panel_group_slug, 'exc': e})
|
||||||
|
|
||||||
|
|
||||||
class HorizonSite(Site):
|
class HorizonSite(Site):
|
||||||
"""A singleton implementation of Site such that all dealings with horizon
|
"""A singleton implementation of Site such that all dealings with horizon
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
# The name of the panel group to be added to HORIZON_CONFIG. Required.
|
||||||
|
PANEL_GROUP = 'plugin_panel_group'
|
||||||
|
# The display name of the PANEL_GROUP. Required.
|
||||||
|
PANEL_GROUP_NAME = 'Plugin Panel Group'
|
||||||
|
# The name of the dashboard the PANEL_GROUP associated with. Required.
|
||||||
|
PANEL_GROUP_DASHBOARD = 'admin'
|
@ -0,0 +1,10 @@
|
|||||||
|
# The name of the panel to be added to HORIZON_CONFIG. Required.
|
||||||
|
PANEL = 'plugin_panel'
|
||||||
|
# The name of the dashboard the PANEL associated with. Required.
|
||||||
|
PANEL_DASHBOARD = 'admin'
|
||||||
|
# The name of the panel group the PANEL is associated with.
|
||||||
|
PANEL_GROUP = 'plugin_panel_group'
|
||||||
|
|
||||||
|
# Python panel class of the PANEL to be added.
|
||||||
|
ADD_PANEL = \
|
||||||
|
'openstack_dashboard.test.test_panels.plugin_panel.panel.PluginPanel'
|
@ -25,6 +25,7 @@ from django.conf import settings
|
|||||||
from django.contrib.auth.middleware import AuthenticationMiddleware # noqa
|
from django.contrib.auth.middleware import AuthenticationMiddleware # noqa
|
||||||
from django.contrib.messages.storage import default_storage # noqa
|
from django.contrib.messages.storage import default_storage # noqa
|
||||||
from django.core.handlers import wsgi
|
from django.core.handlers import wsgi
|
||||||
|
from django.core import urlresolvers
|
||||||
from django import http
|
from django import http
|
||||||
from django.test.client import RequestFactory # noqa
|
from django.test.client import RequestFactory # noqa
|
||||||
from django.utils.importlib import import_module # noqa
|
from django.utils.importlib import import_module # noqa
|
||||||
@ -47,6 +48,8 @@ import mox
|
|||||||
from openstack_auth import user
|
from openstack_auth import user
|
||||||
from openstack_auth import utils
|
from openstack_auth import utils
|
||||||
|
|
||||||
|
from horizon import base
|
||||||
|
from horizon import conf
|
||||||
from horizon import middleware
|
from horizon import middleware
|
||||||
from horizon.test import helpers as horizon_helpers
|
from horizon.test import helpers as horizon_helpers
|
||||||
|
|
||||||
@ -415,3 +418,54 @@ def my_custom_sort(flavor):
|
|||||||
'm1.massive': 2,
|
'm1.massive': 2,
|
||||||
}
|
}
|
||||||
return sort_order[flavor.name]
|
return sort_order[flavor.name]
|
||||||
|
|
||||||
|
|
||||||
|
class PluginTestCase(TestCase):
|
||||||
|
"""The ``PluginTestCase`` class is for use with tests which deal with the
|
||||||
|
pluggable dashboard and panel configuration, it takes care of backing up
|
||||||
|
and restoring the Horizon configuration.
|
||||||
|
"""
|
||||||
|
def setUp(self):
|
||||||
|
super(PluginTestCase, self).setUp()
|
||||||
|
self.old_horizon_config = conf.HORIZON_CONFIG
|
||||||
|
conf.HORIZON_CONFIG = conf.LazySettings()
|
||||||
|
base.Horizon._urls()
|
||||||
|
# Trigger discovery, registration, and URLconf generation if it
|
||||||
|
# hasn't happened yet.
|
||||||
|
self.client.get("/")
|
||||||
|
# Store our original dashboards
|
||||||
|
self._discovered_dashboards = base.Horizon._registry.keys()
|
||||||
|
# Gather up and store our original panels for each dashboard
|
||||||
|
self._discovered_panels = {}
|
||||||
|
for dash in self._discovered_dashboards:
|
||||||
|
panels = base.Horizon._registry[dash]._registry.keys()
|
||||||
|
self._discovered_panels[dash] = panels
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super(PluginTestCase, self).tearDown()
|
||||||
|
conf.HORIZON_CONFIG = self.old_horizon_config
|
||||||
|
# Destroy our singleton and re-create it.
|
||||||
|
base.HorizonSite._instance = None
|
||||||
|
del base.Horizon
|
||||||
|
base.Horizon = base.HorizonSite()
|
||||||
|
# Reload the convenience references to Horizon stored in __init__
|
||||||
|
reload(import_module("horizon"))
|
||||||
|
# Re-register our original dashboards and panels.
|
||||||
|
# This is necessary because autodiscovery only works on the first
|
||||||
|
# import, and calling reload introduces innumerable additional
|
||||||
|
# problems. Manual re-registration is the only good way for testing.
|
||||||
|
for dash in self._discovered_dashboards:
|
||||||
|
base.Horizon.register(dash)
|
||||||
|
for panel in self._discovered_panels[dash]:
|
||||||
|
dash.register(panel)
|
||||||
|
self._reload_urls()
|
||||||
|
|
||||||
|
def _reload_urls(self):
|
||||||
|
"""Clears out the URL caches, reloads the root urls module, and
|
||||||
|
re-triggers the autodiscovery mechanism for Horizon. Allows URLs
|
||||||
|
to be re-calculated after registering new dashboards. Useful
|
||||||
|
only for testing and should never be used on a live site.
|
||||||
|
"""
|
||||||
|
urlresolvers.clear_url_caches()
|
||||||
|
reload(import_module(settings.ROOT_URLCONF))
|
||||||
|
base.Horizon._urls()
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
# The name of the panel group to be added to HORIZON_CONFIG. Required.
|
||||||
|
PANEL_GROUP = 'plugin_panel_group'
|
||||||
|
# The display name of the PANEL_GROUP. Required.
|
||||||
|
PANEL_GROUP_NAME = 'Plugin Panel Group'
|
||||||
|
# The name of the dashboard the PANEL_GROUP associated with. Required.
|
||||||
|
PANEL_GROUP_DASHBOARD = 'admin'
|
@ -0,0 +1,10 @@
|
|||||||
|
# The name of the panel to be added to HORIZON_CONFIG. Required.
|
||||||
|
PANEL = 'plugin_panel'
|
||||||
|
# The name of the dashboard the PANEL associated with. Required.
|
||||||
|
PANEL_DASHBOARD = 'admin'
|
||||||
|
# The name of the panel group the PANEL is associated with.
|
||||||
|
PANEL_GROUP = 'plugin_panel_group'
|
||||||
|
|
||||||
|
# Python panel class of the PANEL to be added.
|
||||||
|
ADD_PANEL = \
|
||||||
|
'openstack_dashboard.test.test_panels.plugin_panel.panel.PluginPanel'
|
48
openstack_dashboard/test/test_plugins/panel_group_tests.py
Normal file
48
openstack_dashboard/test/test_plugins/panel_group_tests.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# 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 copy
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
import horizon
|
||||||
|
|
||||||
|
from openstack_dashboard.test import helpers as test
|
||||||
|
from openstack_dashboard.test.test_panels.plugin_panel \
|
||||||
|
import panel as plugin_panel
|
||||||
|
import openstack_dashboard.test.test_plugins.panel_group_config
|
||||||
|
from openstack_dashboard.utils import settings as util_settings
|
||||||
|
|
||||||
|
|
||||||
|
PANEL_GROUP_SLUG = 'plugin_panel_group'
|
||||||
|
HORIZON_CONFIG = copy.deepcopy(settings.HORIZON_CONFIG)
|
||||||
|
INSTALLED_APPS = list(settings.INSTALLED_APPS)
|
||||||
|
|
||||||
|
util_settings.update_dashboards([
|
||||||
|
openstack_dashboard.test.test_plugins.panel_group_config,
|
||||||
|
], HORIZON_CONFIG, INSTALLED_APPS)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(HORIZON_CONFIG=HORIZON_CONFIG,
|
||||||
|
INSTALLED_APPS=INSTALLED_APPS)
|
||||||
|
class PanelGroupPluginTests(test.PluginTestCase):
|
||||||
|
def test_add_panel_group(self):
|
||||||
|
dashboard = horizon.get_dashboard("admin")
|
||||||
|
self.assertIsNotNone(dashboard.get_panel_group(PANEL_GROUP_SLUG))
|
||||||
|
|
||||||
|
def test_add_panel(self):
|
||||||
|
dashboard = horizon.get_dashboard("admin")
|
||||||
|
self.assertIn(plugin_panel.PluginPanel,
|
||||||
|
[p.__class__ for p in dashboard.get_panels()])
|
@ -15,13 +15,9 @@
|
|||||||
import copy
|
import copy
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import urlresolvers
|
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from django.utils.importlib import import_module # noqa
|
|
||||||
|
|
||||||
import horizon
|
import horizon
|
||||||
from horizon import base
|
|
||||||
from horizon import conf
|
|
||||||
|
|
||||||
from openstack_dashboard.dashboards.admin.info import panel as info_panel
|
from openstack_dashboard.dashboards.admin.info import panel as info_panel
|
||||||
from openstack_dashboard.test import helpers as test
|
from openstack_dashboard.test import helpers as test
|
||||||
@ -31,7 +27,7 @@ import openstack_dashboard.test.test_plugins.panel_config
|
|||||||
from openstack_dashboard.utils import settings as util_settings
|
from openstack_dashboard.utils import settings as util_settings
|
||||||
|
|
||||||
|
|
||||||
HORIZON_CONFIG = copy.copy(settings.HORIZON_CONFIG)
|
HORIZON_CONFIG = copy.deepcopy(settings.HORIZON_CONFIG)
|
||||||
INSTALLED_APPS = list(settings.INSTALLED_APPS)
|
INSTALLED_APPS = list(settings.INSTALLED_APPS)
|
||||||
|
|
||||||
util_settings.update_dashboards([
|
util_settings.update_dashboards([
|
||||||
@ -41,36 +37,7 @@ util_settings.update_dashboards([
|
|||||||
|
|
||||||
@override_settings(HORIZON_CONFIG=HORIZON_CONFIG,
|
@override_settings(HORIZON_CONFIG=HORIZON_CONFIG,
|
||||||
INSTALLED_APPS=INSTALLED_APPS)
|
INSTALLED_APPS=INSTALLED_APPS)
|
||||||
class PanelPluginTests(test.TestCase):
|
class PanelPluginTests(test.PluginTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super(PanelPluginTests, self).setUp()
|
|
||||||
self.old_horizon_config = conf.HORIZON_CONFIG
|
|
||||||
conf.HORIZON_CONFIG = conf.LazySettings()
|
|
||||||
base.Horizon._urls()
|
|
||||||
# Trigger discovery, registration, and URLconf generation if it
|
|
||||||
# hasn't happened yet.
|
|
||||||
self.client.get("/")
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
super(PanelPluginTests, self).tearDown()
|
|
||||||
conf.HORIZON_CONFIG = self.old_horizon_config
|
|
||||||
# Destroy our singleton and re-create it.
|
|
||||||
base.HorizonSite._instance = None
|
|
||||||
del base.Horizon
|
|
||||||
base.Horizon = base.HorizonSite()
|
|
||||||
self._reload_urls()
|
|
||||||
|
|
||||||
def _reload_urls(self):
|
|
||||||
"""Clears out the URL caches, reloads the root urls module, and
|
|
||||||
re-triggers the autodiscovery mechanism for Horizon. Allows URLs
|
|
||||||
to be re-calculated after registering new dashboards. Useful
|
|
||||||
only for testing and should never be used on a live site.
|
|
||||||
"""
|
|
||||||
urlresolvers.clear_url_caches()
|
|
||||||
reload(import_module(settings.ROOT_URLCONF))
|
|
||||||
base.Horizon._urls()
|
|
||||||
|
|
||||||
def test_add_panel(self):
|
def test_add_panel(self):
|
||||||
dashboard = horizon.get_dashboard("admin")
|
dashboard = horizon.get_dashboard("admin")
|
||||||
self.assertIn(plugin_panel.PluginPanel,
|
self.assertIn(plugin_panel.PluginPanel,
|
||||||
|
@ -44,12 +44,13 @@ def import_dashboard_config(modules):
|
|||||||
if hasattr(submodule, 'DASHBOARD'):
|
if hasattr(submodule, 'DASHBOARD'):
|
||||||
dashboard = submodule.DASHBOARD
|
dashboard = submodule.DASHBOARD
|
||||||
config[dashboard].update(submodule.__dict__)
|
config[dashboard].update(submodule.__dict__)
|
||||||
elif hasattr(submodule, 'PANEL'):
|
elif (hasattr(submodule, 'PANEL')
|
||||||
|
or hasattr(submodule, 'PANEL_GROUP')):
|
||||||
config[submodule.__name__] = submodule.__dict__
|
config[submodule.__name__] = submodule.__dict__
|
||||||
#_update_panels(config, submodule)
|
|
||||||
else:
|
else:
|
||||||
logging.warning("Skipping %s because it doesn't have DASHBOARD"
|
logging.warning("Skipping %s because it doesn't have DASHBOARD"
|
||||||
" or PANEL defined.", submodule.__name__)
|
", PANEL or PANEL_GROUP defined.",
|
||||||
|
submodule.__name__)
|
||||||
return sorted(config.iteritems(),
|
return sorted(config.iteritems(),
|
||||||
key=lambda c: c[1]['__name__'].rsplit('.', 1))
|
key=lambda c: c[1]['__name__'].rsplit('.', 1))
|
||||||
|
|
||||||
@ -98,7 +99,7 @@ def update_dashboards(modules, horizon_config, installed_apps):
|
|||||||
apps.extend(config.get('ADD_INSTALLED_APPS', []))
|
apps.extend(config.get('ADD_INSTALLED_APPS', []))
|
||||||
if config.get('DEFAULT', False):
|
if config.get('DEFAULT', False):
|
||||||
horizon_config['default_dashboard'] = dashboard
|
horizon_config['default_dashboard'] = dashboard
|
||||||
elif config.get('PANEL'):
|
elif config.get('PANEL') or config.get('PANEL_GROUP'):
|
||||||
panel_customization.append(config)
|
panel_customization.append(config)
|
||||||
horizon_config['panel_customization'] = panel_customization
|
horizon_config['panel_customization'] = panel_customization
|
||||||
horizon_config['dashboards'] = tuple(dashboards)
|
horizon_config['dashboards'] = tuple(dashboards)
|
||||||
|
Loading…
Reference in New Issue
Block a user