mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Enable plugins to define user preferences
This commit is contained in:
@ -99,22 +99,23 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
|||||||
|
|
||||||
#### PluginConfig Attributes
|
#### PluginConfig Attributes
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
| ---- | ----------- |
|
| ---- |---------------------------------------------------------------------------------------------------------------|
|
||||||
| `name` | Raw plugin name; same as the plugin's source directory |
|
| `name` | Raw plugin name; same as the plugin's source directory |
|
||||||
| `verbose_name` | Human-friendly name for the plugin |
|
| `verbose_name` | Human-friendly name for the plugin |
|
||||||
| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) |
|
| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) |
|
||||||
| `description` | Brief description of the plugin's purpose |
|
| `description` | Brief description of the plugin's purpose |
|
||||||
| `author` | Name of plugin's author |
|
| `author` | Name of plugin's author |
|
||||||
| `author_email` | Author's public email address |
|
| `author_email` | Author's public email address |
|
||||||
| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. |
|
| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. |
|
||||||
| `required_settings` | A list of any configuration parameters that **must** be defined by the user |
|
| `required_settings` | A list of any configuration parameters that **must** be defined by the user |
|
||||||
| `default_settings` | A dictionary of configuration parameters and their default values |
|
| `default_settings` | A dictionary of configuration parameters and their default values |
|
||||||
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
|
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
|
||||||
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
|
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
|
||||||
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
|
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
|
||||||
| `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`) |
|
||||||
|
| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) |
|
||||||
|
|
||||||
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
|
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ from extras.plugins.utils import import_object
|
|||||||
# 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_preferences'] = {}
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -54,6 +55,7 @@ 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'
|
||||||
|
user_preferences = 'preferences.preferences'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
|
|
||||||
@ -67,6 +69,12 @@ class PluginConfig(AppConfig):
|
|||||||
if menu_items is not None:
|
if menu_items is not None:
|
||||||
register_menu_items(self.verbose_name, menu_items)
|
register_menu_items(self.verbose_name, menu_items)
|
||||||
|
|
||||||
|
# Register user preferences
|
||||||
|
user_preferences = import_object(f"{self.__module__}.{self.user_preferences}")
|
||||||
|
if user_preferences is not None:
|
||||||
|
plugin_name = self.name.rsplit('.', 1)[1]
|
||||||
|
register_user_preferences(plugin_name, user_preferences)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, user_config, netbox_version):
|
def validate(cls, user_config, netbox_version):
|
||||||
|
|
||||||
@ -242,3 +250,14 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# User preferences
|
||||||
|
#
|
||||||
|
|
||||||
|
def register_user_preferences(plugin_name, preferences):
|
||||||
|
"""
|
||||||
|
Register a list of user preferences defined by a plugin.
|
||||||
|
"""
|
||||||
|
registry['plugin_preferences'][plugin_name] = preferences
|
||||||
|
20
netbox/extras/tests/dummy_plugin/preferences.py
Normal file
20
netbox/extras/tests/dummy_plugin/preferences.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from users.preferences import UserPreference
|
||||||
|
|
||||||
|
|
||||||
|
preferences = {
|
||||||
|
'pref1': UserPreference(
|
||||||
|
label='First preference',
|
||||||
|
choices=(
|
||||||
|
('foo', 'Foo'),
|
||||||
|
('bar', 'Bar'),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'pref2': UserPreference(
|
||||||
|
label='Second preference',
|
||||||
|
choices=(
|
||||||
|
('a', 'A'),
|
||||||
|
('b', 'B'),
|
||||||
|
('c', 'C'),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
@ -74,6 +74,15 @@ class PluginTest(TestCase):
|
|||||||
|
|
||||||
self.assertIn(SiteContent, registry['plugin_template_extensions']['dcim.site'])
|
self.assertIn(SiteContent, registry['plugin_template_extensions']['dcim.site'])
|
||||||
|
|
||||||
|
def test_user_preferences(self):
|
||||||
|
"""
|
||||||
|
Check that plugin UserPreferences are registered.
|
||||||
|
"""
|
||||||
|
self.assertIn('dummy_plugin', registry['plugin_preferences'])
|
||||||
|
user_preferences = registry['plugin_preferences']['dummy_plugin']
|
||||||
|
self.assertEqual(type(user_preferences), dict)
|
||||||
|
self.assertEqual(list(user_preferences.keys()), ['pref1', 'pref2'])
|
||||||
|
|
||||||
def test_middleware(self):
|
def test_middleware(self):
|
||||||
"""
|
"""
|
||||||
Check that plugin middleware is registered.
|
Check that plugin middleware is registered.
|
||||||
|
49
netbox/netbox/preferences.py
Normal file
49
netbox/netbox/preferences.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from extras.registry import registry
|
||||||
|
from users.preferences import UserPreference
|
||||||
|
from utilities.paginator import EnhancedPaginator
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_lengths():
|
||||||
|
return [
|
||||||
|
(v, str(v)) for v in EnhancedPaginator.default_page_lengths
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
PREFERENCES = {
|
||||||
|
|
||||||
|
# User interface
|
||||||
|
'ui.colormode': UserPreference(
|
||||||
|
label='Color mode',
|
||||||
|
choices=(
|
||||||
|
('light', 'Light'),
|
||||||
|
('dark', 'Dark'),
|
||||||
|
),
|
||||||
|
default='light',
|
||||||
|
),
|
||||||
|
'pagination.per_page': UserPreference(
|
||||||
|
label='Page length',
|
||||||
|
choices=get_page_lengths(),
|
||||||
|
description='The number of objects to display per page',
|
||||||
|
coerce=lambda x: int(x)
|
||||||
|
),
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
'data_format': UserPreference(
|
||||||
|
label='Data format',
|
||||||
|
choices=(
|
||||||
|
('json', 'JSON'),
|
||||||
|
('yaml', 'YAML'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
# Register plugin preferences
|
||||||
|
if registry['plugin_preferences']:
|
||||||
|
plugin_preferences = {}
|
||||||
|
|
||||||
|
for plugin_name, preferences in registry['plugin_preferences'].items():
|
||||||
|
for name, userpreference in preferences.items():
|
||||||
|
PREFERENCES[f'plugins.{plugin_name}.{name}'] = userpreference
|
||||||
|
|
||||||
|
PREFERENCES.update(plugin_preferences)
|
@ -8,6 +8,7 @@
|
|||||||
<form method="post" action="" id="preferences-update">
|
<form method="post" action="" id="preferences-update">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
{% for group, fields in form.Meta.fieldsets %}
|
{% for group, fields in form.Meta.fieldsets %}
|
||||||
<div class="field-group my-5">
|
<div class="field-group my-5">
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
@ -18,6 +19,9 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% render_form form %}
|
||||||
|
|
||||||
<div class="text-end my-3">
|
<div class="text-end my-3">
|
||||||
<a class="btn btn-outline-secondary" href="{% url 'user:preferences' %}">Cancel</a>
|
<a class="btn btn-outline-secondary" href="{% url 'user:preferences' %}">Cancel</a>
|
||||||
|
@ -2,10 +2,10 @@ from django import forms
|
|||||||
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
|
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
|
||||||
from django.utils.html import mark_safe
|
from django.utils.html import mark_safe
|
||||||
|
|
||||||
|
from netbox.preferences import PREFERENCES
|
||||||
from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect
|
from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect
|
||||||
from utilities.utils import flatten_dict
|
from utilities.utils import flatten_dict
|
||||||
from .models import Token, UserConfig
|
from .models import Token, UserConfig
|
||||||
from .preferences import PREFERENCES
|
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(BootstrapMixin, AuthenticationForm):
|
class LoginForm(BootstrapMixin, AuthenticationForm):
|
||||||
@ -44,15 +44,6 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = UserConfig
|
model = UserConfig
|
||||||
fields = ()
|
fields = ()
|
||||||
fieldsets = (
|
|
||||||
('User Interface', (
|
|
||||||
'pagination.per_page',
|
|
||||||
'ui.colormode',
|
|
||||||
)),
|
|
||||||
('Miscellaneous', (
|
|
||||||
'data_format',
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *args, instance=None, **kwargs):
|
def __init__(self, *args, instance=None, **kwargs):
|
||||||
|
|
||||||
|
@ -1,12 +1,3 @@
|
|||||||
from utilities.paginator import EnhancedPaginator
|
|
||||||
|
|
||||||
|
|
||||||
def get_page_lengths():
|
|
||||||
return [
|
|
||||||
(v, str(v)) for v in EnhancedPaginator.default_page_lengths
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class UserPreference:
|
class UserPreference:
|
||||||
|
|
||||||
def __init__(self, label, choices, default=None, description='', coerce=lambda x: x):
|
def __init__(self, label, choices, default=None, description='', coerce=lambda x: x):
|
||||||
@ -15,33 +6,3 @@ class UserPreference:
|
|||||||
self.default = default if default is not None else choices[0]
|
self.default = default if default is not None else choices[0]
|
||||||
self.description = description
|
self.description = description
|
||||||
self.coerce = coerce
|
self.coerce = coerce
|
||||||
|
|
||||||
|
|
||||||
PREFERENCES = {
|
|
||||||
|
|
||||||
# User interface
|
|
||||||
'ui.colormode': UserPreference(
|
|
||||||
label='Color mode',
|
|
||||||
choices=(
|
|
||||||
('light', 'Light'),
|
|
||||||
('dark', 'Dark'),
|
|
||||||
),
|
|
||||||
default='light',
|
|
||||||
),
|
|
||||||
'pagination.per_page': UserPreference(
|
|
||||||
label='Page length',
|
|
||||||
choices=get_page_lengths(),
|
|
||||||
description='The number of objects to display per page',
|
|
||||||
coerce=lambda x: int(x)
|
|
||||||
),
|
|
||||||
|
|
||||||
# Miscellaneous
|
|
||||||
'data_format': UserPreference(
|
|
||||||
label='Data format',
|
|
||||||
choices=(
|
|
||||||
('json', 'JSON'),
|
|
||||||
('yaml', 'YAML'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
}
|
|
||||||
|
Reference in New Issue
Block a user