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 extras.reports import Report
name = "Device Connections Report"
class DeviceConnectionsReport(Report):
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
* Establish their own REST API endpoints
* 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.
@ -27,6 +29,8 @@ plugin_name/
- __init__.py
- middleware.py
- navigation.py
- reports.py
- scripts.py
- signals.py
- template_content.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
| `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`) |
| `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
@ -363,6 +369,59 @@ class SiteAnimalCount(PluginTemplateExtension):
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
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 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 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`.
* **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'
- Release Checklist: 'development/release-checklist.md'
- Release Notes:
- Version 2.9: 'release-notes/version-2.9.md'
- Version 2.8: 'release-notes/version-2.8.md'
- Version 2.7: 'release-notes/version-2.7.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
registry['plugin_template_extensions'] = collections.defaultdict(list)
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.
template_extensions = 'template_content.template_extensions'
menu_items = 'navigation.menu_items'
reports = 'reports.reports'
scripts = 'scripts.scripts'
def ready(self):
@ -73,6 +77,20 @@ class PluginConfig(AppConfig):
except ImportError:
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
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")
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 .models import ReportResult
from .registry import registry
def is_report(obj):
@ -22,23 +23,18 @@ def get_report(module_name, report_name):
"""
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)
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:
@ -47,14 +43,26 @@ def get_reports():
(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 = []
# 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
# defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.REPORTS_ROOT]):
module = importer.find_module(module_name).load_module(module_name)
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))
return module_list

View File

@ -22,6 +22,7 @@ from utilities.exceptions import AbortTransaction
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .forms import ScriptForm
from .signals import purge_changelog
from .registry import registry
__all__ = [
'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-
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()
# 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
# defined.
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'])
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):
"""
Check that plugin middleware is registered.

View File

@ -340,7 +340,7 @@ class ReportListView(PermissionRequiredMixin, View):
def get(self, request):
reports = get_reports()
reports = get_reports(use_names=True)
results = {r.report: r for r in ReportResult.objects.all()}
ret = []
@ -365,7 +365,7 @@ class ReportView(PermissionRequiredMixin, View):
def get(self, request, name):
# 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)
if report is None:
raise Http404
@ -388,7 +388,7 @@ class ReportRunView(PermissionRequiredMixin, View):
def post(self, request, name):
# 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)
if report is None:
raise Http404