Просмотр исходного кода

Initial implementation of UserConfig model

Jeremy Stretch 5 лет назад
Родитель
Сommit
750deac2cf
3 измененных файлов с 219 добавлено и 0 удалено
  1. 28 0
      netbox/users/migrations/0004_userconfig.py
  2. 103 0
      netbox/users/models.py
  3. 88 0
      netbox/users/tests/test_models.py

+ 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
 import os
 
 
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
+from django.contrib.postgres.fields import JSONField
 from django.core.validators import MinLengthValidator
 from django.core.validators import MinLengthValidator
 from django.db import models
 from django.db import models
 from django.utils import timezone
 from django.utils import timezone
@@ -9,9 +10,111 @@ from django.utils import timezone
 
 
 __all__ = (
 __all__ = (
     'Token',
     '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):
 class Token(models.Model):
     """
     """
     An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens.
     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')