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/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/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/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/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/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/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/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/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 95fd99270..740fbe7e7 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 6fbe0874b..116aad5e6 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ 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..2a34f1b3f 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -1,57 +1,40 @@ {% 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 %} + + {% with plugin_fields=form.plugin_fields %} + {% if plugin_fields %} +
+
+
Plugins
+
+ {% for name in plugin_fields %} + {% render_field form|getfield:name %} + {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ 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..70e300a8c 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,8 +1,11 @@ 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 -from .models import Token +from netbox.preferences import PREFERENCES +from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect +from utilities.utils import flatten_dict +from .models import Token, UserConfig class LoginForm(BootstrapMixin, AuthenticationForm): @@ -13,6 +16,67 @@ class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): pass +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(): + description = f'{preference.description}
' if preference.description else '' + help_text = f'{description}{field_name}' + field_kwargs = { + 'label': preference.label, + 'choices': preference.choices, + 'help_text': mark_safe(help_text), + 'coerce': preference.coerce, + 'required': False, + 'widget': StaticSelect, + } + preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs) + attrs.update(preference_fields) + + return super().__new__(mcs, name, bases, attrs) + + +class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass): + + class Meta: + model = UserConfig + fields = () + fieldsets = ( + ('User Interface', ( + 'pagination.per_page', + 'ui.colormode', + )), + ('Miscellaneous', ( + 'data_format', + )), + ) + + def __init__(self, *args, instance=None, **kwargs): + + # Get initial data from UserConfig instance + initial_data = flatten_dict(instance.data) + kwargs['initial'] = initial_data + + super().__init__(*args, instance=instance, **kwargs) + + def save(self, *args, **kwargs): + + # Set UserConfig data + for pref_name, value in self.cleaned_data.items(): + self.instance.set(pref_name, value, commit=False) + + 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( required=False, diff --git a/netbox/users/models.py b/netbox/users/models.py index 64b6432a7..0ce91363b 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 @@ -79,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: - d = d.get(key) - else: - return default + try: + for key in keys: + d = d[key] + return d + except (TypeError, 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[key] + return d + except (TypeError, KeyError): + pass + + # Finally, return the specified default value (if any) + return default def all(self): """ @@ -166,7 +179,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 new file mode 100644 index 000000000..cff6a3c9b --- /dev/null +++ b/netbox/users/preferences.py @@ -0,0 +1,10 @@ +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 + self.default = default if default is not None else choices[0] + self.description = description + self.coerce = coerce 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) 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