Add support for script and report packaging in plugins (#4573)

This commit is contained in:
Glenn Matthews 2020-05-11 16:47:06 -04:00
parent 3abb52a085
commit 7532bca67c
13 changed files with 217 additions and 17 deletions

View File

@ -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"

View File

@ -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>],
}
```

View File

@ -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:

View File

@ -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.

View 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

View File

@ -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'

View File

@ -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)

View File

@ -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

View File

@ -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]):

View File

@ -0,0 +1,8 @@
from extras.reports import *
class PluginReport(Report):
description: "A dummy report. No-op."
reports = [PluginReport]

View 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]

View File

@ -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.

View File

@ -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