Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -107,7 +130,7 @@ EmailFrequency.objects.update_or_create(
)
```

This project doesnt 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

Expand Down
4 changes: 2 additions & 2 deletions example/notifications/templates/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ <h1 class="text-xl">Notification settings</h1>
<td class="text-center">
{% with channel_data=type_data.channels|dict_get:channel_key %}
<input type="checkbox"
name="{{ type_data.notification_type.key }}_{{ channel_key }}"
name="{{ type_data.notification_type.key }}__{{ channel_key }}"
class="checkbox checkbox-primary"
{% if channel_data.enabled %}checked{% endif %}
{% if channel_data.required %}disabled{% endif %}
Expand All @@ -51,7 +51,7 @@ <h1 class="text-xl">Notification settings</h1>
<!-- Email Frequency Select -->
{% if "email" in channels %}
<td class="text-center">
<select name="{{ type_data.notification_type.key }}_frequency"
<select name="{{ type_data.notification_type.key }}__frequency"
class="select select-bordered select-sm"
:disabled="!emailEnabled"
:class="{ 'opacity-50': !emailEnabled }">
Expand Down
82 changes: 3 additions & 79 deletions example/notifications/views.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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")
105 changes: 105 additions & 0 deletions generic_notifications/preferences.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"},
Expand All @@ -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",
Expand Down
Loading