Skip to content

Commit

Permalink
Add machinery to hold user preferences
Browse files Browse the repository at this point in the history
User preferences are stored in a JSON blob and namespaced via proxy
models.

Try removing boilerplate at your own peril.
  • Loading branch information
hmpf committed Oct 29, 2024
1 parent 90bdc8a commit e53e87c
Show file tree
Hide file tree
Showing 4 changed files with 225 additions and 2 deletions.
7 changes: 6 additions & 1 deletion src/argus/auth/admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin

from .models import User
from .models import User, Preferences
from argus.notificationprofile.models import DestinationConfig


Expand All @@ -19,8 +19,13 @@ def get_queryset(self, request):
return qs.filter(user=self.parent_obj)


class PreferencesInline(admin.TabularInline):
model = Preferences


class UserAdmin(BaseUserAdmin):
# inlines = [DestinationConfigInline]
inlines = [PreferencesInline]

def has_delete_permission(self, request, obj=None):
return False
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 4.2.16 on 2024-10-22 13:06

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
("argus_auth", "0003_delete_phonenumber"),
]

operations = [
migrations.CreateModel(
name="Preferences",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("namespace", models.CharField(max_length=255)),
("preferences", models.JSONField(blank=True, default=dict)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "User Preferences",
"verbose_name_plural": "Users' Preferences",
},
),
migrations.AddConstraint(
model_name="preferences",
constraint=models.UniqueConstraint(
fields=("user", "namespace"), name="unique_preference"
),
),
]
85 changes: 85 additions & 0 deletions src/argus/auth/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from typing import Union

from django.contrib.auth.models import AbstractUser, Group
from django.core.exceptions import ValidationError
from django.db import models


class User(AbstractUser):
Expand All @@ -19,3 +21,86 @@ def is_member_of_group(self, group: Union[str, Group]):
except Group.DoesNotExist:
return None
return group in self.groups.all()


class PreferencesManager(models.Manager):
def get_by_natural_key(self, user, namespace):
return self.get(user=user, namespace=namespace)

def create_missing_preferences(self):
precount = Preferences.objects.count()
for namespace, subclass in Preferences.NAMESPACES.items():
for user in User.objects.all():
Preferences.ensure_for_user(user)
return (precount, Preferences.objects.count())


class SubclassMixin:
@classmethod
def generate_namespace(cls):
app_label = cls.app_label
class_name = cls.class_name
return generate_namespace(app_label, class_name)

@classmethod
def validate_namespace(cls, value):
if (namespace := cls.generate_namespace()) != value:
raise ValidationError(f'"namespace" must be "{namespace}"')


def generate_namespace(app_label, class_name):
return f"{app_label}.{class_name}"


class Preferences(models.Model):
class Meta:
verbose_name = "User Preferences"
verbose_name_plural = "Users' Preferences"
constraints = [
models.UniqueConstraint(name="unique_preference", fields=["user", "namespace"]),
]

NAMESPACES = {}

user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="preferences")
namespace = models.CharField(blank=False, max_length=255)
preferences = models.JSONField(blank=True, default=dict)

objects = PreferencesManager()

# django methods

# called when subclass is constructing itself
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
namespace = cls.generate_namespace()
cls.NAMESPACES[namespace] = cls

def __str__(self):
return f"{self.user}'s {self.namespace}"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.get_instance()

def natural_key(self):
return (self.user, self.namespace)

# our methods

@classmethod
def get_namespace_mapping(cls):
mapping = {}
for namespace, subclass in cls.NAMESPACES.items():
mapping[subclass] = namespace
return mapping

def get_instance(self):
subclass = self.NAMESPACES.get(self.namespace, None)
if subclass:
self.__class__ = subclass

@classmethod
def ensure_for_user(cls, user):
for namespace, subclass in cls.NAMESPACES.items():
subclass.objects.get_or_create(user=user, namespace=namespace)
87 changes: 86 additions & 1 deletion tests/auth/test_models.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
from django.test import TestCase
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError

from argus.auth.factories import PersonUserFactory, SourceUserFactory
from argus.auth.models import Preferences, SubclassMixin, generate_namespace
from argus.incident.factories import SourceSystemFactory

from .models import MyPreferences, MyOtherPreferences


User = get_user_model()


class UserTests(TestCase):
class UserMethodTests(TestCase):
def test_is_end_user(self):
user = PersonUserFactory()
self.assertTrue(user.is_end_user)
Expand All @@ -19,6 +24,8 @@ def test_is_source_system(self):
ss = SourceSystemFactory(user=user)
self.assertTrue(user.is_source_system)


class UserIsMemberOfGroupTests(TestCase):
def test_is_member_of_group_when_no_groups_returns_none(self):
user = PersonUserFactory()
self.assertEqual(user.is_member_of_group("blbl"), None)
Expand All @@ -39,3 +46,81 @@ def test_is_member_of_group_when_not_member_of_group_fails(self):
user = PersonUserFactory()
group = Group.objects.create(name="foobar")
self.assertFalse(user.is_member_of_group(group))


class PreferencesTests(TestCase):
def test_generate_namespace(self):
namespace = generate_namespace("a", "b")
self.assertEqual(namespace, "a.b")

def test_natural_key_should_return_username_and_namespace(self):
user = PersonUserFactory()

Preferences.ensure_for_user(user)
mypref = MyPreferences.objects.get()
natural_key = mypref.natural_key()
self.assertEqual(natural_key, (user, mypref.namespace))

def test_str_should_return_username_and_namespace(self):
user = PersonUserFactory()

Preferences.ensure_for_user(user)
mypref = MyPreferences.objects.get()
string = str(mypref)
self.assertIn(user.username, string)
self.assertIn(mypref.namespace, string)

def test_imported_models_autoregister_in_Preferences_NAMESPACES(self):
namespace1 = MyPreferences.generate_namespace()
self.assertIn(namespace1, Preferences.NAMESPACES)
self.assertEqual(Preferences.NAMESPACES[namespace1], MyPreferences)

namespace2 = MyOtherPreferences.generate_namespace()
self.assertIn(namespace2, Preferences.NAMESPACES)
self.assertEqual(Preferences.NAMESPACES[namespace2], MyOtherPreferences)

def test_validate_namespace_fails_on_fake_namespace(self):
with self.assertRaises(ValidationError):
MyPreferences.validate_namespace("non-existent namespace")

def test_validate_namespace_succeeds_on_real_namspace(self):
MyPreferences.validate_namespace("auth.MyPreferences")

def test_subclasses_autoregister_in_Preferences_NAMESPACES(self):
class BlBl(SubclassMixin, Preferences):
app_label = "auth"
class_name = "blbl"

class Meta:
app_label = "auth"

namespace = BlBl.generate_namespace()
self.assertIn(namespace, Preferences.NAMESPACES)
self.assertEqual(Preferences.NAMESPACES[namespace], BlBl)

def test_get_namespace_mapping_is_a_reverse_dict_of_NAMESPACES(self):
mapping = Preferences.get_namespace_mapping()

namespace1 = MyPreferences.generate_namespace()
self.assertIn(MyPreferences, mapping)
self.assertEqual(mapping[MyPreferences], namespace1)

namespace2 = MyOtherPreferences.generate_namespace()
self.assertIn(MyOtherPreferences, mapping)
self.assertEqual(mapping[MyOtherPreferences], namespace2)

def test_ensure_for_user_creates_all_installed_namespaces_for_user(self):
user = PersonUserFactory()

Preferences.ensure_for_user(user)
self.assertTrue(Preferences.objects.filter(namespace=MyPreferences.generate_namespace()))
self.assertTrue(Preferences.objects.filter(namespace=MyOtherPreferences.generate_namespace()))

def test_get_instance_converts_to_subclass(self):
user = PersonUserFactory()

Preferences.ensure_for_user(user)
instances = Preferences.objects.filter(user=user, namespace=MyPreferences.generate_namespace())
self.assertEqual(instances.count(), 1)
instance = instances[0]
self.assertIsInstance(instance, MyPreferences)

0 comments on commit e53e87c

Please sign in to comment.