mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 20:12:00 -06:00
Merge pull request #4532 from netbox-community/3294-user-prefs
Closes #3294: User preference tracking
This commit is contained in:
commit
7feaa896e5
9
docs/development/user-preferences.md
Normal file
9
docs/development/user-preferences.md
Normal file
@ -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 |
|
@ -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:
|
||||
|
@ -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
netbox/templates/users/preferences.html
Normal file
35
netbox/templates/users/preferences.html
Normal file
@ -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 %}
|
@ -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
netbox/users/migrations/0004_userconfig.py
Normal file
28
netbox/users/migrations/0004_userconfig.py
Normal file
@ -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
netbox/users/migrations/0005_create_userconfigs.py
Normal file
27
netbox/users/migrations/0005_create_userconfigs.py
Normal file
@ -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
|
||||
),
|
||||
]
|
@ -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
netbox/users/tests/test_models.py
Normal file
109
netbox/users/tests/test_models.py
Normal file
@ -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')
|
@ -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'),
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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,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)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user