From 70f257b1ea64aae4d6e5868d2eb3d2e7c3889420 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Dec 2021 16:29:01 -0500 Subject: [PATCH 1/9] Introduce UserConfigForm for managing user preferences --- netbox/project-static/dist/netbox.js | Bin 374756 -> 374514 bytes netbox/project-static/dist/netbox.js.map | Bin 344228 -> 343986 bytes netbox/project-static/src/buttons/index.ts | 2 - .../project-static/src/buttons/preferences.ts | 30 -------- netbox/templates/users/preferences.html | 60 ++++------------ netbox/users/forms.py | 66 +++++++++++++++++- netbox/users/views.py | 30 ++++---- netbox/utilities/utils.py | 2 +- 8 files changed, 94 insertions(+), 96 deletions(-) delete mode 100644 netbox/project-static/src/buttons/preferences.ts diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 95fd99270f6418fc224d471cf1054aa65323fd0b..740fbe7e7888ea3f0e235cb7cd4f86b72477685f 100644 GIT binary patch delta 22234 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 delta 22377 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 R@~s*p>XgsFRpXuae*o+S3akJC diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 6fbe0874b1025b63008a8cc41168eb25890d4295..116aad5e6e4c2d8846a76397b4d64cf9ddd5647c 100644 GIT binary patch delta 83 zcmZ3|D7xvt$cB}6lmFUrOnzzSH+iL>N!E2NOqL$o#Z*}PWB?nh8+-r& delta 303 zcmdn=Ut~$6=!TVclmFX^3ni77l;r0X>lYNIrll68<|U_2o@JNXEN0&>X3q%3OhC-M zUCf@P;TB7Yi>1r<5;@j&EcM17UOJJEjuuWj?v9T6PCCwxt{E;Mwy%z-qoY5BmF(!8 z1Y(7Q2v40*N5^2Gth=R?jw6T<6a?{stZXN+A^wiedCnl=M4doKXD>_z!H&*`UCXPE%bktE!U)SvoOFtv9G$D2Ay#>VtnzhqjsThIt&<6~FVhKVgkz|V lr*nZ*f%EoRlB{!B7&E4u%dwhFpAgT&!x*t$MVYlv1^`4WSF`{C diff --git a/netbox/project-static/src/buttons/index.ts b/netbox/project-static/src/buttons/index.ts index 251e0feaf..6a9001cd1 100644 --- a/netbox/project-static/src/buttons/index.ts +++ b/netbox/project-static/src/buttons/index.ts @@ -1,7 +1,6 @@ import { initConnectionToggle } from './connectionToggle'; import { initDepthToggle } from './depthToggle'; import { initMoveButtons } from './moveOptions'; -import { initPreferenceUpdate } from './preferences'; import { initReslug } from './reslug'; import { initSelectAll } from './selectAll'; @@ -11,7 +10,6 @@ export function initButtons(): void { initConnectionToggle, initReslug, initSelectAll, - initPreferenceUpdate, initMoveButtons, ]) { func(); diff --git a/netbox/project-static/src/buttons/preferences.ts b/netbox/project-static/src/buttons/preferences.ts deleted file mode 100644 index 6e8b21c02..000000000 --- a/netbox/project-static/src/buttons/preferences.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { setColorMode } from '../colorMode'; -import { getElement } from '../util'; - -/** - * Perform actions in the UI based on the value of user profile updates. - * - * @param event Form Submit - */ -function handlePreferenceSave(event: Event): void { - // Create a FormData instance to access the form values. - const form = event.currentTarget as HTMLFormElement; - const formData = new FormData(form); - - // Update the UI color mode immediately when the user preference changes. - if (formData.get('ui.colormode') === 'dark') { - setColorMode('dark'); - } else if (formData.get('ui.colormode') === 'light') { - setColorMode('light'); - } -} - -/** - * Initialize handlers for user profile updates. - */ -export function initPreferenceUpdate(): void { - const form = getElement('preferences-update'); - if (form !== null) { - form.addEventListener('submit', handlePreferenceSave); - } -} diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html index bbb92bde0..254b5b8ff 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -1,57 +1,27 @@ {% extends 'users/base.html' %} {% load helpers %} +{% load form_helpers %} {% block title %}User Preferences{% endblock %} {% block content %}
{% csrf_token %} -
-
Color Mode
-

Set preferred UI color mode

- {% with color_mode=preferences|get_key:'ui.colormode'%} -
- - + + {% for group, fields in form.Meta.fieldsets %} +
+
+
{{ group }}
+
+ {% for name in fields %} + {% render_field form|getfield:name %} + {% endfor %}
-
- - -
- {% endwith %} + {% endfor %} + +
+ Cancel +
-
-
- -
-
- {% if preferences %} -
-
Other Preferences
- - - - - - - - - - {% for key, value in preferences.items %} - - - - - - {% endfor %} - -
PreferenceValue
{{ key }}{{ value }}
- -
- {% else %} -

No preferences found

- {% endif %} {% endblock %} diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 8bd54cb66..7007ef958 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -2,7 +2,9 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from utilities.forms import BootstrapMixin, DateTimePicker -from .models import Token +from utilities.paginator import EnhancedPaginator +from utilities.utils import flatten_dict +from .models import Token, UserConfig class LoginForm(BootstrapMixin, AuthenticationForm): @@ -13,6 +15,68 @@ class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): pass +def get_page_lengths(): + return [ + (v, str(v)) for v in EnhancedPaginator.default_page_lengths + ] + + +class UserConfigForm(BootstrapMixin, forms.ModelForm): + pagination__per_page = forms.TypedChoiceField( + label='Page length', + coerce=lambda val: int(val), + choices=get_page_lengths, + required=False + ) + ui__colormode = forms.ChoiceField( + label='Color mode', + choices=( + ('light', 'Light'), + ('dark', 'Dark'), + ), + required=False + ) + extras__configcontext__format = forms.ChoiceField( + label='ConfigContext format', + choices=( + ('json', 'JSON'), + ('yaml', 'YAML'), + ), + required=False + ) + + class Meta: + model = UserConfig + fields = () + fieldsets = ( + ('User Interface', ( + 'pagination__per_page', + 'ui__colormode', + )), + ('Miscellaneous', ( + 'extras__configcontext__format', + )), + ) + + def __init__(self, *args, instance=None, **kwargs): + + # Get initial data from UserConfig instance + initial_data = flatten_dict(instance.data, separator='__') + kwargs['initial'] = initial_data + + super().__init__(*args, instance=instance, **kwargs) + + def save(self, *args, **kwargs): + + # Set UserConfig data + for field_name, value in self.cleaned_data.items(): + pref_name = field_name.replace('__', '.') + print(f'{pref_name}: {value}') + self.instance.set(pref_name, value, commit=False) + + return super().save(*args, **kwargs) + + class TokenForm(BootstrapMixin, forms.ModelForm): key = forms.CharField( required=False, diff --git a/netbox/users/views.py b/netbox/users/views.py index ecf3295b5..cd3c34aa9 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -19,7 +19,7 @@ from extras.models import ObjectChange from extras.tables import ObjectChangeTable from netbox.config import get_config from utilities.forms import ConfirmationForm -from .forms import LoginForm, PasswordChangeForm, TokenForm +from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm from .models import Token @@ -137,32 +137,28 @@ class UserConfigView(LoginRequiredMixin, View): template_name = 'users/preferences.html' def get(self, request): + userconfig = request.user.config + form = UserConfigForm(instance=userconfig) return render(request, self.template_name, { - 'preferences': request.user.config.all(), + 'form': form, 'active_tab': 'preferences', }) def post(self, request): userconfig = request.user.config - data = userconfig.all() + form = UserConfigForm(request.POST, instance=userconfig) - # Delete selected preferences - if "_delete" in request.POST: - for key in request.POST.getlist('pk'): - if key in data: - userconfig.clear(key) - # Update specific values - elif "_update" in request.POST: - for key in request.POST: - if not key.startswith('_') and not key.startswith('csrf'): - for value in request.POST.getlist(key): - userconfig.set(key, value) + if form.is_valid(): + form.save() - userconfig.save() - messages.success(request, "Your preferences have been updated.") + messages.success(request, "Your preferences have been updated.") + return redirect('user:preferences') - return redirect('user:preferences') + return render(request, self.template_name, { + 'form': form, + 'active_tab': 'preferences', + }) class ChangePasswordView(LoginRequiredMixin, View): diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 3234135fb..3fc50ddc4 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -282,7 +282,7 @@ def flatten_dict(d, prefix='', separator='.'): for k, v in d.items(): key = separator.join([prefix, k]) if prefix else k if type(v) is dict: - ret.update(flatten_dict(v, prefix=key)) + ret.update(flatten_dict(v, prefix=key, separator=separator)) else: ret[key] = v return ret From 36d2422eefa25d2414b2141dca518e8111a55736 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Dec 2021 17:05:06 -0500 Subject: [PATCH 2/9] Introduce UserPreference to define user preferences --- netbox/users/forms.py | 60 ++++++++++++++++--------------------- netbox/users/preferences.py | 46 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 35 deletions(-) create mode 100644 netbox/users/preferences.py diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 7007ef958..c4e55c5bc 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -2,9 +2,9 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from utilities.forms import BootstrapMixin, DateTimePicker -from utilities.paginator import EnhancedPaginator from utilities.utils import flatten_dict from .models import Token, UserConfig +from .preferences import PREFERENCES class LoginForm(BootstrapMixin, AuthenticationForm): @@ -15,53 +15,45 @@ class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): pass -def get_page_lengths(): - return [ - (v, str(v)) for v in EnhancedPaginator.default_page_lengths - ] +class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): + + def __new__(mcs, name, bases, attrs): + + # Emulate a declared field for each supported user preference + preference_fields = {} + for field_name, preference in PREFERENCES.items(): + field_kwargs = { + 'label': preference.label, + 'choices': preference.choices, + 'help_text': preference.description, + 'coerce': preference.coerce, + 'required': False, + } + preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs) + attrs.update(preference_fields) + + return super().__new__(mcs, name, bases, attrs) -class UserConfigForm(BootstrapMixin, forms.ModelForm): - pagination__per_page = forms.TypedChoiceField( - label='Page length', - coerce=lambda val: int(val), - choices=get_page_lengths, - required=False - ) - ui__colormode = forms.ChoiceField( - label='Color mode', - choices=( - ('light', 'Light'), - ('dark', 'Dark'), - ), - required=False - ) - extras__configcontext__format = forms.ChoiceField( - label='ConfigContext format', - choices=( - ('json', 'JSON'), - ('yaml', 'YAML'), - ), - required=False - ) +class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass): class Meta: model = UserConfig fields = () fieldsets = ( ('User Interface', ( - 'pagination__per_page', - 'ui__colormode', + 'pagination.per_page', + 'ui.colormode', )), ('Miscellaneous', ( - 'extras__configcontext__format', + 'data_format', )), ) def __init__(self, *args, instance=None, **kwargs): # Get initial data from UserConfig instance - initial_data = flatten_dict(instance.data, separator='__') + initial_data = flatten_dict(instance.data) kwargs['initial'] = initial_data super().__init__(*args, instance=instance, **kwargs) @@ -69,9 +61,7 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm): def save(self, *args, **kwargs): # Set UserConfig data - for field_name, value in self.cleaned_data.items(): - pref_name = field_name.replace('__', '.') - print(f'{pref_name}: {value}') + for pref_name, value in self.cleaned_data.items(): self.instance.set(pref_name, value, commit=False) return super().save(*args, **kwargs) diff --git a/netbox/users/preferences.py b/netbox/users/preferences.py new file mode 100644 index 000000000..18c3dbac0 --- /dev/null +++ b/netbox/users/preferences.py @@ -0,0 +1,46 @@ +from utilities.paginator import EnhancedPaginator + + +def get_page_lengths(): + return [ + (v, str(v)) for v in EnhancedPaginator.default_page_lengths + ] + + +class UserPreference: + + def __init__(self, label, choices, default=None, description='', coerce=lambda x: x): + self.label = label + self.choices = choices + self.default = default if default is not None else choices[0] + self.description = description + 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(), + coerce=lambda x: int(x) + ), + + # Miscellaneous + 'data_format': UserPreference( + label='Data format', + choices=( + ('json', 'JSON'), + ('yaml', 'YAML'), + ), + ), + +} From 2c01e178c7ea43fc04e0b811ffd540325670a681 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Dec 2021 19:59:33 -0500 Subject: [PATCH 3/9] Update config context display to reference data_format preference --- docs/development/user-preferences.md | 11 ++++++----- netbox/extras/views.py | 8 ++++---- netbox/users/forms.py | 3 ++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md index 0595bc358..a707eb6ad 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -4,8 +4,9 @@ The `users.UserConfig` model holds individual preferences for each user in the f ## Available Preferences -| Name | Description | -| ---- | ----------- | -| extras.configcontext.format | Preferred format when rendering config context data (JSON or YAML) | -| pagination.per_page | The number of items to display per page of a paginated table | -| tables.TABLE_NAME.columns | The ordered list of columns to display when viewing the table | +| Name | Description | +|-------------------------|-------------| +| data_format | Preferred format when rendering raw data (JSON or YAML) | +| pagination.per_page | The number of items to display per page of a paginated table | +| tables.${table}.columns | The ordered list of columns to display when viewing the table | +| ui.colormode | Light or dark mode in the user interface | diff --git a/netbox/extras/views.py b/netbox/extras/views.py index a2bc92f88..256709c6a 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -296,9 +296,9 @@ class ConfigContextView(generic.ObjectView): if request.GET.get('format') in ['json', 'yaml']: format = request.GET.get('format') if request.user.is_authenticated: - request.user.config.set('extras.configcontext.format', format, commit=True) + request.user.config.set('data_format', format, commit=True) elif request.user.is_authenticated: - format = request.user.config.get('extras.configcontext.format', 'json') + format = request.user.config.get('data_format', 'json') else: format = 'json' @@ -341,9 +341,9 @@ class ObjectConfigContextView(generic.ObjectView): if request.GET.get('format') in ['json', 'yaml']: format = request.GET.get('format') if request.user.is_authenticated: - request.user.config.set('extras.configcontext.format', format, commit=True) + request.user.config.set('data_format', format, commit=True) elif request.user.is_authenticated: - format = request.user.config.get('extras.configcontext.format', 'json') + format = request.user.config.get('data_format', 'json') else: format = 'json' diff --git a/netbox/users/forms.py b/netbox/users/forms.py index c4e55c5bc..5a4b1c2ff 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,7 +1,7 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm -from utilities.forms import BootstrapMixin, DateTimePicker +from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect from utilities.utils import flatten_dict from .models import Token, UserConfig from .preferences import PREFERENCES @@ -28,6 +28,7 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): 'help_text': preference.description, 'coerce': preference.coerce, 'required': False, + 'widget': StaticSelect, } preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs) attrs.update(preference_fields) From 1eeac7f4f45f63bad3b371b8a72d8575aea2693d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Dec 2021 20:30:59 -0500 Subject: [PATCH 4/9] Introduce DEFAULT_USER_PREFERENCES dynamic config setting --- docs/configuration/dynamic-settings.md | 16 ++++++++++++++++ netbox/extras/admin.py | 3 +++ netbox/netbox/config/parameters.py | 9 +++++++++ netbox/users/forms.py | 5 ++++- netbox/users/models.py | 4 +++- netbox/users/preferences.py | 1 + 6 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index a222272c2..5649eb9be 100644 --- a/docs/configuration/dynamic-settings.md +++ b/docs/configuration/dynamic-settings.md @@ -66,6 +66,22 @@ CUSTOM_VALIDATORS = { --- +## DEFAULT_USER_PREFERENCES + +This is a dictionary defining the default preferences to be set for newly-created user accounts. For example, to set the default page size for all users to 100, define the following: + +```python +DEFAULT_USER_PREFERENCES = { + "pagination": { + "per_page": 100 + } +} +``` + +For a complete list of available preferences, log into NetBox and navigate to `/user/preferences/`. A period in a preference name indicates a level of nesting in the JSON data. The example above maps to `pagination.per_page`. + +--- + ## ENFORCE_GLOBAL_UNIQUE Default: False diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index b6ee01db9..2c98d2a81 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -33,6 +33,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): ('NAPALM', { 'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'), }), + ('User Preferences', { + 'fields': ('DEFAULT_USER_PREFERENCES',), + }), ('Miscellaneous', { 'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'), }), diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index b4f16bf28..d3ebc7bff 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -131,6 +131,15 @@ PARAMS = ( field=forms.JSONField ), + # User preferences + ConfigParam( + name='DEFAULT_USER_PREFERENCES', + label='Default preferences', + default={}, + description="Default preferences for new users", + field=forms.JSONField + ), + # Miscellaneous ConfigParam( name='MAINTENANCE_MODE', diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 5a4b1c2ff..721c68e43 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django.utils.html import mark_safe from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect from utilities.utils import flatten_dict @@ -22,10 +23,12 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): # Emulate a declared field for each supported user preference preference_fields = {} for field_name, preference in PREFERENCES.items(): + description = f'{preference.description}
' if preference.description else '' + help_text = f'{description}{field_name}' field_kwargs = { 'label': preference.label, 'choices': preference.choices, - 'help_text': preference.description, + 'help_text': mark_safe(help_text), 'coerce': preference.coerce, 'required': False, 'widget': StaticSelect, diff --git a/netbox/users/models.py b/netbox/users/models.py index 64b6432a7..7b768b57f 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -10,6 +10,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from netbox.config import get_config from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict @@ -166,7 +167,8 @@ def create_userconfig(instance, created, **kwargs): Automatically create a new UserConfig when a new User is created. """ if created: - UserConfig(user=instance).save() + config = get_config() + UserConfig(user=instance, data=config.DEFAULT_USER_PREFERENCES).save() # diff --git a/netbox/users/preferences.py b/netbox/users/preferences.py index 18c3dbac0..635393913 100644 --- a/netbox/users/preferences.py +++ b/netbox/users/preferences.py @@ -31,6 +31,7 @@ PREFERENCES = { '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) ), From 1aafcf241fe537025f8a5860d468320c1d2202d5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Dec 2021 09:10:50 -0500 Subject: [PATCH 5/9] Enable plugins to define user preferences --- docs/plugins/development.md | 33 +++++++------ netbox/extras/plugins/__init__.py | 19 +++++++ .../extras/tests/dummy_plugin/preferences.py | 20 ++++++++ netbox/extras/tests/test_plugins.py | 9 ++++ netbox/netbox/preferences.py | 49 +++++++++++++++++++ netbox/templates/users/preferences.html | 4 ++ netbox/users/forms.py | 11 +---- netbox/users/preferences.py | 39 --------------- 8 files changed, 119 insertions(+), 65 deletions(-) create mode 100644 netbox/extras/tests/dummy_plugin/preferences.py create mode 100644 netbox/netbox/preferences.py diff --git a/docs/plugins/development.md b/docs/plugins/development.md index 89436a321..d20f73cb6 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -99,22 +99,23 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i #### PluginConfig Attributes -| Name | Description | -| ---- | ----------- | -| `name` | Raw plugin name; same as the plugin's source directory | -| `verbose_name` | Human-friendly name for the plugin | -| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | -| `description` | Brief description of the plugin's purpose | -| `author` | Name of plugin's author | -| `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. | -| `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 | -| `min_version` | Minimum 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 | -| `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`) | +| Name | Description | +| ---- |---------------------------------------------------------------------------------------------------------------| +| `name` | Raw plugin name; same as the plugin's source directory | +| `verbose_name` | Human-friendly name for the plugin | +| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | +| `description` | Brief description of the plugin's purpose | +| `author` | Name of plugin's author | +| `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. | +| `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 | +| `min_version` | Minimum 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 | +| `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`) | +| `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. diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index f9a7856ea..5b02b5ab7 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -15,6 +15,7 @@ from extras.plugins.utils import import_object # Initialize plugin registry stores registry['plugin_template_extensions'] = collections.defaultdict(list) registry['plugin_menu_items'] = {} +registry['plugin_preferences'] = {} # @@ -54,6 +55,7 @@ class PluginConfig(AppConfig): # integrated components. template_extensions = 'template_content.template_extensions' menu_items = 'navigation.menu_items' + user_preferences = 'preferences.preferences' def ready(self): @@ -67,6 +69,12 @@ class PluginConfig(AppConfig): if menu_items is not None: 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 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") 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 diff --git a/netbox/extras/tests/dummy_plugin/preferences.py b/netbox/extras/tests/dummy_plugin/preferences.py new file mode 100644 index 000000000..f925ee6e0 --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/preferences.py @@ -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'), + ) + ), +} diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 2508ffb83..4bea9933e 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -74,6 +74,15 @@ class PluginTest(TestCase): 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): """ Check that plugin middleware is registered. diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py new file mode 100644 index 000000000..4cad8cf24 --- /dev/null +++ b/netbox/netbox/preferences.py @@ -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) diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html index 254b5b8ff..06a48b431 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -8,6 +8,7 @@
{% csrf_token %} + {% comment %} {% for group, fields in form.Meta.fieldsets %}
@@ -18,6 +19,9 @@ {% endfor %}
{% endfor %} + {% endcomment %} + + {% render_form form %}
Cancel diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 721c68e43..a6c606c4b 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -2,10 +2,10 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from django.utils.html import mark_safe +from netbox.preferences import PREFERENCES from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect from utilities.utils import flatten_dict from .models import Token, UserConfig -from .preferences import PREFERENCES class LoginForm(BootstrapMixin, AuthenticationForm): @@ -44,15 +44,6 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe class Meta: model = UserConfig fields = () - fieldsets = ( - ('User Interface', ( - 'pagination.per_page', - 'ui.colormode', - )), - ('Miscellaneous', ( - 'data_format', - )), - ) def __init__(self, *args, instance=None, **kwargs): diff --git a/netbox/users/preferences.py b/netbox/users/preferences.py index 635393913..c66bc96c0 100644 --- a/netbox/users/preferences.py +++ b/netbox/users/preferences.py @@ -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: 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.description = description 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'), - ), - ), - -} From 7926225e9be8bfc64fd168ac63e92df19371de17 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Dec 2021 09:35:29 -0500 Subject: [PATCH 6/9] Improve preferences form rendering --- netbox/templates/users/preferences.html | 17 +++++++++++++---- netbox/users/forms.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html index 06a48b431..2a34f1b3f 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -8,20 +8,29 @@ {% csrf_token %} - {% comment %} {% for group, fields in form.Meta.fieldsets %}
{{ group }}
{% for name in fields %} - {% render_field form|getfield:name %} + {% render_field form|getfield:name %} {% endfor %}
{% endfor %} - {% endcomment %} - {% render_form form %} + {% with plugin_fields=form.plugin_fields %} + {% if plugin_fields %} +
+
+
Plugins
+
+ {% for name in plugin_fields %} + {% render_field form|getfield:name %} + {% endfor %} +
+ {% endif %} + {% endwith %}
Cancel diff --git a/netbox/users/forms.py b/netbox/users/forms.py index a6c606c4b..70e300a8c 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -44,6 +44,15 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe class Meta: model = UserConfig fields = () + fieldsets = ( + ('User Interface', ( + 'pagination.per_page', + 'ui.colormode', + )), + ('Miscellaneous', ( + 'data_format', + )), + ) def __init__(self, *args, instance=None, **kwargs): @@ -61,6 +70,12 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe return super().save(*args, **kwargs) + @property + def plugin_fields(self): + return [ + name for name in self.fields.keys() if name.startswith('plugins.') + ] + class TokenForm(BootstrapMixin, forms.ModelForm): key = forms.CharField( From 01997efcbe4c03617a8b4e2803786c546c921c2b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Dec 2021 09:51:31 -0500 Subject: [PATCH 7/9] Add tests & cleanup --- netbox/users/preferences.py | 4 ++- netbox/users/tests/test_models.py | 2 -- netbox/users/tests/test_preferences.py | 39 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 netbox/users/tests/test_preferences.py diff --git a/netbox/users/preferences.py b/netbox/users/preferences.py index c66bc96c0..cff6a3c9b 100644 --- a/netbox/users/preferences.py +++ b/netbox/users/preferences.py @@ -1,5 +1,7 @@ class UserPreference: - + """ + Represents a configurable user preference. + """ def __init__(self, label, choices, default=None, description='', coerce=lambda x: x): self.label = label self.choices = choices diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index 8047796c4..48d440278 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -1,8 +1,6 @@ from django.contrib.auth.models import User from django.test import TestCase -from users.models import UserConfig - class UserConfigTest(TestCase): diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py new file mode 100644 index 000000000..23e94e8ef --- /dev/null +++ b/netbox/users/tests/test_preferences.py @@ -0,0 +1,39 @@ +from django.contrib.auth.models import User +from django.test import override_settings, TestCase + +from users.preferences import UserPreference + + +DEFAULT_USER_PREFERENCES = { + 'pagination': { + 'per_page': 250, + } +} + + +class UserPreferencesTest(TestCase): + + def test_userpreference(self): + CHOICES = ( + ('foo', 'Foo'), + ('bar', 'Bar'), + ) + kwargs = { + 'label': 'Test Preference', + 'choices': CHOICES, + 'default': CHOICES[0][0], + 'description': 'Description', + } + userpref = UserPreference(**kwargs) + + self.assertEqual(userpref.label, kwargs['label']) + self.assertEqual(userpref.choices, kwargs['choices']) + self.assertEqual(userpref.default, kwargs['default']) + self.assertEqual(userpref.description, kwargs['description']) + + @override_settings(DEFAULT_USER_PREFERENCES=DEFAULT_USER_PREFERENCES) + def test_default_preferences(self): + user = User.objects.create(username='User 1') + userconfig = user.config + + self.assertEqual(userconfig.data, DEFAULT_USER_PREFERENCES) From cb6342c87404264a5d46dac54c6cf2736321561e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Dec 2021 10:13:08 -0500 Subject: [PATCH 8/9] Reference DEFAULT_USER_PREFERENCES for undefined preferences --- netbox/users/models.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index 7b768b57f..0afc7d374 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -80,13 +80,25 @@ class UserConfig(models.Model): keys = path.split('.') # Iterate down the hierarchy, returning the default value if any invalid key is encountered - for key in keys: - if type(d) is dict and key in d: + try: + for key in keys: d = d.get(key) - else: - return default + return d + except (AttributeError, KeyError): + pass - return d + # If the key is not found in the user's config, check for an application-wide default + config = get_config() + d = config.DEFAULT_USER_PREFERENCES + try: + for key in keys: + d = d.get(key) + return d + except (AttributeError, KeyError): + pass + + # Finally, return the specified default value (if any) + return default def all(self): """ From 7343ae73397089c6e8d45ec91d8f5df5bd65c754 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Dec 2021 10:45:21 -0500 Subject: [PATCH 9/9] Fix invalid key retrieval --- netbox/users/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index 0afc7d374..0ce91363b 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -82,9 +82,9 @@ class UserConfig(models.Model): # Iterate down the hierarchy, returning the default value if any invalid key is encountered try: for key in keys: - d = d.get(key) + d = d[key] return d - except (AttributeError, KeyError): + except (TypeError, KeyError): pass # If the key is not found in the user's config, check for an application-wide default @@ -92,9 +92,9 @@ class UserConfig(models.Model): d = config.DEFAULT_USER_PREFERENCES try: for key in keys: - d = d.get(key) + d = d[key] return d - except (AttributeError, KeyError): + except (TypeError, KeyError): pass # Finally, return the specified default value (if any)