Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Streamline defining and usage of preferences #1072

Merged
merged 14 commits into from
Jan 15, 2025
Merged
1 change: 1 addition & 0 deletions changelog.d/1072.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
argus.htmx: streamline definition and usage of preferences
9 changes: 3 additions & 6 deletions docs/development/howtos/add-more-preferences.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ In order to add more preferences:
of preferences for the app you could use the app name.
3. Adapt the below boilerplate::

from argus.auth.models import preferences, PreferencesBase
from argus.auth.models import preferences, PreferencesBase, PreferenceField

class MagicNumberForm(forms.Form):
magic_number = forms.IntegerField()
Expand All @@ -23,11 +23,8 @@ In order to add more preferences:
@preferences(namespace="mypref")
class MyPreferences: # Optionally you can inherit from PreferencesBase
# here to get method completion
FORMS = {
"magic_number": MagicNumberForm,
}
_FIELD_DEFAULTS = {
"magic_number": 42,
FIELDS = {
"magic_number": PreferenceField(form=MagicNumberForm, default=42),
}

# Optional Meta for testing, not needed unless only used for tests
Expand Down
14 changes: 12 additions & 2 deletions src/argus/auth/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,23 @@
See django settings for ``TEMPLATES``.
"""

import functools
from argus.auth.models import Preferences


# When ``render`` is called multiple times during a request (such as by HtmxMessageMiddleware),
# that also results in this function being called more than once for a request. That's unnecessary.
# Luckily we can cache the result for a request.
@functools.lru_cache(maxsize=10)
def preferences(request):
preferences_choices = {}
for namespace, cls in Preferences.NAMESPACES.items():
preferences_choices[namespace] = {
name: field.choices for name, field in cls.FIELDS.items() if field.choices is not None
hmpf marked this conversation as resolved.
Show resolved Hide resolved
}
# Try stored preferences first
if request.user.is_authenticated:
return {"preferences": request.user.get_preferences_context()}
return {"preferences": request.user.get_preferences_context(), "preferences_choices": preferences_choices}

# Use defaults if available
prefdict = Preferences.objects.get_all_defaults()
Expand All @@ -23,4 +33,4 @@ def preferences(request):
for namespace, values in request.session["preferences"].items():
prefdict[namespace].update(values)

return {"preferences": prefdict}
return {"preferences": prefdict, "preferences_choices": preferences_choices}
64 changes: 38 additions & 26 deletions src/argus/auth/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

import dataclasses
import functools
from typing import Any, List, Optional, Type, Union, Protocol
from typing import Any, Optional, Sequence, Union, Protocol

from django.contrib.auth.models import AbstractUser, Group
from django.db import models
Expand All @@ -20,6 +21,14 @@ def create(self, **kwargs):
return Manager()


@dataclasses.dataclass
class PreferenceField:
form: forms.Form
choices: Optional[Sequence] = None
default: Optional[Any] = None
partial_response_template: Optional[str] = None


def preferences(cls: Optional[type] = None, namespace: Optional[str] = None):
"""Use this decorator to declare a namespaced subclass of ``Preferences`` without manually
subclassing it. This decorator will add the Preferences model as a base and set the required
Expand All @@ -28,10 +37,13 @@ def preferences(cls: Optional[type] = None, namespace: Optional[str] = None):

Use like the following:

class MagicNumberForm(forms.Form):
magic_number = forms.IntegerField()

@prefrences(namespace="my_namespace")
class MyPreferences:
_FIELD_DEFAULTS = {
"example_pref": "some value"
FIELDS = {
"magic_number": PreferenceField(form=MagicNumberForm, default=42),
hmpf marked this conversation as resolved.
Show resolved Hide resolved
}

In order to get code/method completion, you can inherit from the ``PreferencesBase`` Protocol,
Expand Down Expand Up @@ -145,8 +157,6 @@ def __init__(self, session, namespace):
self._namespace = namespace
self.namespace = namespace
self.prefclass = Preferences.NAMESPACES[namespace]
self.FORMS = self.prefclass.FORMS.copy()
self._FIELD_DEFAULTS = self.prefclass._FIELD_DEFAULTS.copy()
self.session.setdefault("preferences", dict())
self.session["preferences"].setdefault(namespace, self.get_defaults())
self.preferences = self.session["preferences"][namespace]
Expand All @@ -164,26 +174,26 @@ def ensure_for_user(cls, _):
def get_instance(self):
raise NotImplementedError

def get_defaults(self):
return self.prefclass.get_defaults()

def update_context(self, context):
return self.prefclass.update_context(context)

def get_context(self):
return self.prefclass.get_context()
_proxy_attrs = (
"get_forms",
"get_defaults",
"update_context",
"get_context",
"get_preference",
)

def get_preference(self, name):
return self.prefclass.get_preference(name)
def __getattr__(self, name):
if name in self._proxy_attrs:
return getattr(self.prefclass, name)
return super().__getattr__(name)

def save_preference(self, name, value):
self.preferences[name] = value
self.session["preferences"][self._namespace][name] = value


class PreferencesBase(Protocol):
FORMS: dict[str, forms.Form]
_FIELD_DEFAULTS: dict[str, Any]
FIELDS: dict[str, PreferenceField]

@classmethod
def get_defaults(cls) -> dict[str, Any]:
Expand All @@ -201,7 +211,7 @@ class Meta:
models.UniqueConstraint(name="unique_preference", fields=["user", "namespace"]),
]

NAMESPACES: dict[str, Type[Preferences]] = {}
NAMESPACES: dict[str, type[Preferences]] = {}

user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="preferences")
namespace = models.CharField(blank=False, max_length=255)
Expand All @@ -211,17 +221,15 @@ class Meta:
unregistered = UnregisteredPreferencesManager()

# must be set by the subclasses
FORMS: dict[str, forms.Form]
_FIELD_DEFAULTS: dict[str, Any]
FIELDS: dict[str, PreferenceField]

# django methods

# called when subclass is constructing itself
def __init_subclass__(cls, **kwargs):
assert isinstance(getattr(cls, "FORMS", None), dict), f"{cls.__name__}.FORMS must be a dictionary"
assert isinstance(getattr(cls, "_FIELD_DEFAULTS", None), dict), (
f"{cls.__name__}._FIELD_DEFAULTS must be a dictionary"
)
FIELDS = getattr(cls, "FIELDS", None)
if FIELDS is None or not all(isinstance(k, str) and isinstance(v, PreferenceField) for k, v in FIELDS.items()):
raise TypeError(f"{cls.__name__}.FIELDS must be set to a dict[str, PreferenceField]")

super().__init_subclass__(**kwargs)
cls.NAMESPACES[cls._namespace] = cls
Expand All @@ -243,13 +251,17 @@ def get_instance(self):
if subclass:
self.__class__ = subclass

@classmethod
def get_forms(cls):
return {key: field.form for key, field in cls.FIELDS.items()}

@classmethod
def get_defaults(cls):
"Override to add magic"
return cls._FIELD_DEFAULTS.copy() if cls._FIELD_DEFAULTS else {}
return {key: field.default for key, field in cls.FIELDS.items()}

@classmethod
def ensure_for_user(cls, user) -> List[Preferences]:
def ensure_for_user(cls, user) -> list[Preferences]:
all_preferences = {p.namespace: p for p in user.preferences.all()}
valid_preferences = []

Expand Down
44 changes: 35 additions & 9 deletions src/argus/auth/utils.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
from copy import deepcopy
import logging
from typing import Any, Tuple
from typing import Mapping, Union

from django.conf import settings
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend
from django.contrib import messages
from django.http import HttpRequest
from django.utils.module_loading import import_string

from social_core.backends.oauth import BaseOAuth2

from argus.auth.models import SessionPreferences
from argus.auth.models import Preferences, SessionPreferences


_all__ = [
__all__ = [
"get_authentication_backend_classes",
"has_model_backend",
"has_remote_user_backend",
"get_psa_authentication_backends",
"get_preference_obj",
"get_preference",
"save_preference",
"get_or_update_preference",
]


Expand All @@ -45,7 +46,9 @@ def get_psa_authentication_backends(backends=None):
return [backend for backend in backends if issubclass(backend, BaseOAuth2)]


def get_preference_obj(request, namespace):
def get_preference_obj(request, namespace) -> Preferences:
if namespace not in Preferences.NAMESPACES:
raise ValueError(f"Unkown namespace '{namespace}'")
if request.user.is_authenticated:
prefs = request.user.get_namespaced_preferences(namespace)
else:
Expand All @@ -58,21 +61,44 @@ def get_preference(request, namespace, preference):
return prefs.get_preference(preference)


def get_or_update_preference(request, data, namespace, preference) -> Tuple[Any, bool]:
def save_preferences(request, data, namespace_or_prefs: Union[str, Preferences]):
prefs = (
namespace_or_prefs
if isinstance(namespace_or_prefs, Preferences)
else get_preference_obj(request, namespace_or_prefs)
)
saved = []
failed = []
for key in prefs.get_forms():
if key in data:
success = _save_preference(request, prefs, key, data)[1]
if success:
saved.append(key)
else:
failed.append(key)
return saved, failed


def get_or_update_preference(request, data, namespace, preference):
"""Save the single preference given in data to the given namespace

Returns a tuple (value, success). Value is the value of the preference, and success a boolean
indicating whether the preference was successfully updated
"""
prefs = get_preference_obj(request, namespace)
return _save_preference(request, prefs, preference, data)


def _save_preference(request: HttpRequest, prefs: Preferences, preference: str, data: Mapping):
value = prefs.get_preference(preference)
LOG.debug("Changing %s: currently %s", preference, value)

if not data.get(preference, None):
if data.get(preference, None) is None:
LOG.debug("Failed to change %s, not in input: %s", preference, data)
return value, False

form = prefs.FORMS[preference](data)
form = prefs.get_forms()[preference](data)

if not form.is_valid():
messages.warning(request, f"Failed to change {preference}, invalid input")
LOG.warning("Failed to change %s, invalid input: %s", preference, data)
Expand All @@ -82,7 +108,7 @@ def get_or_update_preference(request, data, namespace, preference) -> Tuple[Any,
value = form.cleaned_data[preference]
if value == old_value:
LOG.debug("Did not change %s: no change", preference)
return value, False
return value, True

prefs.save_preference(preference, value)
messages.success(request, f"Changed {preference}: {old_value} → {value}")
Expand Down
3 changes: 2 additions & 1 deletion src/argus/htmx/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
See django settings for ``TEMPLATES``.
"""

from . import defaults
from django.conf import settings

from . import defaults


def path_to_stylesheet(request):
return {"path_to_stylesheet": getattr(settings, "STYLESHEET_PATH", defaults.STYLESHEET_PATH)}
10 changes: 0 additions & 10 deletions src/argus/htmx/dateformat/urls.py

This file was deleted.

26 changes: 0 additions & 26 deletions src/argus/htmx/dateformat/views.py

This file was deleted.

4 changes: 2 additions & 2 deletions src/argus/htmx/templates/htmx/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
Logged in as: <span class="text-info">{{ request.user }}</span>
<div class="divider divider-secondary my-0"></div>
</li>
<li>{% include "htmx/themes/_theme_dropdown.html" %}</li>
<li>{% include "htmx/dateformat/_dateformat_dropdown.html" %}</li>
<li>{% include "htmx/user/_theme_dropdown.html" %}</li>
<li>{% include "htmx/user/_dateformat_dropdown.html" %}</li>
<li>
<a href="{% url 'htmx:user-preferences' %}">Preferences…</a>
</li>
Expand Down
14 changes: 0 additions & 14 deletions src/argus/htmx/templates/htmx/dateformat/_dateformat_dropdown.html

This file was deleted.

16 changes: 0 additions & 16 deletions src/argus/htmx/templates/htmx/dateformat/_dateformat_list.html

This file was deleted.

Loading
Loading