From c0b1ae49236afb6a96525d42fb6d81086cdac5a1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Apr 2020 11:02:35 -0400 Subject: [PATCH 01/10] Initialize v2.9 development --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8b1541a23..711402c54 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.8.2-dev' +VERSION = '2.9.0-dev' # Hostname HOSTNAME = platform.node() From 750deac2cf096500cee763c9c825eb9e7ccadd6f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Apr 2020 15:25:47 -0400 Subject: [PATCH 02/10] Initial implementation of UserConfig model --- netbox/users/migrations/0004_userconfig.py | 28 ++++++ netbox/users/models.py | 103 +++++++++++++++++++++ netbox/users/tests/test_models.py | 88 ++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 netbox/users/migrations/0004_userconfig.py create mode 100644 netbox/users/tests/test_models.py diff --git a/netbox/users/migrations/0004_userconfig.py b/netbox/users/migrations/0004_userconfig.py new file mode 100644 index 000000000..f8ca3e01b --- /dev/null +++ b/netbox/users/migrations/0004_userconfig.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.5 on 2020-04-23 15:49 + +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'], + }, + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 5be784777..012eadfa0 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -2,6 +2,7 @@ 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.utils import timezone @@ -9,9 +10,111 @@ from django.utils import timezone __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'] + + def get(self, path): + """ + 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']. + """ + d = self.data + keys = path.split('.') + + # Iterate down the hierarchy, returning None for any invalid keys + for key in keys: + if type(d) is dict: + d = d.get(key) + else: + return None + + return d + + 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() + + 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..55dba997b --- /dev/null +++ b/netbox/users/tests/test_models.py @@ -0,0 +1,88 @@ +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') + initial_data = { + 'a': True, + 'b': { + 'foo': 101, + 'bar': 102, + }, + 'c': { + 'foo': { + 'x': 201, + }, + 'bar': { + 'y': 202, + }, + 'baz': { + 'z': 203, + } + } + } + + self.userconfig = UserConfig(user=user, data=initial_data) + + 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')) + + 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') + self.assertIsNone(userconfig.pk) + + # Set a value and commit to the database + userconfig.set('a', 'def', commit=True) + self.assertEqual(userconfig.data['a'], 'def') + self.assertIsNotNone(userconfig.pk) + + # 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') From afa0565a44c43cf15d6fdfe2f05c88a6579350ea Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Apr 2020 15:53:43 -0400 Subject: [PATCH 03/10] Show user config in admin UI --- netbox/users/admin.py | 10 +++++++++- netbox/users/migrations/0004_userconfig.py | 4 ++-- netbox/users/models.py | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) 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 index f8ca3e01b..ba8438741 100644 --- a/netbox/users/migrations/0004_userconfig.py +++ b/netbox/users/migrations/0004_userconfig.py @@ -1,5 +1,3 @@ -# Generated by Django 3.0.5 on 2020-04-23 15:49 - from django.conf import settings import django.contrib.postgres.fields.jsonb from django.db import migrations, models @@ -23,6 +21,8 @@ class Migration(migrations.Migration): ], options={ 'ordering': ['user'], + 'verbose_name': 'User Preferences', + 'verbose_name_plural': 'User Preferences' }, ), ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 012eadfa0..228b5aace 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -29,6 +29,7 @@ class UserConfig(models.Model): class Meta: ordering = ['user'] + verbose_name = verbose_name_plural = 'User Preferences' def get(self, path): """ From f3012ed839a3dc68eb9d18d298dce31ceb081dd2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Apr 2020 16:36:12 -0400 Subject: [PATCH 04/10] Automatically create UserConfig for users --- .../migrations/0005_create_userconfigs.py | 27 +++++++++++++++++++ netbox/users/models.py | 18 ++++++++++--- netbox/users/tests/test_models.py | 9 ++++--- 3 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 netbox/users/migrations/0005_create_userconfigs.py 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 228b5aace..d401ad68e 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -5,6 +5,8 @@ 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 @@ -31,23 +33,24 @@ class UserConfig(models.Model): ordering = ['user'] verbose_name = verbose_name_plural = 'User Preferences' - def get(self, path): + 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 None for any invalid keys + # Iterate down the hierarchy, returning the default value if any invalid key is encountered for key in keys: if type(d) is dict: d = d.get(key) else: - return None + return default return d @@ -116,6 +119,15 @@ class UserConfig(models.Model): 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 index 55dba997b..a6d0916ac 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -9,7 +9,7 @@ class UserConfigTest(TestCase): def setUp(self): user = User.objects.create_user(username='testuser') - initial_data = { + user.config.data = { 'a': True, 'b': { 'foo': 101, @@ -27,8 +27,9 @@ class UserConfigTest(TestCase): } } } + user.config.save() - self.userconfig = UserConfig(user=user, data=initial_data) + self.userconfig = user.config def test_get(self): userconfig = self.userconfig @@ -58,12 +59,12 @@ class UserConfigTest(TestCase): userconfig.set('b.baz', 'abc') self.assertEqual(userconfig.data['d'], 'abc') self.assertEqual(userconfig.data['b']['baz'], 'abc') - self.assertIsNone(userconfig.pk) # Set a value and commit to the database userconfig.set('a', 'def', commit=True) + + userconfig.refresh_from_db() self.assertEqual(userconfig.data['a'], 'def') - self.assertIsNotNone(userconfig.pk) # Attempt to change a branch node to a leaf node with self.assertRaises(TypeError): From 30c3d6ee406eed8bf30cad1d19ade37caac356c0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Apr 2020 16:48:13 -0400 Subject: [PATCH 05/10] Remember user's per_page preference (POC for UserConfig) --- netbox/utilities/paginator.py | 19 +++++++++++++++++++ netbox/utilities/views.py | 5 ++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py index cf91df3ca..d6e0ad10c 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('paginate_count', per_page, commit=True) + return per_page + except ValueError: + pass + + return request.user.config.get('paginate_count', settings.PAGINATE_COUNT) 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) From d8494e44e78eab1730952239fa365a5a5f85333b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Apr 2020 09:23:58 -0400 Subject: [PATCH 06/10] Document available user preferences --- docs/development/user-preferences.md | 9 +++++++++ mkdocs.yml | 1 + netbox/templates/users/preferences.html | 18 ++++++++++++++++++ netbox/utilities/paginator.py | 4 ++-- 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 docs/development/user-preferences.md create mode 100644 netbox/templates/users/preferences.html 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/preferences.html b/netbox/templates/users/preferences.html new file mode 100644 index 000000000..65254645c --- /dev/null +++ b/netbox/templates/users/preferences.html @@ -0,0 +1,18 @@ +{% extends 'users/_user.html' %} +{% load helpers %} + +{% block title %}User Preferences{% endblock %} + +{% block usercontent %} + + + + + + + + + {% for %} + +
PreferenceValue
+{% endblock %} diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py index d6e0ad10c..cef7c941f 100644 --- a/netbox/utilities/paginator.py +++ b/netbox/utilities/paginator.py @@ -50,9 +50,9 @@ def get_paginate_count(request): if 'per_page' in request.GET: try: per_page = int(request.GET.get('per_page')) - request.user.config.set('paginate_count', per_page, commit=True) + request.user.config.set('pagination.per_page', per_page, commit=True) return per_page except ValueError: pass - return request.user.config.get('paginate_count', settings.PAGINATE_COUNT) + return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT) From 7c8c85e435bd056c42dedf6939f0d262645eb710 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Apr 2020 09:50:26 -0400 Subject: [PATCH 07/10] Add all() method to UserConfig --- netbox/users/models.py | 8 ++++++++ netbox/users/tests/test_models.py | 14 ++++++++++++++ netbox/utilities/utils.py | 18 ++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/netbox/users/models.py b/netbox/users/models.py index d401ad68e..c83de3f90 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -9,6 +9,8 @@ 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', @@ -54,6 +56,12 @@ class UserConfig(models.Model): 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: diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index a6d0916ac..ec1a00326 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -45,6 +45,20 @@ class UserConfigTest(TestCase): self.assertIsNone(userconfig.get('b.foo.invalid')) self.assertIsNone(userconfig.get('b.foo.x.invalid')) + 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 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 From 587339bea0457c9a9003fe94e09616c19476fa3f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Apr 2020 10:29:06 -0400 Subject: [PATCH 08/10] Add page for user to view/clear preferences --- netbox/templates/users/_user.html | 3 ++ netbox/templates/users/preferences.html | 39 ++++++++++++++++++------- netbox/users/urls.py | 1 + netbox/users/views.py | 24 +++++++++++++++ 4 files changed, 56 insertions(+), 11 deletions(-) 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 index 65254645c..0884c7f17 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -4,15 +4,32 @@ {% block title %}User Preferences{% endblock %} {% block usercontent %} - - - - - - - - - {% for %} - -
PreferenceValue
+ {% 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/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' From dc9617c7aa6f3a78e6174601c34579babea6d450 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Apr 2020 10:37:02 -0400 Subject: [PATCH 09/10] Fix returning default for unknown userconfig key --- netbox/users/models.py | 2 +- netbox/users/tests/test_models.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index c83de3f90..02356696f 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -49,7 +49,7 @@ class UserConfig(models.Model): # Iterate down the hierarchy, returning the default value if any invalid key is encountered for key in keys: - if type(d) is dict: + if type(d) is dict and key in d: d = d.get(key) else: return default diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index ec1a00326..0157d8fdd 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -45,6 +45,12 @@ class UserConfigTest(TestCase): 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 = { From 178052b2f6bc19d0d0b063070d40ae028e934157 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Apr 2020 10:38:09 -0400 Subject: [PATCH 10/10] Prepare for merge into 2.8 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 711402c54..8b1541a23 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.9.0-dev' +VERSION = '2.8.2-dev' # Hostname HOSTNAME = platform.node()