Просмотр исходного кода

Merge pull request #8143 from netbox-community/7759-user-preferences

Closes #7759: User preferences framework
Jeremy Stretch 4 лет назад
Родитель
Сommit
d64c88786e

+ 16 - 0
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

+ 6 - 5
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 |

+ 17 - 16
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.
 

+ 3 - 0
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'),
         }),

+ 19 - 0
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

+ 20 - 0
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'),
+        )
+    ),
+}

+ 9 - 0
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.

+ 4 - 4
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'
 

+ 9 - 0
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',

+ 49 - 0
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)

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 0 - 2
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();

+ 0 - 30
netbox/project-static/src/buttons/preferences.ts

@@ -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<HTMLFormElement>('preferences-update');
-  if (form !== null) {
-    form.addEventListener('submit', handlePreferenceSave);
-  }
-}

+ 28 - 45
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 %}
   <form method="post" action="" id="preferences-update">
     {% csrf_token %}
-    <div class="field-group mb-3">
-      <h5>Color Mode</h5>
-      <p class="text-muted">Set preferred UI color mode</p>
-      {% with color_mode=preferences|get_key:'ui.colormode'%}
-      <div class="form-check form-check-inline">
-        <input class="form-check-input" type="radio" name="ui.colormode" id="color-mode-preference-dark" value="dark"{% if color_mode == 'dark'%} checked{% endif %}>
-        <label class="form-check-label" for="color-mode-preference-dark">Dark</label>
-      </div>
-      <div class="form-check form-check-inline">
-        <input class="form-check-input" type="radio" name="ui.colormode" id="color-mode-preference-light" value="light"{% if color_mode == 'light'%} checked{% endif %}>
-        <label class="form-check-label" for="color-mode-preference-light">Light</label>
-      </div>
-      {% endwith %}
-    </div>
-    <div class="row mb-3">
-      <div class="col">
-        <button type="submit" class="btn btn-primary" name="_update">Save</button>
+
+    {% for group, fields in form.Meta.fieldsets %}
+      <div class="field-group my-5">
+        <div class="row mb-2">
+          <h5 class="offset-sm-3">{{ group }}</h5>
+        </div>
+        {% for name in fields %}
+          {% render_field form|getfield:name %}
+        {% endfor %}
       </div>
+    {% endfor %}
+
+    {% with plugin_fields=form.plugin_fields %}
+      {% if plugin_fields %}
+        <div class="field-group my-5">
+          <div class="row mb-2">
+            <h5 class="offset-sm-3">Plugins</h5>
+          </div>
+          {% for name in plugin_fields %}
+            {% render_field form|getfield:name %}
+          {% endfor %}
+        </div>
+      {% endif %}
+    {% endwith %}
+
+    <div class="text-end my-3">
+      <a class="btn btn-outline-secondary" href="{% url 'user:preferences' %}">Cancel</a>
+      <button type="submit" name="_update" class="btn btn-primary">Save </button>
     </div>
-    {% if preferences %}
-      <div class="field-group mb-3">
-        <h5>Other Preferences</h5>
-        <table class="table table-striped">
-          <thead>
-            <tr>
-              <th><input type="checkbox" class="toggle form-check-input" title="Toggle All"></th>
-              <th>Preference</th>
-              <th>Value</th>
-            </tr>
-          </thead>
-          <tbody>
-            {% for key, value in preferences.items %}
-              <tr>
-                <td class="min-width"><input class="form-check-input" type="checkbox" name="pk" value="{{ key }}"></td>
-                <td><samp>{{ key }}</samp></td>
-                <td><samp>{{ value }}</samp></td>
-              </tr>
-            {% endfor %}
-          </tbody>
-        </table>
-        <button type="submit" class="btn btn-danger" name="_delete">
-          <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Clear Selected
-        </button>
-      </div>
-    {% else %}
-      <h3 class="text-muted text-center">No preferences found</h3>
-    {% endif %}
   </form>
 {% endblock %}

+ 66 - 2
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}<br />' if preference.description else ''
+            help_text = f'{description}<code>{field_name}</code>'
+            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,

+ 21 - 7
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
+
+        # 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
 
-        return d
+        # 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()
 
 
 #

+ 10 - 0
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

+ 0 - 2
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):
 

+ 39 - 0
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)

+ 16 - 20
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()
-
-        # 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)
-
-        userconfig.save()
-        messages.success(request, "Your preferences have been updated.")
-
-        return redirect('user:preferences')
+        form = UserConfigForm(request.POST, instance=userconfig)
+
+        if form.is_valid():
+            form.save()
+
+            messages.success(request, "Your preferences have been updated.")
+            return redirect('user:preferences')
+
+        return render(request, self.template_name, {
+            'form': form,
+            'active_tab': 'preferences',
+        })
 
 
 class ChangePasswordView(LoginRequiredMixin, View):

+ 1 - 1
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

Некоторые файлы не были показаны из-за большого количества измененных файлов