Explorar el Código

Initial implementation of UserConfig model

Jeremy Stretch hace 5 años
padre
commit
750deac2cf

+ 28 - 0
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'],
+            },
+        ),
+    ]

+ 103 - 0
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.

+ 88 - 0
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')