Przeglądaj źródła

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

Closes #3294: User preference tracking
Jeremy Stretch 5 lat temu
rodzic
commit
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'
         - Utility Views: 'development/utility-views.md'
         - Extending Models: 'development/extending-models.md'
         - Extending Models: 'development/extending-models.md'
         - Application Registry: 'development/application-registry.md'
         - Application Registry: 'development/application-registry.md'
+        - User Preferences: 'development/user-preferences.md'
         - Release Checklist: 'development/release-checklist.md'
         - Release Checklist: 'development/release-checklist.md'
         - Squashing Migrations: 'development/squashing-migrations.md'
         - Squashing Migrations: 'development/squashing-migrations.md'
     - Release Notes:
     - Release Notes:

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

@@ -12,6 +12,9 @@
             <li{% ifequal active_tab "profile" %} class="active"{% endifequal %}>
             <li{% ifequal active_tab "profile" %} class="active"{% endifequal %}>
                 <a href="{% url 'user:profile' %}">Profile</a>
                 <a href="{% url 'user:profile' %}">Profile</a>
             </li>
             </li>
+            <li{% ifequal active_tab "preferences" %} class="active"{% endifequal %}>
+                <a href="{% url 'user:preferences' %}">Preferences</a>
+            </li>
             {% if not request.user.ldap_username %}
             {% if not request.user.ldap_username %}
                 <li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
                 <li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
                     <a href="{% url 'user:change_password' %}">Change Password</a>
                     <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.admin import UserAdmin as UserAdmin_
 from django.contrib.auth.models import User
 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
 # Unregister the built-in UserAdmin so that we can use our custom admin view below
 admin.site.unregister(User)
 admin.site.unregister(User)
 
 
 
 
+class UserConfigInline(admin.TabularInline):
+    model = UserConfig
+    readonly_fields = ('data',)
+    can_delete = False
+    verbose_name = 'Preferences'
+
+
 @admin.register(User)
 @admin.register(User)
 class UserAdmin(UserAdmin_):
 class UserAdmin(UserAdmin_):
     list_display = [
     list_display = [
         'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
         'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
     ]
     ]
+    inlines = (UserConfigInline,)
 
 
 
 
 class TokenAdminForm(forms.ModelForm):
 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
 import os
 
 
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
+from django.contrib.postgres.fields import JSONField
 from django.core.validators import MinLengthValidator
 from django.core.validators import MinLengthValidator
 from django.db import models
 from django.db import models
+from django.db.models.signals import post_save
+from django.dispatch import receiver
 from django.utils import timezone
 from django.utils import timezone
 
 
+from utilities.utils import flatten_dict
+
 
 
 __all__ = (
 __all__ = (
     'Token',
     '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):
 class Token(models.Model):
     """
     """
     An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens.
     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 = [
 urlpatterns = [
 
 
     path('profile/', views.ProfileView.as_view(), name='profile'),
     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('password/', views.ChangePasswordView.as_view(), name='change_password'),
     path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
     path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
     path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
     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):
 class ChangePasswordView(LoginRequiredMixin, View):
     template_name = 'users/change_password.html'
     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)
             page_list.insert(page_list.index(i), False)
 
 
         return page_list
         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]
             difference[key] = destination_dict[key]
 
 
     return difference
     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
 import sys
 from copy import deepcopy
 from copy import deepcopy
 
 
-from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ValidationError
 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 utilities.utils import csv_format, prepare_cloned_fields
 from .error_handlers import handle_protectederror
 from .error_handlers import handle_protectederror
 from .forms import ConfirmationForm, ImportForm
 from .forms import ConfirmationForm, ImportForm
-from .paginator import EnhancedPaginator
+from .paginator import EnhancedPaginator, get_paginate_count
 
 
 
 
 class GetReturnURLMixin(object):
 class GetReturnURLMixin(object):
@@ -172,7 +171,7 @@ class ObjectListView(View):
         # Apply the request context
         # Apply the request context
         paginate = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
-            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+            'per_page': get_paginate_count(request)
         }
         }
         RequestConfig(request, paginate).configure(table)
         RequestConfig(request, paginate).configure(table)