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 %} +
+ {% csrf_token %} + + + + + + + + + + {% for key, value in preferences.items %} + + + + + + {% endfor %} + +
PreferenceValue
{{ key }}{{ value }}
+ +
+ {% 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)