diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md
new file mode 100644
index 000000000..b81117ac9
--- /dev/null
+++ b/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 |
diff --git a/mkdocs.yml b/mkdocs.yml
index d1ced6d8c..bed73eb9c 100644
--- a/mkdocs.yml
+++ b/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:
diff --git a/netbox/templates/users/_user.html b/netbox/templates/users/_user.html
index 441caf289..d03d44fa6 100644
--- a/netbox/templates/users/_user.html
+++ b/netbox/templates/users/_user.html
@@ -12,6 +12,9 @@
Profile
+
+ Preferences
+
{% if not request.user.ldap_username %}
Change Password
diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html
new file mode 100644
index 000000000..0884c7f17
--- /dev/null
+++ b/netbox/templates/users/preferences.html
@@ -0,0 +1,35 @@
+{% extends 'users/_user.html' %}
+{% load helpers %}
+
+{% block title %}User Preferences{% endblock %}
+
+{% block usercontent %}
+ {% if preferences %}
+
+ {% else %}
+ No preferences found
+ {% endif %}
+{% endblock %}
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index 289a1efcd..42e651712 100644
--- a/netbox/users/admin.py
+++ b/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):
diff --git a/netbox/users/migrations/0004_userconfig.py b/netbox/users/migrations/0004_userconfig.py
new file mode 100644
index 000000000..ba8438741
--- /dev/null
+++ b/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'
+ },
+ ),
+ ]
diff --git a/netbox/users/migrations/0005_create_userconfigs.py b/netbox/users/migrations/0005_create_userconfigs.py
new file mode 100644
index 000000000..39ce174f6
--- /dev/null
+++ b/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
+ ),
+ ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 5be784777..02356696f 100644
--- a/netbox/users/models.py
+++ b/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.
diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py
new file mode 100644
index 000000000..0157d8fdd
--- /dev/null
+++ b/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')
diff --git a/netbox/users/urls.py b/netbox/users/urls.py
index dae540726..b8b16cdf8 100644
--- a/netbox/users/urls.py
+++ b/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'),
diff --git a/netbox/users/views.py b/netbox/users/views.py
index ae1345b6b..c3e366542 100644
--- a/netbox/users/views.py
+++ b/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'
diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py
index cf91df3ca..cef7c941f 100644
--- a/netbox/utilities/paginator.py
+++ b/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)
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 446622118..351b1fd68 100644
--- a/netbox/utilities/utils.py
+++ b/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
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index b671eec9c..294acb1d1 100644
--- a/netbox/utilities/views.py
+++ b/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)