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

Merge pull request #4532 from netbox-community/3294-user-prefs

Closes #3294: User preference tracking
Jeremy Stretch 5 лет назад
Родитель
Сommit
7feaa896e5

+ 9 - 0
docs/development/user-preferences.md

@@ -0,0 +1,9 @@
+# User Preferences
+
+The `users.UserConfig` model holds individual preferences for each user in the form of JSON data. This page serves as a manifest of all recognized user preferences in NetBox.
+
+## Available Preferences
+
+| Name | Description |
+| ---- | ----------- |
+| pagination.per_page | The number of items to display per page of a paginated table |

+ 1 - 0
mkdocs.yml

@@ -72,6 +72,7 @@ nav:
         - Utility Views: 'development/utility-views.md'
         - Extending Models: 'development/extending-models.md'
         - Application Registry: 'development/application-registry.md'
+        - User Preferences: 'development/user-preferences.md'
         - Release Checklist: 'development/release-checklist.md'
         - Squashing Migrations: 'development/squashing-migrations.md'
     - Release Notes:

+ 3 - 0
netbox/templates/users/_user.html

@@ -12,6 +12,9 @@
             <li{% ifequal active_tab "profile" %} class="active"{% endifequal %}>
                 <a href="{% url 'user:profile' %}">Profile</a>
             </li>
+            <li{% ifequal active_tab "preferences" %} class="active"{% endifequal %}>
+                <a href="{% url 'user:preferences' %}">Preferences</a>
+            </li>
             {% if not request.user.ldap_username %}
                 <li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
                     <a href="{% url 'user:change_password' %}">Change Password</a>

+ 35 - 0
netbox/templates/users/preferences.html

@@ -0,0 +1,35 @@
+{% extends 'users/_user.html' %}
+{% load helpers %}
+
+{% block title %}User Preferences{% endblock %}
+
+{% block usercontent %}
+    {% if preferences %}
+        <form method="post" action="">
+            {% csrf_token %}
+            <table class="table table-striped">
+                <thead>
+                    <tr>
+                        <th><input type="checkbox" class="toggle" 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 type="checkbox" name="pk" value="{{ key }}"></td>
+                            <td>{{ key }}</td>
+                            <td>{{ value }}</td>
+                        </tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+            <button type="submit" class="btn btn-danger">
+                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Clear Selected
+            </button>
+        </form>
+    {% else %}
+        <h3 class="text-muted text-center">No preferences found</h3>
+    {% endif %}
+{% endblock %}

+ 9 - 1
netbox/users/admin.py

@@ -3,17 +3,25 @@ from django.contrib import admin
 from django.contrib.auth.admin import UserAdmin as UserAdmin_
 from django.contrib.auth.models import User
 
-from .models import Token
+from .models import Token, UserConfig
 
 # Unregister the built-in UserAdmin so that we can use our custom admin view below
 admin.site.unregister(User)
 
 
+class UserConfigInline(admin.TabularInline):
+    model = UserConfig
+    readonly_fields = ('data',)
+    can_delete = False
+    verbose_name = 'Preferences'
+
+
 @admin.register(User)
 class UserAdmin(UserAdmin_):
     list_display = [
         'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
     ]
+    inlines = (UserConfigInline,)
 
 
 class TokenAdminForm(forms.ModelForm):

+ 28 - 0
netbox/users/migrations/0004_userconfig.py

@@ -0,0 +1,28 @@
+from django.conf import settings
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('users', '0002_standardize_description'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='UserConfig',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('data', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
+                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='config', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'ordering': ['user'],
+                'verbose_name': 'User Preferences',
+                'verbose_name_plural': 'User Preferences'
+            },
+        ),
+    ]

+ 27 - 0
netbox/users/migrations/0005_create_userconfigs.py

@@ -0,0 +1,27 @@
+from django.contrib.auth import get_user_model
+from django.db import migrations
+
+
+def create_userconfigs(apps, schema_editor):
+    """
+    Create an empty UserConfig instance for each existing User.
+    """
+    User = get_user_model()
+    UserConfig = apps.get_model('users', 'UserConfig')
+    UserConfig.objects.bulk_create(
+        [UserConfig(user_id=user.pk) for user in User.objects.all()]
+    )
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0004_userconfig'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=create_userconfigs,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 124 - 0
netbox/users/models.py

@@ -2,16 +2,140 @@ import binascii
 import os
 
 from django.contrib.auth.models import User
+from django.contrib.postgres.fields import JSONField
 from django.core.validators import MinLengthValidator
 from django.db import models
+from django.db.models.signals import post_save
+from django.dispatch import receiver
 from django.utils import timezone
 
+from utilities.utils import flatten_dict
+
 
 __all__ = (
     'Token',
+    'UserConfig',
 )
 
 
+class UserConfig(models.Model):
+    """
+    This model stores arbitrary user-specific preferences in a JSON data structure.
+    """
+    user = models.OneToOneField(
+        to=User,
+        on_delete=models.CASCADE,
+        related_name='config'
+    )
+    data = JSONField(
+        default=dict
+    )
+
+    class Meta:
+        ordering = ['user']
+        verbose_name = verbose_name_plural = 'User Preferences'
+
+    def get(self, path, default=None):
+        """
+        Retrieve a configuration parameter specified by its dotted path. Example:
+
+            userconfig.get('foo.bar.baz')
+
+        :param path: Dotted path to the configuration key. For example, 'foo.bar' returns self.data['foo']['bar'].
+        :param default: Default value to return for a nonexistent key (default: None).
+        """
+        d = self.data
+        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
+
+        return d
+
+    def all(self):
+        """
+        Return a dictionary of all defined keys and their values.
+        """
+        return flatten_dict(self.data)
+
+    def set(self, path, value, commit=False):
+        """
+        Define or overwrite a configuration parameter. Example:
+
+            userconfig.set('foo.bar.baz', 123)
+
+        Leaf nodes (those which are not dictionaries of other nodes) cannot be overwritten as dictionaries. Similarly,
+        branch nodes (dictionaries) cannot be overwritten as single values. (A TypeError exception will be raised.) In
+        both cases, the existing key must first be cleared. This safeguard is in place to help avoid inadvertently
+        overwriting the wrong key.
+
+        :param path: Dotted path to the configuration key. For example, 'foo.bar' sets self.data['foo']['bar'].
+        :param value: The value to be written. This can be any type supported by JSON.
+        :param commit: If true, the UserConfig instance will be saved once the new value has been applied.
+        """
+        d = self.data
+        keys = path.split('.')
+
+        # Iterate through the hierarchy to find the key we're setting. Raise TypeError if we encounter any
+        # interim leaf nodes (keys which do not contain dictionaries).
+        for i, key in enumerate(keys[:-1]):
+            if key in d and type(d[key]) is dict:
+                d = d[key]
+            elif key in d:
+                err_path = '.'.join(path.split('.')[:i + 1])
+                raise TypeError(f"Key '{err_path}' is a leaf node; cannot assign new keys")
+            else:
+                d = d.setdefault(key, {})
+
+        # Set a key based on the last item in the path. Raise TypeError if attempting to overwrite a non-leaf node.
+        key = keys[-1]
+        if key in d and type(d[key]) is dict:
+            raise TypeError(f"Key '{path}' has child keys; cannot assign a value")
+        else:
+            d[key] = value
+
+        if commit:
+            self.save()
+
+    def clear(self, path, commit=False):
+        """
+        Delete a configuration parameter specified by its dotted path. The key and any child keys will be deleted.
+        Example:
+
+            userconfig.clear('foo.bar.baz')
+
+        A KeyError is raised in the event any key along the path does not exist.
+
+        :param path: Dotted path to the configuration key. For example, 'foo.bar' deletes self.data['foo']['bar'].
+        :param commit: If true, the UserConfig instance will be saved once the new value has been applied.
+        """
+        d = self.data
+        keys = path.split('.')
+
+        for key in keys[:-1]:
+            if key in d and type(d[key]) is dict:
+                d = d[key]
+
+        key = keys[-1]
+        del(d[key])
+
+        if commit:
+            self.save()
+
+
+@receiver(post_save, sender=User)
+def create_userconfig(instance, created, **kwargs):
+    """
+    Automatically create a new UserConfig when a new User is created.
+    """
+    if created:
+        UserConfig(user=instance).save()
+
+
 class Token(models.Model):
     """
     An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens.

+ 109 - 0
netbox/users/tests/test_models.py

@@ -0,0 +1,109 @@
+from django.contrib.auth.models import User
+from django.test import TestCase
+
+from users.models import UserConfig
+
+
+class UserConfigTest(TestCase):
+
+    def setUp(self):
+
+        user = User.objects.create_user(username='testuser')
+        user.config.data = {
+            'a': True,
+            'b': {
+                'foo': 101,
+                'bar': 102,
+            },
+            'c': {
+                'foo': {
+                    'x': 201,
+                },
+                'bar': {
+                    'y': 202,
+                },
+                'baz': {
+                    'z': 203,
+                }
+            }
+        }
+        user.config.save()
+
+        self.userconfig = user.config
+
+    def test_get(self):
+        userconfig = self.userconfig
+
+        # Retrieve root and nested values
+        self.assertEqual(userconfig.get('a'), True)
+        self.assertEqual(userconfig.get('b.foo'), 101)
+        self.assertEqual(userconfig.get('c.baz.z'), 203)
+
+        # Invalid values should return None
+        self.assertIsNone(userconfig.get('invalid'))
+        self.assertIsNone(userconfig.get('a.invalid'))
+        self.assertIsNone(userconfig.get('b.foo.invalid'))
+        self.assertIsNone(userconfig.get('b.foo.x.invalid'))
+
+        # Invalid values with a provided default should return the default
+        self.assertEqual(userconfig.get('invalid', 'DEFAULT'), 'DEFAULT')
+        self.assertEqual(userconfig.get('a.invalid', 'DEFAULT'), 'DEFAULT')
+        self.assertEqual(userconfig.get('b.foo.invalid', 'DEFAULT'), 'DEFAULT')
+        self.assertEqual(userconfig.get('b.foo.x.invalid', 'DEFAULT'), 'DEFAULT')
+
+    def test_all(self):
+        userconfig = self.userconfig
+        flattened_data = {
+            'a': True,
+            'b.foo': 101,
+            'b.bar': 102,
+            'c.foo.x': 201,
+            'c.bar.y': 202,
+            'c.baz.z': 203,
+        }
+
+        # Retrieve a flattened dictionary containing all config data
+        self.assertEqual(userconfig.all(), flattened_data)
+
+    def test_set(self):
+        userconfig = self.userconfig
+
+        # Overwrite existing values
+        userconfig.set('a', 'abc')
+        userconfig.set('c.foo.x', 'abc')
+        self.assertEqual(userconfig.data['a'], 'abc')
+        self.assertEqual(userconfig.data['c']['foo']['x'], 'abc')
+
+        # Create new values
+        userconfig.set('d', 'abc')
+        userconfig.set('b.baz', 'abc')
+        self.assertEqual(userconfig.data['d'], 'abc')
+        self.assertEqual(userconfig.data['b']['baz'], 'abc')
+
+        # Set a value and commit to the database
+        userconfig.set('a', 'def', commit=True)
+
+        userconfig.refresh_from_db()
+        self.assertEqual(userconfig.data['a'], 'def')
+
+        # Attempt to change a branch node to a leaf node
+        with self.assertRaises(TypeError):
+            userconfig.set('b', 1)
+
+        # Attempt to change a leaf node to a branch node
+        with self.assertRaises(TypeError):
+            userconfig.set('a.x', 1)
+
+    def test_clear(self):
+        userconfig = self.userconfig
+
+        # Clear existing values
+        userconfig.clear('a')
+        userconfig.clear('b.foo')
+        self.assertTrue('a' not in userconfig.data)
+        self.assertTrue('foo' not in userconfig.data['b'])
+        self.assertEqual(userconfig.data['b']['bar'], 102)
+
+        # Clear an invalid value
+        with self.assertRaises(KeyError):
+            userconfig.clear('invalid')

+ 1 - 0
netbox/users/urls.py

@@ -6,6 +6,7 @@ app_name = 'user'
 urlpatterns = [
 
     path('profile/', views.ProfileView.as_view(), name='profile'),
+    path('preferences/', views.UserConfigView.as_view(), name='preferences'),
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
     path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
     path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),

+ 24 - 0
netbox/users/views.py

@@ -111,6 +111,30 @@ class ProfileView(LoginRequiredMixin, View):
         })
 
 
+class UserConfigView(LoginRequiredMixin, View):
+    template_name = 'users/preferences.html'
+
+    def get(self, request):
+
+        return render(request, self.template_name, {
+            'preferences': request.user.config.all(),
+            'active_tab': 'preferences',
+        })
+
+    def post(self, request):
+        userconfig = request.user.config
+        data = userconfig.all()
+
+        # Delete selected preferences
+        for key in request.POST.getlist('pk'):
+            if key in data:
+                userconfig.clear(key)
+        userconfig.save()
+        messages.success(request, "Your preferences have been updated.")
+
+        return redirect('user:preferences')
+
+
 class ChangePasswordView(LoginRequiredMixin, View):
     template_name = 'users/change_password.html'
 

+ 19 - 0
netbox/utilities/paginator.py

@@ -37,3 +37,22 @@ class EnhancedPage(Page):
             page_list.insert(page_list.index(i), False)
 
         return page_list
+
+
+def get_paginate_count(request):
+    """
+    Determine the length of a page, using the following in order:
+
+        1. per_page URL query parameter
+        2. Saved user preference
+        3. PAGINATE_COUNT global setting.
+    """
+    if 'per_page' in request.GET:
+        try:
+            per_page = int(request.GET.get('per_page'))
+            request.user.config.set('pagination.per_page', per_page, commit=True)
+            return per_page
+        except ValueError:
+            pass
+
+    return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)

+ 18 - 0
netbox/utilities/utils.py

@@ -239,3 +239,21 @@ def shallow_compare_dict(source_dict, destination_dict, exclude=None):
             difference[key] = destination_dict[key]
 
     return difference
+
+
+def flatten_dict(d, prefix='', separator='.'):
+    """
+    Flatten netsted dictionaries into a single level by joining key names with a separator.
+
+    :param d: The dictionary to be flattened
+    :param prefix: Initial prefix (if any)
+    :param separator: The character to use when concatenating key names
+    """
+    ret = {}
+    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))
+        else:
+            ret[key] = v
+    return ret

+ 2 - 3
netbox/utilities/views.py

@@ -2,7 +2,6 @@ import logging
 import sys
 from copy import deepcopy
 
-from django.conf import settings
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ValidationError
@@ -29,7 +28,7 @@ from utilities.forms import BootstrapMixin, CSVDataField
 from utilities.utils import csv_format, prepare_cloned_fields
 from .error_handlers import handle_protectederror
 from .forms import ConfirmationForm, ImportForm
-from .paginator import EnhancedPaginator
+from .paginator import EnhancedPaginator, get_paginate_count
 
 
 class GetReturnURLMixin(object):
@@ -172,7 +171,7 @@ class ObjectListView(View):
         # Apply the request context
         paginate = {
             'paginator_class': EnhancedPaginator,
-            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+            'per_page': get_paginate_count(request)
         }
         RequestConfig(request, paginate).configure(table)