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')