diff --git a/README.md b/README.md
index d45ca87..e3411e4 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,29 @@ By default every user gets notifications of all registered types delivered to ev
All notification types default to daily digest, except for `SystemMessage` which defaults to real-time. Users can choose different frequency per notification type.
+### Using the preference helpers
+
+The library provides helper functions to simplify building preference management UIs:
+
+```python
+from generic_notifications.preferences import (
+ get_notification_preferences,
+ save_notification_preferences
+)
+
+# Get preferences for display in a form
+# Returns a list of dicts with notification types, channels, and current settings
+preferences = get_notification_preferences(user)
+
+# Save preferences from form data
+# Form field format: {notification_type_key}__{channel_key} and {notification_type_key}__frequency
+save_notification_preferences(user, request.POST)
+```
+
+### Manual preference management
+
+You can also manage preferences directly:
+
```python
from generic_notifications.models import DisabledNotificationTypeChannel, EmailFrequency
from generic_notifications.channels import EmailChannel
@@ -107,7 +130,7 @@ EmailFrequency.objects.update_or_create(
)
```
-This project doesn’t come with a UI (view + template) for managing user preferences, but an example is provided in the [example app](#example-app).
+This project doesn't come with a UI (view + template) for managing user preferences, but an example is provided in the [example app](#example-app).
## Custom Channels
diff --git a/example/notifications/templates/settings.html b/example/notifications/templates/settings.html
index d2a39c4..481deaf 100644
--- a/example/notifications/templates/settings.html
+++ b/example/notifications/templates/settings.html
@@ -38,7 +38,7 @@
Notification settings
{% with channel_data=type_data.channels|dict_get:channel_key %}
Notification settings
{% if "email" in channels %}
-
diff --git a/example/notifications/views.py b/example/notifications/views.py
index cea5d53..0e855c9 100644
--- a/example/notifications/views.py
+++ b/example/notifications/views.py
@@ -1,11 +1,9 @@
-from typing import Any, Dict
-
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.views.generic import View
from generic_notifications import send_notification
-from generic_notifications.models import DisabledNotificationTypeChannel, EmailFrequency
+from generic_notifications.preferences import get_notification_preferences, save_notification_preferences
from generic_notifications.registry import registry
from generic_notifications.utils import get_notifications, mark_notifications_as_read
@@ -70,46 +68,10 @@ def post(self, request):
class NotificationSettingsView(LoginRequiredMixin, View):
def get(self, request):
- # Get all registered notification types, channels, and frequencies
- notification_types = {nt.key: nt for nt in registry.get_all_types()}
+ settings_data = get_notification_preferences(request.user)
channels = {ch.key: ch for ch in registry.get_all_channels()}
frequencies = {freq.key: freq for freq in registry.get_all_frequencies()}
- # Get user's current disabled channels (opt-out system)
- disabled_channels = set(
- DisabledNotificationTypeChannel.objects.filter(user=request.user).values_list(
- "notification_type", "channel"
- )
- )
-
- # Get user's email frequency preferences
- email_frequencies = dict(
- EmailFrequency.objects.filter(user=request.user).values_list("notification_type", "frequency")
- )
-
- # Build settings data structure for template
- settings_data = []
- for notification_type in notification_types.values():
- type_key = notification_type.key
- type_data: Dict[str, Any] = {
- "notification_type": notification_type,
- "channels": {},
- "email_frequency": email_frequencies.get(type_key, notification_type.default_email_frequency.key),
- }
-
- for channel in channels.values():
- channel_key = channel.key
- is_disabled = (type_key, channel_key) in disabled_channels
- is_required = channel_key in [ch.key for ch in notification_type.required_channels]
-
- type_data["channels"][channel_key] = {
- "channel": channel,
- "enabled": is_required or not is_disabled, # Required channels are always enabled
- "required": is_required,
- }
-
- settings_data.append(type_data)
-
context = {
"settings_data": settings_data,
"channels": channels,
@@ -118,43 +80,5 @@ def get(self, request):
return TemplateResponse(request, "settings.html", context=context)
def post(self, request):
- # Clear existing preferences to rebuild from form data
- DisabledNotificationTypeChannel.objects.filter(user=request.user).delete()
- EmailFrequency.objects.filter(user=request.user).delete()
-
- notification_types = {nt.key: nt for nt in registry.get_all_types()}
- channels = {ch.key: ch for ch in registry.get_all_channels()}
- frequencies = {freq.key: freq for freq in registry.get_all_frequencies()}
-
- # Process form data
- for notification_type in notification_types.values():
- type_key = notification_type.key
-
- # Handle channel preferences
- for channel in channels.values():
- channel_key = channel.key
- form_key = f"{type_key}_{channel_key}"
-
- # Check if this channel is required (cannot be disabled)
- if channel_key in [ch.key for ch in notification_type.required_channels]:
- continue
-
- # If checkbox not checked, create disabled entry
- if form_key not in request.POST:
- DisabledNotificationTypeChannel.objects.create(
- user=request.user, notification_type=type_key, channel=channel_key
- )
-
- # Handle email frequency preference
- if "email" in [ch.key for ch in channels.values()]:
- frequency_key = f"{type_key}_frequency"
- if frequency_key in request.POST:
- frequency_value = request.POST[frequency_key]
- if frequency_value in frequencies:
- # Only save if different from default
- if frequency_value != notification_type.default_email_frequency.key:
- EmailFrequency.objects.create(
- user=request.user, notification_type=type_key, frequency=frequency_value
- )
-
+ save_notification_preferences(request.user, request.POST)
return redirect("notification-settings")
diff --git a/generic_notifications/preferences.py b/generic_notifications/preferences.py
new file mode 100644
index 0000000..0e98cd4
--- /dev/null
+++ b/generic_notifications/preferences.py
@@ -0,0 +1,105 @@
+from typing import TYPE_CHECKING, Any, Dict, List
+
+from .models import DisabledNotificationTypeChannel, EmailFrequency
+from .registry import registry
+
+if TYPE_CHECKING:
+ from django.contrib.auth.models import AbstractUser
+
+
+def get_notification_preferences(user: "AbstractUser") -> List[Dict[str, Any]]:
+ """
+ Get notification preferences data for a user.
+
+ Returns a list of dictionaries, each containing:
+ - notification_type: The NotificationType instance
+ - channels: Dict of channel_key -> {channel, enabled, required}
+ - email_frequency: The current email frequency key for this type
+
+ This data structure can be used directly in templates to render
+ notification preference forms.
+ """
+ notification_types = {nt.key: nt for nt in registry.get_all_types()}
+ channels = {ch.key: ch for ch in registry.get_all_channels()}
+
+ # Get user's current disabled channels (opt-out system)
+ disabled_channels = set(
+ DisabledNotificationTypeChannel.objects.filter(user=user).values_list("notification_type", "channel")
+ )
+
+ # Get user's email frequency preferences
+ email_frequencies = dict(EmailFrequency.objects.filter(user=user).values_list("notification_type", "frequency"))
+
+ # Build settings data structure
+ settings_data = []
+ for notification_type in notification_types.values():
+ type_key = notification_type.key
+ type_data: Dict[str, Any] = {
+ "notification_type": notification_type,
+ "channels": {},
+ "email_frequency": email_frequencies.get(type_key, notification_type.default_email_frequency.key),
+ }
+
+ for channel in channels.values():
+ channel_key = channel.key
+ is_disabled = (type_key, channel_key) in disabled_channels
+ is_required = channel_key in [ch.key for ch in notification_type.required_channels]
+
+ type_data["channels"][channel_key] = {
+ "channel": channel,
+ "enabled": is_required or not is_disabled, # Required channels are always enabled
+ "required": is_required,
+ }
+
+ settings_data.append(type_data)
+
+ return settings_data
+
+
+def save_notification_preferences(user: "AbstractUser", form_data: Dict[str, Any]) -> None:
+ """
+ Save notification preferences from form data.
+
+ Expected form_data format:
+ - For channels: "{notification_type_key}__{channel_key}" -> "on" (if enabled)
+ - For email frequencies: "{notification_type_key}__frequency" -> frequency_key
+
+ This function implements an opt-out model: channels are enabled by default
+ and only disabled entries are stored in the database.
+ """
+ # Clear existing preferences to rebuild from form data
+ DisabledNotificationTypeChannel.objects.filter(user=user).delete()
+ EmailFrequency.objects.filter(user=user).delete()
+
+ notification_types = {nt.key: nt for nt in registry.get_all_types()}
+ channels = {ch.key: ch for ch in registry.get_all_channels()}
+ frequencies = {freq.key: freq for freq in registry.get_all_frequencies()}
+
+ # Process form data
+ for notification_type in notification_types.values():
+ type_key = notification_type.key
+
+ # Handle channel preferences
+ for channel in channels.values():
+ channel_key = channel.key
+ form_key = f"{type_key}__{channel_key}"
+
+ # Check if this channel is required (cannot be disabled)
+ if channel_key in [ch.key for ch in notification_type.required_channels]:
+ continue
+
+ # If checkbox not checked, create disabled entry
+ if form_key not in form_data:
+ DisabledNotificationTypeChannel.objects.create(
+ user=user, notification_type=type_key, channel=channel_key
+ )
+
+ # Handle email frequency preference
+ if "email" in [ch.key for ch in channels.values()]:
+ frequency_key = f"{type_key}__frequency"
+ if frequency_key in form_data:
+ frequency_value = form_data[frequency_key]
+ if frequency_value in frequencies:
+ # Only save if different from default
+ if frequency_value != notification_type.default_email_frequency.key:
+ EmailFrequency.objects.create(user=user, notification_type=type_key, frequency=frequency_value)
diff --git a/pyproject.toml b/pyproject.toml
index 78abebe..326b83a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "django-generic-notifications"
-version = "1.0.0"
+version = "1.1.0"
description = "A flexible, multi-channel notification system for Django applications with built-in support for email digests, user preferences, and extensible delivery channels."
authors = [
{name = "Kevin Renskers", email = "kevin@loopwerk.io"},
@@ -10,7 +10,7 @@ license-files = [ "LICENSE" ]
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
- "django>=3.2.0",
+ "django>=4.2.0",
]
keywords = [
"django",
diff --git a/tests/test_preferences.py b/tests/test_preferences.py
new file mode 100644
index 0000000..8b5532b
--- /dev/null
+++ b/tests/test_preferences.py
@@ -0,0 +1,215 @@
+from typing import Any
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from generic_notifications.channels import WebsiteChannel
+from generic_notifications.frequencies import DailyFrequency, RealtimeFrequency
+from generic_notifications.models import DisabledNotificationTypeChannel, EmailFrequency
+from generic_notifications.preferences import (
+ get_notification_preferences,
+ save_notification_preferences,
+)
+from generic_notifications.registry import registry
+from generic_notifications.types import NotificationType
+
+User = get_user_model()
+
+
+class TestNotificationType(NotificationType):
+ key = "test_notification"
+ name = "Test Notification"
+ description = "A test notification type"
+ default_email_frequency = RealtimeFrequency
+ required_channels = []
+
+
+class RequiredChannelNotificationType(NotificationType):
+ key = "required_channel_notification"
+ name = "Required Channel Notification"
+ description = "A notification with required channels"
+ default_email_frequency = DailyFrequency
+ required_channels = [WebsiteChannel]
+
+
+class GetNotificationPreferencesTest(TestCase):
+ user: Any # User model instance created in setUpClass
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass")
+
+ # Register custom notification types
+ registry.register_type(TestNotificationType, force=True)
+ registry.register_type(RequiredChannelNotificationType, force=True)
+
+ def test_opt_out_model_all_channels_enabled_by_default(self):
+ """Test the opt-out model: all channels are enabled by default."""
+ preferences = get_notification_preferences(self.user)
+
+ for pref in preferences:
+ if pref["notification_type"].key == "test_notification":
+ self.assertTrue(pref["channels"]["website"]["enabled"])
+ self.assertTrue(pref["channels"]["email"]["enabled"])
+ self.assertFalse(pref["channels"]["website"]["required"])
+ self.assertFalse(pref["channels"]["email"]["required"])
+
+ def test_disabled_channels_are_reflected_in_preferences(self):
+ """Test that disabled channels are properly reflected."""
+ # Disable email for test notification
+ DisabledNotificationTypeChannel.objects.create(
+ user=self.user, notification_type="test_notification", channel="email"
+ )
+
+ preferences = get_notification_preferences(self.user)
+
+ for pref in preferences:
+ if pref["notification_type"].key == "test_notification":
+ self.assertTrue(pref["channels"]["website"]["enabled"])
+ self.assertFalse(pref["channels"]["email"]["enabled"])
+
+ def test_email_frequency_defaults_and_overrides(self):
+ """Test email frequency business logic: defaults and custom overrides."""
+ # First check defaults
+ preferences = get_notification_preferences(self.user)
+
+ for pref in preferences:
+ if pref["notification_type"].key == "test_notification":
+ self.assertEqual(pref["email_frequency"], "realtime")
+ elif pref["notification_type"].key == "required_channel_notification":
+ self.assertEqual(pref["email_frequency"], "daily")
+
+ # Now override one
+ EmailFrequency.objects.create(user=self.user, notification_type="test_notification", frequency="daily")
+
+ preferences = get_notification_preferences(self.user)
+
+ for pref in preferences:
+ if pref["notification_type"].key == "test_notification":
+ self.assertEqual(pref["email_frequency"], "daily")
+
+
+class SaveNotificationPreferencesTest(TestCase):
+ user: Any # User model instance created in setUpClass
+ other_user: Any # Second user for isolation testing
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.user = User.objects.create_user(username="testuser2", email="test2@example.com", password="testpass")
+ cls.other_user = User.objects.create_user(username="otheruser", email="other@example.com", password="testpass")
+
+ # Register custom notification types
+ registry.register_type(TestNotificationType, force=True)
+ registry.register_type(RequiredChannelNotificationType, force=True)
+
+ def test_complete_form_save_workflow(self):
+ """Test the complete form save workflow with channels and frequencies."""
+ form_data = {
+ # User wants website only for test notifications
+ "test_notification__website": "on",
+ "test_notification__frequency": "daily",
+ # User wants both channels for required channel notifications
+ "required_channel_notification__website": "on",
+ "required_channel_notification__email": "on",
+ "required_channel_notification__frequency": "realtime",
+ }
+
+ save_notification_preferences(self.user, form_data)
+
+ # Verify disabled channels for our test notification type
+ disabled = DisabledNotificationTypeChannel.objects.filter(user=self.user, notification_type="test_notification")
+ self.assertEqual(disabled.count(), 1)
+ self.assertEqual(disabled.first().channel, "email")
+
+ # Verify frequencies (only non-defaults saved)
+ frequencies = EmailFrequency.objects.filter(user=self.user).order_by("notification_type")
+ self.assertEqual(frequencies.count(), 2)
+
+ test_freq = frequencies.filter(notification_type="test_notification").first()
+ self.assertEqual(test_freq.frequency, "daily")
+
+ required_freq = frequencies.filter(notification_type="required_channel_notification").first()
+ self.assertEqual(required_freq.frequency, "realtime")
+
+ def test_preferences_cleared_before_saving_new_ones(self):
+ """Test that old preferences are properly cleared when saving new ones."""
+ # Create some existing preferences
+ DisabledNotificationTypeChannel.objects.create(
+ user=self.user, notification_type="test_notification", channel="website"
+ )
+ EmailFrequency.objects.create(user=self.user, notification_type="test_notification", frequency="daily")
+
+ # Save completely different preferences
+ form_data = {
+ "test_notification__website": "on",
+ "test_notification__email": "on",
+ # No frequency specified - should use default
+ }
+
+ save_notification_preferences(self.user, form_data)
+
+ # Old disabled entry should be gone for test_notification
+ disabled = DisabledNotificationTypeChannel.objects.filter(user=self.user, notification_type="test_notification")
+ self.assertEqual(disabled.count(), 0)
+
+ # Old frequency should be gone
+ frequencies = EmailFrequency.objects.filter(user=self.user)
+ self.assertEqual(frequencies.count(), 0)
+
+ def test_required_channels_ignored_in_form_data(self):
+ """Test that required channels cannot be disabled even if missing from form."""
+ form_data = {
+ # Website not included for required_channel_notification (trying to disable it)
+ "required_channel_notification__email": "on",
+ }
+
+ save_notification_preferences(self.user, form_data)
+
+ # Website should NOT be in disabled channels because it's required
+ disabled = DisabledNotificationTypeChannel.objects.filter(
+ user=self.user, notification_type="required_channel_notification", channel="website"
+ )
+ self.assertEqual(disabled.count(), 0)
+
+ def test_user_preferences_are_isolated(self):
+ """Test that preferences are properly isolated between users."""
+ # Set preferences for first user
+ form_data = {"test_notification__email": "on"}
+ save_notification_preferences(self.user, form_data)
+
+ # Set different preferences for second user
+ other_form_data = {"test_notification__website": "on"}
+ save_notification_preferences(self.other_user, other_form_data)
+
+ # Check first user's preferences
+ user_disabled = DisabledNotificationTypeChannel.objects.filter(
+ user=self.user, notification_type="test_notification"
+ )
+ self.assertEqual(user_disabled.count(), 1)
+ self.assertEqual(user_disabled.first().channel, "website")
+
+ # Check second user's preferences
+ other_disabled = DisabledNotificationTypeChannel.objects.filter(
+ user=self.other_user, notification_type="test_notification"
+ )
+ self.assertEqual(other_disabled.count(), 1)
+ self.assertEqual(other_disabled.first().channel, "email")
+
+ def test_only_non_default_frequencies_are_saved(self):
+ """Test the optimization that only non-default frequencies are stored."""
+ form_data = {
+ # Using default frequency for test_notification (realtime)
+ "test_notification__frequency": "realtime",
+ # Using non-default for required_channel_notification (default is daily)
+ "required_channel_notification__frequency": "realtime",
+ }
+
+ save_notification_preferences(self.user, form_data)
+
+ # Only the non-default frequency should be saved
+ frequencies = EmailFrequency.objects.filter(user=self.user)
+ self.assertEqual(frequencies.count(), 1)
+ self.assertEqual(frequencies.first().notification_type, "required_channel_notification")
+ self.assertEqual(frequencies.first().frequency, "realtime")