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
64 changes: 64 additions & 0 deletions docs/customizing.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ class SMSChannel(BaseChannel):
supports_realtime = True
supports_digest = False

def should_send(self, notification):
return bool(getattr(notification.recipient, "phone_number", None))

def send_now(self, notification):
# Send SMS using your preferred service
send_sms(
Expand All @@ -25,6 +28,7 @@ class SMSChannel(BaseChannel):
Make certain channels mandatory for critical notifications:

```python
from generic_notifications.types import NotificationType
from generic_notifications.channels import EmailChannel

@register
Expand All @@ -35,6 +39,66 @@ class SecurityAlert(NotificationType):
required_channels = [EmailChannel] # Cannot be disabled
```

## Forbidden Channels

Prevent certain channels from being used for specific notification types:

```python
from generic_notifications.types import NotificationType
from generic_notifications.channels import SMSChannel

@register
class CommentReceivedNotification(NotificationType):
key = "comment_received"
name = "Comment received"
description = "You received a comment"
forbidden_channels = [SMSChannel] # Never send via SMS
```

Forbidden channels take highest priority - they cannot be enabled even if specified in `default_channels`, `required_channels`, or user preferences.

## Defaults Channels

By default all channels are enabled for all users, and for all notifications types. Control which channels are enabled by default.

### Per-Channel Defaults

Disable a channel by default across all notification types:

```python
@register
class SMSChannel(BaseChannel):
key = "sms"
name = "SMS"
supports_realtime = True
supports_digest = False
enabled_by_default = False # Opt-in only - users must explicitly enable
```

### Per-NotificationType Defaults

By default all channels are enabled for every notification type. You can override channel defaults for specific notification types:

```python
@register
class MarketingEmail(NotificationType):
key = "marketing"
name = "Marketing Updates"
# Only enable email by default
# (users can still opt-in to enable other channels)
default_channels = [EmailChannel]
```

### Priority Order

The system determines enabled channels in this priority order:

1. **Forbidden channels** - Always disabled (cannot be overridden)
2. **Required channels** - Always enabled (cannot be disabled)
3. **User preferences** - Explicit user enable/disable choices (see [preferences.md](https://github.com/loopwerk/django-generic-notifications/tree/main/docs/preferences.md))
4. **NotificationType.default_channels** - Per-type defaults (if specified)
5. **BaseChannel.enabled_by_default** - Global channel defaults

## Custom Frequencies

Add custom email frequencies:
Expand Down
19 changes: 14 additions & 5 deletions docs/preferences.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
## User Preferences

By default every user gets notifications of all registered types delivered to every registered channel, but users can opt-out of receiving notification types, per channel.
By default, users receive notifications based on the channel defaults configured for each notification type and channel (see [customizing.md](https://github.com/loopwerk/django-generic-notifications/tree/main/docs/customizing.md)). Users can then customize their preferences by explicitly enabling or disabling specific channels for each notification type.

All notification types default to daily digest, except for `SystemMessage` which defaults to real-time. Users can choose different frequency per notification type.
The system supports both:

- **Channel preferences**: Enable/disable specific channels per notification type
- **Frequency preferences**: Choose between realtime delivery and digest delivery per notification type

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).

Expand Down Expand Up @@ -30,14 +33,20 @@ save_notification_preferences(user, request.POST)
You can also manage preferences directly:

```python
from generic_notifications.models import DisabledNotificationTypeChannel, NotificationFrequency
from generic_notifications.models import NotificationTypeChannelPreference, NotificationFrequencyPreference
from generic_notifications.channels import EmailChannel
from generic_notifications.frequencies import RealtimeFrequency
from myapp.notifications import CommentNotification

# Disable email channel for comment notifications
CommentNotification.disable_channel(user=user, channel=EmailChannel)

# Change to realtime digest for a notification type
# Enable email channel for comment notifications
CommentNotification.enable_channel(user=user, channel=EmailChannel)

# Check which channels are enabled for a user
enabled_channels = CommentNotification.get_enabled_channels(user)

# Change to realtime frequency for a notification type
CommentNotification.set_frequency(user=user, frequency=RealtimeFrequency)
```
```
16 changes: 10 additions & 6 deletions example/notifications/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from django.contrib import admin
from generic_notifications.models import DisabledNotificationTypeChannel, Notification, NotificationFrequency
from generic_notifications.models import (
Notification,
NotificationFrequencyPreference,
NotificationTypeChannelPreference,
)


@admin.register(Notification)
Expand All @@ -15,11 +19,11 @@ def get_channels(self, obj):
return ", ".join(channels) if channels else "-"


@admin.register(DisabledNotificationTypeChannel)
class DisabledNotificationTypeChannelAdmin(admin.ModelAdmin):
list_display = ["user", "notification_type", "channel"]
@admin.register(NotificationTypeChannelPreference)
class NotificationTypeChannelPreferenceAdmin(admin.ModelAdmin):
list_display = ["user", "notification_type", "channel", "enabled"]


@admin.register(NotificationFrequency)
class NotificationFrequencyAdmin(admin.ModelAdmin):
@admin.register(NotificationFrequencyPreference)
class NotificationFrequencyPreferenceAdmin(admin.ModelAdmin):
list_display = ["user", "notification_type", "frequency"]
2 changes: 1 addition & 1 deletion example/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions generic_notifications/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class BaseChannel(ABC):
name: str
supports_realtime: bool = True
supports_digest: bool = False
enabled_by_default: bool = True

@classmethod
def should_send(cls, notification: "Notification") -> bool:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Generated by Django 5.2.5 on 2025-10-20 19:11

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


class Migration(migrations.Migration):
dependencies = [
("generic_notifications", "0005_alter_notificationfrequency_options_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
# Rename NotificationFrequency to NotificationFrequencyPreference
migrations.RenameModel(
old_name="NotificationFrequency",
new_name="NotificationFrequencyPreference",
),
migrations.AlterModelOptions(
name="notificationfrequencypreference",
options={"verbose_name_plural": "Notification frequency preferences"},
),
migrations.AlterField(
model_name="notificationfrequencypreference",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_frequency_preferences",
to=settings.AUTH_USER_MODEL,
),
),
# Rename DisabledNotificationTypeChannel to NotificationTypeChannelPreference
migrations.RenameModel(
old_name="DisabledNotificationTypeChannel",
new_name="NotificationTypeChannelPreference",
),
migrations.AlterField(
model_name="notificationtypechannelpreference",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_type_channel_preferences",
to=settings.AUTH_USER_MODEL,
),
),
# Add the new enabled field with default=False
migrations.AddField(
model_name="notificationtypechannelpreference",
name="enabled",
field=models.BooleanField(default=False),
),
]
38 changes: 25 additions & 13 deletions generic_notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,17 @@ def __str__(self):
return f"{self.notification} - {self.channel} ({status})"


class DisabledNotificationTypeChannel(models.Model):
class NotificationTypeChannelPreference(models.Model):
"""
If a row exists here, that notification type/channel combination is DISABLED for the user.
By default (no row), all notifications are enabled on all channels.
Stores explicit user preferences for notification type/channel combinations.
If no row exists, the default behavior (from NotificationType.default_channels
or BaseChannel.enabled_by_default) is used.
"""

user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="disabled_notification_type_channels")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="notification_type_channel_preferences")
notification_type = models.CharField(max_length=50)
channel = models.CharField(max_length=20)
enabled = models.BooleanField(default=False)

class Meta:
unique_together = ["user", "notification_type", "channel"]
Expand All @@ -232,11 +234,20 @@ def clean(self):
)

# Check if trying to disable a required channel
required_channel_keys = [cls.key for cls in notification_type_cls.required_channels]
if self.channel in required_channel_keys:
raise ValidationError(
f"Cannot disable {self.channel} channel for {notification_type_cls.name} - this channel is required"
)
if not self.enabled:
required_channel_keys = [cls.key for cls in notification_type_cls.required_channels]
if self.channel in required_channel_keys:
raise ValidationError(
f"Cannot disable {self.channel} channel for {notification_type_cls.name} - this channel is required"
)

# Check if trying to enable a forbidden channel
if self.enabled:
forbidden_channel_keys = [cls.key for cls in notification_type_cls.forbidden_channels]
if self.channel in forbidden_channel_keys:
raise ValidationError(
f"Cannot enable {self.channel} channel for {notification_type_cls.name} - this channel is forbidden"
)

try:
registry.get_channel(self.channel)
Expand All @@ -248,23 +259,24 @@ def clean(self):
raise ValidationError(f"Unknown channel: {self.channel}. No channels are currently registered.")

def __str__(self) -> str:
return f"{self.user} disabled {self.notification_type} on {self.channel}"
status = "enabled" if self.enabled else "disabled"
return f"{self.user} {status} {self.notification_type} on {self.channel}"


class NotificationFrequency(models.Model):
class NotificationFrequencyPreference(models.Model):
"""
Delivery frequency preference per notification type.
This applies to all channels that support the chosen frequency.
Default is `NotificationType.default_frequency` if no row exists.
"""

user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="notification_frequencies")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="notification_frequency_preferences")
notification_type = models.CharField(max_length=50)
frequency = models.CharField(max_length=20)

class Meta:
unique_together = ["user", "notification_type"]
verbose_name_plural = "Notification frequencies"
verbose_name_plural = "Notification frequency preferences"

def clean(self):
if self.notification_type:
Expand Down
55 changes: 40 additions & 15 deletions generic_notifications/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from django.contrib.auth.models import AbstractUser

from .models import DisabledNotificationTypeChannel, NotificationFrequency
from .models import NotificationFrequencyPreference, NotificationTypeChannelPreference
from .registry import registry


Expand All @@ -21,14 +21,15 @@ def get_notification_preferences(user: "AbstractUser") -> List[Dict[str, Any]]:
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 channel preferences
channel_preferences = {
(pref.notification_type, pref.channel): pref.enabled
for pref in NotificationTypeChannelPreference.objects.filter(user=user)
}

# Get user's notification frequency preferences
notification_frequencies = dict(
NotificationFrequency.objects.filter(user=user).values_list("notification_type", "frequency")
NotificationFrequencyPreference.objects.filter(user=user).values_list("notification_type", "frequency")
)

# Build settings data structure
Expand All @@ -43,13 +44,27 @@ def get_notification_preferences(user: "AbstractUser") -> List[Dict[str, Any]]:

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]
is_forbidden = channel_key in [ch.key for ch in notification_type.forbidden_channels]

# Determine if channel is enabled using the same logic as get_enabled_channels
if is_forbidden:
is_enabled = False
elif is_required:
is_enabled = True
elif (type_key, channel_key) in channel_preferences:
# User has explicit preference
is_enabled = channel_preferences[(type_key, channel_key)]
else:
# No user preference - use defaults
if notification_type.default_channels is not None:
is_enabled = channel in notification_type.default_channels
else:
is_enabled = channel.enabled_by_default

type_data["channels"][channel_key] = {
"channel": channel,
"enabled": not is_forbidden and (is_required or not is_disabled),
"enabled": is_enabled,
"required": is_required,
"forbidden": is_forbidden,
}
Expand All @@ -67,12 +82,11 @@ def save_notification_preferences(user: "AbstractUser", form_data: Dict[str, Any
- For channels: "{notification_type_key}__{channel_key}" -> "on" (if enabled)
- For notification 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.
This function stores explicit preferences for both enabled and disabled channels.
"""
# Clear existing preferences to rebuild from form data
DisabledNotificationTypeChannel.objects.filter(user=user).delete()
NotificationFrequency.objects.filter(user=user).delete()
NotificationTypeChannelPreference.objects.filter(user=user).delete()
NotificationFrequencyPreference.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()}
Expand All @@ -93,9 +107,20 @@ def save_notification_preferences(user: "AbstractUser", form_data: Dict[str, Any
if channel_key in [ch.key for ch in notification_type.forbidden_channels]:
continue

# If checkbox not checked, create disabled entry
if form_key not in form_data:
notification_type.disable_channel(user=user, channel=channel)
# Determine what the default would be for this channel
if notification_type.default_channels is not None:
default_enabled = channel in notification_type.default_channels
else:
default_enabled = channel.enabled_by_default

# Check if form value differs from default
form_enabled = form_key in form_data
if form_enabled != default_enabled:
# Store explicit preference since it differs from default
if form_enabled:
notification_type.enable_channel(user=user, channel=channel)
else:
notification_type.disable_channel(user=user, channel=channel)

# Handle notification frequency preference
frequency_key = f"{type_key}__frequency"
Expand Down
Loading