mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-28 11:26:26 -06:00
Add support for script and report packaging in plugins (#4573)
This commit is contained in:
parent
3abb52a085
commit
7532bca67c
@ -37,6 +37,8 @@ from dcim.constants import CONNECTION_STATUS_PLANNED
|
|||||||
from dcim.models import ConsolePort, Device, PowerPort
|
from dcim.models import ConsolePort, Device, PowerPort
|
||||||
from extras.reports import Report
|
from extras.reports import Report
|
||||||
|
|
||||||
|
name = "Device Connections Report"
|
||||||
|
|
||||||
|
|
||||||
class DeviceConnectionsReport(Report):
|
class DeviceConnectionsReport(Report):
|
||||||
description = "Validate the minimum physical connections for each device"
|
description = "Validate the minimum physical connections for each device"
|
||||||
|
@ -53,3 +53,27 @@ Plugin content that gets embedded into core NetBox templates. The store comprise
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `plugin_reports`
|
||||||
|
|
||||||
|
[Reports](../additional-features/reports.md) provided by plugins. Each plugin report module is registered as a key with the list of reports it provides as its value. An example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
'plugin_a.reports.first': [<Report>, <Report>],
|
||||||
|
'plugin_a.reports.second': [<Report>],
|
||||||
|
'plugin_b.reports': [<Report>, <Report>, <Report>],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `plugin_scripts`
|
||||||
|
|
||||||
|
[Custom scripts](../additional-features/custom-scripts.md) provided by plugins. Each plugin script module is registered as a key with the list of scripts it provides. An example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"plugin_a.scripts": [<Script>, <Script>, <Script>],
|
||||||
|
"plugin_b.scripts.script_1": [<Script>],
|
||||||
|
"plugin_b.scripts.script_2": [<Script>, <Script>],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
@ -9,6 +9,8 @@ Plugins can do a lot, including:
|
|||||||
* Inject template content and navigation links
|
* Inject template content and navigation links
|
||||||
* Establish their own REST API endpoints
|
* Establish their own REST API endpoints
|
||||||
* Add custom request/response middleware
|
* Add custom request/response middleware
|
||||||
|
* Provide [reports](../additional-features/reports.md)
|
||||||
|
* Provide [custom scripts](../additional-features/custom-scripts.md)
|
||||||
|
|
||||||
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
|
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
|
||||||
|
|
||||||
@ -27,6 +29,8 @@ plugin_name/
|
|||||||
- __init__.py
|
- __init__.py
|
||||||
- middleware.py
|
- middleware.py
|
||||||
- navigation.py
|
- navigation.py
|
||||||
|
- reports.py
|
||||||
|
- scripts.py
|
||||||
- signals.py
|
- signals.py
|
||||||
- template_content.py
|
- template_content.py
|
||||||
- urls.py
|
- urls.py
|
||||||
@ -109,6 +113,8 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
|||||||
| `caching_config` | Plugin-specific cache configuration
|
| `caching_config` | Plugin-specific cache configuration
|
||||||
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
|
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
|
||||||
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
|
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
|
||||||
|
| `reports` | The dotted path to the list of reports provided by the plugin (default: `reports.reports`) |
|
||||||
|
| `scripts` | The dotted path to the list of scripts provided by the plugin (default: `scripts.scripts`) |
|
||||||
|
|
||||||
### Install the Plugin for Development
|
### Install the Plugin for Development
|
||||||
|
|
||||||
@ -363,6 +369,59 @@ class SiteAnimalCount(PluginTemplateExtension):
|
|||||||
template_extensions = [SiteAnimalCount]
|
template_extensions = [SiteAnimalCount]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Reports
|
||||||
|
|
||||||
|
Plugins can package [reports](../additional-features/reports.md).
|
||||||
|
|
||||||
|
All reports (i.e., subclasses of `extras.reports.Report`) should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `reports` within a `reports.py` file or `reports` Python module. (This can be overridden by setting `reports` to a custom value on the plugin's PluginConfig.) An example is below.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from extras.reports import *
|
||||||
|
from .models import Animal
|
||||||
|
|
||||||
|
name = "Animal-related reports"
|
||||||
|
|
||||||
|
class AnimalReport(Report):
|
||||||
|
description = "Validate that every animal has a sound"
|
||||||
|
|
||||||
|
def test_sound(self):
|
||||||
|
for animal in Animal.objects.all():
|
||||||
|
if animal.sound:
|
||||||
|
self.log_success(animal, f"The {animal.name} says {animal.sound}")
|
||||||
|
else:
|
||||||
|
self.log_failure(animal, f"The {animal.name} makes no sound!")
|
||||||
|
|
||||||
|
reports = [AnimalReport]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
Plugins can package [custom scripts](../additional-features/custom-scripts.md) as well.
|
||||||
|
|
||||||
|
All script objects (i.e., subclasses of `extras.scripts.Script`) should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `scripts` within a `scripts.py` file or `scripts` Python module. (This can be overridden by setting `scripts` to a custom value on the plugin's PluginConfig). An example of a self-contained `scripts.py` file is below.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from extras.scripts import *
|
||||||
|
from .models import Animal
|
||||||
|
|
||||||
|
name = "Animal-related scripts"
|
||||||
|
|
||||||
|
class AnimalScript(Script):
|
||||||
|
class Meta:
|
||||||
|
description = "What does the animal say?"
|
||||||
|
|
||||||
|
animal = StringVar()
|
||||||
|
|
||||||
|
def run(self, data, commit):
|
||||||
|
try:
|
||||||
|
animal = Animal.objects.get(name=data['animal'])
|
||||||
|
self.log_success(f'The {animal.name} says "{animal.sound}"!')
|
||||||
|
except Animal.DoesNotExist:
|
||||||
|
self.log_failure(f'No such animal "{data["animal"]}"')
|
||||||
|
|
||||||
|
scripts = [AnimalScript]
|
||||||
|
```
|
||||||
|
|
||||||
## Caching Configuration
|
## Caching Configuration
|
||||||
|
|
||||||
By default, all query operations within a plugin are cached. To change this, define a caching configuration under the PluginConfig class' `caching_config` attribute. All configuration keys will be applied within the context of the plugin; there is no need to include the plugin name. An example configuration is below:
|
By default, all query operations within a plugin are cached. To change this, define a caching configuration under the PluginConfig class' `caching_config` attribute. All configuration keys will be applied within the context of the plugin; there is no need to include the plugin name. An example configuration is below:
|
||||||
|
@ -13,6 +13,8 @@ The NetBox plugin architecture allows for the following:
|
|||||||
* **Add content to existing model templates.** A template content class can be used to inject custom HTML content within the view of a core NetBox model. This content can appear in the left side, right side, or bottom of the page.
|
* **Add content to existing model templates.** A template content class can be used to inject custom HTML content within the view of a core NetBox model. This content can appear in the left side, right side, or bottom of the page.
|
||||||
* **Add navigation menu items.** Each plugin can register new links in the navigation menu. Each link may have a set of buttons for specific actions, similar to the built-in navigation items.
|
* **Add navigation menu items.** Each plugin can register new links in the navigation menu. Each link may have a set of buttons for specific actions, similar to the built-in navigation items.
|
||||||
* **Add custom middleware.** Custom Django middleware can be registered by each plugin.
|
* **Add custom middleware.** Custom Django middleware can be registered by each plugin.
|
||||||
|
* **Add custom reports.** Plugins can provide [reports](../additional-features/reports.md).
|
||||||
|
* **Add custom scripts.** Plugins can provide [custom scripts](../additional-features/custom-scripts.md).
|
||||||
* **Declare configuration parameters.** Each plugin can define required, optional, and default configuration parameters within its unique namespace. Plug configuration parameter are defined by the user under `PLUGINS_CONFIG` in `configuration.py`.
|
* **Declare configuration parameters.** Each plugin can define required, optional, and default configuration parameters within its unique namespace. Plug configuration parameter are defined by the user under `PLUGINS_CONFIG` in `configuration.py`.
|
||||||
* **Limit installation by NetBox version.** A plugin can specify a minimum and/or maximum NetBox version with which it is compatible.
|
* **Limit installation by NetBox version.** A plugin can specify a minimum and/or maximum NetBox version with which it is compatible.
|
||||||
|
|
||||||
|
7
docs/release-notes/version-2.9.md
Normal file
7
docs/release-notes/version-2.9.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Netbox v2.9
|
||||||
|
|
||||||
|
## v2.9.0 (FUTURE)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#4573](https://github.com/netbox-community/netbox/issues/4573) - Support plugins as a delivery mechanism for reports and custom scripts
|
@ -75,6 +75,7 @@ nav:
|
|||||||
- User Preferences: 'development/user-preferences.md'
|
- User Preferences: 'development/user-preferences.md'
|
||||||
- Release Checklist: 'development/release-checklist.md'
|
- Release Checklist: 'development/release-checklist.md'
|
||||||
- Release Notes:
|
- Release Notes:
|
||||||
|
- Version 2.9: 'release-notes/version-2.9.md'
|
||||||
- Version 2.8: 'release-notes/version-2.8.md'
|
- Version 2.8: 'release-notes/version-2.8.md'
|
||||||
- Version 2.7: 'release-notes/version-2.7.md'
|
- Version 2.7: 'release-notes/version-2.7.md'
|
||||||
- Version 2.6: 'release-notes/version-2.6.md'
|
- Version 2.6: 'release-notes/version-2.6.md'
|
||||||
|
@ -15,6 +15,8 @@ from utilities.choices import ButtonColorChoices
|
|||||||
# Initialize plugin registry stores
|
# Initialize plugin registry stores
|
||||||
registry['plugin_template_extensions'] = collections.defaultdict(list)
|
registry['plugin_template_extensions'] = collections.defaultdict(list)
|
||||||
registry['plugin_menu_items'] = {}
|
registry['plugin_menu_items'] = {}
|
||||||
|
registry['plugin_reports'] = collections.defaultdict(list)
|
||||||
|
registry['plugin_scripts'] = collections.defaultdict(list)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -56,6 +58,8 @@ class PluginConfig(AppConfig):
|
|||||||
# integrated components.
|
# integrated components.
|
||||||
template_extensions = 'template_content.template_extensions'
|
template_extensions = 'template_content.template_extensions'
|
||||||
menu_items = 'navigation.menu_items'
|
menu_items = 'navigation.menu_items'
|
||||||
|
reports = 'reports.reports'
|
||||||
|
scripts = 'scripts.scripts'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
|
|
||||||
@ -73,6 +77,20 @@ class PluginConfig(AppConfig):
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Register reports (if any)
|
||||||
|
try:
|
||||||
|
reports = import_string(f"{self.__module__}.{self.reports}")
|
||||||
|
register_reports(reports)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Register scripts (if any)
|
||||||
|
try:
|
||||||
|
scripts = import_string(f"{self.__module__}.{self.scripts}")
|
||||||
|
register_scripts(scripts)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, user_config):
|
def validate(cls, user_config):
|
||||||
|
|
||||||
@ -248,3 +266,29 @@ def register_menu_items(section_name, class_list):
|
|||||||
raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton")
|
raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton")
|
||||||
|
|
||||||
registry['plugin_menu_items'][section_name] = class_list
|
registry['plugin_menu_items'][section_name] = class_list
|
||||||
|
|
||||||
|
|
||||||
|
def register_reports(report_list):
|
||||||
|
"""
|
||||||
|
Register a list of Report classes for a given plugin.
|
||||||
|
"""
|
||||||
|
from extras.reports import Report
|
||||||
|
|
||||||
|
for report in report_list:
|
||||||
|
if not issubclass(report, Report):
|
||||||
|
raise TypeError(f"{report} must be a subclass of extras.reports.Report")
|
||||||
|
|
||||||
|
registry['plugin_reports'][report.__module__].append(report)
|
||||||
|
|
||||||
|
|
||||||
|
def register_scripts(script_list):
|
||||||
|
"""
|
||||||
|
Register a list of Script classes for a given plugin.
|
||||||
|
"""
|
||||||
|
from extras.scripts import Script
|
||||||
|
|
||||||
|
for script in script_list:
|
||||||
|
if not issubclass(script, Script):
|
||||||
|
raise TypeError(f"{script} must be a subclass of extras.scripts.Script")
|
||||||
|
|
||||||
|
registry['plugin_scripts'][script.__module__].append(script)
|
||||||
|
@ -9,6 +9,7 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from .constants import *
|
from .constants import *
|
||||||
from .models import ReportResult
|
from .models import ReportResult
|
||||||
|
from .registry import registry
|
||||||
|
|
||||||
|
|
||||||
def is_report(obj):
|
def is_report(obj):
|
||||||
@ -22,23 +23,18 @@ def get_report(module_name, report_name):
|
|||||||
"""
|
"""
|
||||||
Return a specific report from within a module.
|
Return a specific report from within a module.
|
||||||
"""
|
"""
|
||||||
file_path = '{}/{}.py'.format(settings.REPORTS_ROOT, module_name)
|
reports = get_reports()
|
||||||
|
for grouping, report_list in reports:
|
||||||
|
if grouping != module_name:
|
||||||
|
continue
|
||||||
|
for report in report_list:
|
||||||
|
if report.name == report_name:
|
||||||
|
return report
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
return None
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
try:
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
report = getattr(module, report_name, None)
|
|
||||||
if report is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return report()
|
|
||||||
|
|
||||||
|
|
||||||
def get_reports():
|
def get_reports(use_names=False):
|
||||||
"""
|
"""
|
||||||
Compile a list of all reports available across all modules in the reports path. Returns a list of tuples:
|
Compile a list of all reports available across all modules in the reports path. Returns a list of tuples:
|
||||||
|
|
||||||
@ -47,14 +43,26 @@ def get_reports():
|
|||||||
(module_name, (report, report, report, ...)),
|
(module_name, (report, report, report, ...)),
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
|
|
||||||
|
Set use_names to True to use each module's human-defined name in place of the actual module name.
|
||||||
"""
|
"""
|
||||||
module_list = []
|
module_list = []
|
||||||
|
|
||||||
|
# Iterate through reports provided by plugins
|
||||||
|
for module_name, report_classes in registry['plugin_reports'].items():
|
||||||
|
if use_names and report_classes:
|
||||||
|
module = inspect.getmodule(report_classes[0])
|
||||||
|
if hasattr(module, "name"):
|
||||||
|
module_name = module.name
|
||||||
|
module_list.append((module_name, [report() for report in report_classes]))
|
||||||
|
|
||||||
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
||||||
# defined.
|
# defined.
|
||||||
for importer, module_name, _ in pkgutil.iter_modules([settings.REPORTS_ROOT]):
|
for importer, module_name, _ in pkgutil.iter_modules([settings.REPORTS_ROOT]):
|
||||||
module = importer.find_module(module_name).load_module(module_name)
|
module = importer.find_module(module_name).load_module(module_name)
|
||||||
report_list = [cls() for _, cls in inspect.getmembers(module, is_report)]
|
report_list = [cls() for _, cls in inspect.getmembers(module, is_report)]
|
||||||
|
if use_names and hasattr(module, "name"):
|
||||||
|
module_name = module.name
|
||||||
module_list.append((module_name, report_list))
|
module_list.append((module_name, report_list))
|
||||||
|
|
||||||
return module_list
|
return module_list
|
||||||
|
@ -22,6 +22,7 @@ from utilities.exceptions import AbortTransaction
|
|||||||
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||||
from .forms import ScriptForm
|
from .forms import ScriptForm
|
||||||
from .signals import purge_changelog
|
from .signals import purge_changelog
|
||||||
|
from .registry import registry
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'BaseScript',
|
'BaseScript',
|
||||||
@ -440,9 +441,26 @@ def get_scripts(use_names=False):
|
|||||||
"""
|
"""
|
||||||
Return a dict of dicts mapping all scripts to their modules. Set use_names to True to use each module's human-
|
Return a dict of dicts mapping all scripts to their modules. Set use_names to True to use each module's human-
|
||||||
defined name in place of the actual module name.
|
defined name in place of the actual module name.
|
||||||
|
|
||||||
|
{
|
||||||
|
module_name: {script_name: <Script class>, script_name: <Script class>},
|
||||||
|
module_name: {script_name: <Script class>}
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
scripts = OrderedDict()
|
scripts = OrderedDict()
|
||||||
|
|
||||||
|
# Iterate through scripts provided by plugins
|
||||||
|
for module_name, script_list in registry['plugin_scripts'].items():
|
||||||
|
if use_names and script_list:
|
||||||
|
module = inspect.getmodule(script_list[0])
|
||||||
|
if hasattr(module, "name"):
|
||||||
|
module_name = module.name
|
||||||
|
module_scripts = OrderedDict()
|
||||||
|
for script in script_list:
|
||||||
|
module_scripts[script.__name__] = script
|
||||||
|
if module_scripts:
|
||||||
|
scripts[module_name] = module_scripts
|
||||||
|
|
||||||
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
||||||
# defined.
|
# defined.
|
||||||
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
||||||
|
8
netbox/extras/tests/dummy_plugin/reports.py
Normal file
8
netbox/extras/tests/dummy_plugin/reports.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from extras.reports import *
|
||||||
|
|
||||||
|
|
||||||
|
class PluginReport(Report):
|
||||||
|
description: "A dummy report. No-op."
|
||||||
|
|
||||||
|
|
||||||
|
reports = [PluginReport]
|
11
netbox/extras/tests/dummy_plugin/scripts.py
Normal file
11
netbox/extras/tests/dummy_plugin/scripts.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from extras.scripts import *
|
||||||
|
|
||||||
|
|
||||||
|
class PluginScript(Script):
|
||||||
|
animal = StringVar()
|
||||||
|
|
||||||
|
def run(self, data, commit):
|
||||||
|
print(f"Raaar! I'm a(n) {data['animal']}")
|
||||||
|
|
||||||
|
|
||||||
|
scripts = [PluginScript]
|
@ -74,6 +74,22 @@ class PluginTest(TestCase):
|
|||||||
|
|
||||||
self.assertIn(SiteContent, registry['plugin_template_extensions']['dcim.site'])
|
self.assertIn(SiteContent, registry['plugin_template_extensions']['dcim.site'])
|
||||||
|
|
||||||
|
def test_reports(self):
|
||||||
|
"""
|
||||||
|
Check that plugin Reports are registered.
|
||||||
|
"""
|
||||||
|
from extras.tests.dummy_plugin.reports import PluginReport
|
||||||
|
|
||||||
|
self.assertIn(PluginReport, registry['plugin_reports']['extras.tests.dummy_plugin.reports'])
|
||||||
|
|
||||||
|
def test_scripts(self):
|
||||||
|
"""
|
||||||
|
Check that plugin Scripts are registered.
|
||||||
|
"""
|
||||||
|
from extras.tests.dummy_plugin.scripts import PluginScript
|
||||||
|
|
||||||
|
self.assertIn(PluginScript, registry['plugin_scripts']['extras.tests.dummy_plugin.scripts'])
|
||||||
|
|
||||||
def test_middleware(self):
|
def test_middleware(self):
|
||||||
"""
|
"""
|
||||||
Check that plugin middleware is registered.
|
Check that plugin middleware is registered.
|
||||||
|
@ -340,7 +340,7 @@ class ReportListView(PermissionRequiredMixin, View):
|
|||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
|
||||||
reports = get_reports()
|
reports = get_reports(use_names=True)
|
||||||
results = {r.report: r for r in ReportResult.objects.all()}
|
results = {r.report: r for r in ReportResult.objects.all()}
|
||||||
|
|
||||||
ret = []
|
ret = []
|
||||||
@ -365,7 +365,7 @@ class ReportView(PermissionRequiredMixin, View):
|
|||||||
def get(self, request, name):
|
def get(self, request, name):
|
||||||
|
|
||||||
# Retrieve the Report by "<module>.<report>"
|
# Retrieve the Report by "<module>.<report>"
|
||||||
module_name, report_name = name.split('.')
|
module_name, report_name = name.rsplit('.', 1)
|
||||||
report = get_report(module_name, report_name)
|
report = get_report(module_name, report_name)
|
||||||
if report is None:
|
if report is None:
|
||||||
raise Http404
|
raise Http404
|
||||||
@ -388,7 +388,7 @@ class ReportRunView(PermissionRequiredMixin, View):
|
|||||||
def post(self, request, name):
|
def post(self, request, name):
|
||||||
|
|
||||||
# Retrieve the Report by "<module>.<report>"
|
# Retrieve the Report by "<module>.<report>"
|
||||||
module_name, report_name = name.split('.')
|
module_name, report_name = name.rsplit('.', 1)
|
||||||
report = get_report(module_name, report_name)
|
report = get_report(module_name, report_name)
|
||||||
if report is None:
|
if report is None:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
Loading…
Reference in New Issue
Block a user