Skip to content

Commit 80649f1

Browse files
feat: Set default channels per notification type and/or an "enabled by default" flag per channel (#22)
1 parent 76706f7 commit 80649f1

File tree

15 files changed

+544
-457
lines changed

15 files changed

+544
-457
lines changed

docs/customizing.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ class SMSChannel(BaseChannel):
1212
supports_realtime = True
1313
supports_digest = False
1414

15+
def should_send(self, notification):
16+
return bool(getattr(notification.recipient, "phone_number", None))
17+
1518
def send_now(self, notification):
1619
# Send SMS using your preferred service
1720
send_sms(
@@ -25,6 +28,7 @@ class SMSChannel(BaseChannel):
2528
Make certain channels mandatory for critical notifications:
2629

2730
```python
31+
from generic_notifications.types import NotificationType
2832
from generic_notifications.channels import EmailChannel
2933

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

42+
## Forbidden Channels
43+
44+
Prevent certain channels from being used for specific notification types:
45+
46+
```python
47+
from generic_notifications.types import NotificationType
48+
from generic_notifications.channels import SMSChannel
49+
50+
@register
51+
class CommentReceivedNotification(NotificationType):
52+
key = "comment_received"
53+
name = "Comment received"
54+
description = "You received a comment"
55+
forbidden_channels = [SMSChannel] # Never send via SMS
56+
```
57+
58+
Forbidden channels take highest priority - they cannot be enabled even if specified in `default_channels`, `required_channels`, or user preferences.
59+
60+
## Defaults Channels
61+
62+
By default all channels are enabled for all users, and for all notifications types. Control which channels are enabled by default.
63+
64+
### Per-Channel Defaults
65+
66+
Disable a channel by default across all notification types:
67+
68+
```python
69+
@register
70+
class SMSChannel(BaseChannel):
71+
key = "sms"
72+
name = "SMS"
73+
supports_realtime = True
74+
supports_digest = False
75+
enabled_by_default = False # Opt-in only - users must explicitly enable
76+
```
77+
78+
### Per-NotificationType Defaults
79+
80+
By default all channels are enabled for every notification type. You can override channel defaults for specific notification types:
81+
82+
```python
83+
@register
84+
class MarketingEmail(NotificationType):
85+
key = "marketing"
86+
name = "Marketing Updates"
87+
# Only enable email by default
88+
# (users can still opt-in to enable other channels)
89+
default_channels = [EmailChannel]
90+
```
91+
92+
### Priority Order
93+
94+
The system determines enabled channels in this priority order:
95+
96+
1. **Forbidden channels** - Always disabled (cannot be overridden)
97+
2. **Required channels** - Always enabled (cannot be disabled)
98+
3. **User preferences** - Explicit user enable/disable choices (see [preferences.md](https://github.com/loopwerk/django-generic-notifications/tree/main/docs/preferences.md))
99+
4. **NotificationType.default_channels** - Per-type defaults (if specified)
100+
5. **BaseChannel.enabled_by_default** - Global channel defaults
101+
38102
## Custom Frequencies
39103

40104
Add custom email frequencies:

docs/preferences.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
## User Preferences
22

3-
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.
3+
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.
44

5-
All notification types default to daily digest, except for `SystemMessage` which defaults to real-time. Users can choose different frequency per notification type.
5+
The system supports both:
6+
7+
- **Channel preferences**: Enable/disable specific channels per notification type
8+
- **Frequency preferences**: Choose between realtime delivery and digest delivery per notification type
69

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

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

3235
```python
33-
from generic_notifications.models import DisabledNotificationTypeChannel, NotificationFrequency
36+
from generic_notifications.models import NotificationTypeChannelPreference, NotificationFrequencyPreference
3437
from generic_notifications.channels import EmailChannel
3538
from generic_notifications.frequencies import RealtimeFrequency
3639
from myapp.notifications import CommentNotification
3740

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

41-
# Change to realtime digest for a notification type
44+
# Enable email channel for comment notifications
45+
CommentNotification.enable_channel(user=user, channel=EmailChannel)
46+
47+
# Check which channels are enabled for a user
48+
enabled_channels = CommentNotification.get_enabled_channels(user)
49+
50+
# Change to realtime frequency for a notification type
4251
CommentNotification.set_frequency(user=user, frequency=RealtimeFrequency)
43-
```
52+
```

example/notifications/admin.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from django.contrib import admin
2-
from generic_notifications.models import DisabledNotificationTypeChannel, Notification, NotificationFrequency
2+
from generic_notifications.models import (
3+
Notification,
4+
NotificationFrequencyPreference,
5+
NotificationTypeChannelPreference,
6+
)
37

48

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

1721

18-
@admin.register(DisabledNotificationTypeChannel)
19-
class DisabledNotificationTypeChannelAdmin(admin.ModelAdmin):
20-
list_display = ["user", "notification_type", "channel"]
22+
@admin.register(NotificationTypeChannelPreference)
23+
class NotificationTypeChannelPreferenceAdmin(admin.ModelAdmin):
24+
list_display = ["user", "notification_type", "channel", "enabled"]
2125

2226

23-
@admin.register(NotificationFrequency)
24-
class NotificationFrequencyAdmin(admin.ModelAdmin):
27+
@admin.register(NotificationFrequencyPreference)
28+
class NotificationFrequencyPreferenceAdmin(admin.ModelAdmin):
2529
list_display = ["user", "notification_type", "frequency"]

example/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

generic_notifications/channels.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class BaseChannel(ABC):
2424
name: str
2525
supports_realtime: bool = True
2626
supports_digest: bool = False
27+
enabled_by_default: bool = True
2728

2829
@classmethod
2930
def should_send(cls, notification: "Notification") -> bool:
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Generated by Django 5.2.5 on 2025-10-20 19:11
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("generic_notifications", "0005_alter_notificationfrequency_options_and_more"),
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
]
13+
14+
operations = [
15+
# Rename NotificationFrequency to NotificationFrequencyPreference
16+
migrations.RenameModel(
17+
old_name="NotificationFrequency",
18+
new_name="NotificationFrequencyPreference",
19+
),
20+
migrations.AlterModelOptions(
21+
name="notificationfrequencypreference",
22+
options={"verbose_name_plural": "Notification frequency preferences"},
23+
),
24+
migrations.AlterField(
25+
model_name="notificationfrequencypreference",
26+
name="user",
27+
field=models.ForeignKey(
28+
on_delete=django.db.models.deletion.CASCADE,
29+
related_name="notification_frequency_preferences",
30+
to=settings.AUTH_USER_MODEL,
31+
),
32+
),
33+
# Rename DisabledNotificationTypeChannel to NotificationTypeChannelPreference
34+
migrations.RenameModel(
35+
old_name="DisabledNotificationTypeChannel",
36+
new_name="NotificationTypeChannelPreference",
37+
),
38+
migrations.AlterField(
39+
model_name="notificationtypechannelpreference",
40+
name="user",
41+
field=models.ForeignKey(
42+
on_delete=django.db.models.deletion.CASCADE,
43+
related_name="notification_type_channel_preferences",
44+
to=settings.AUTH_USER_MODEL,
45+
),
46+
),
47+
# Add the new enabled field with default=False
48+
migrations.AddField(
49+
model_name="notificationtypechannelpreference",
50+
name="enabled",
51+
field=models.BooleanField(default=False),
52+
),
53+
]

generic_notifications/models.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -204,15 +204,17 @@ def __str__(self):
204204
return f"{self.notification} - {self.channel} ({status})"
205205

206206

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

213-
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="disabled_notification_type_channels")
214+
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="notification_type_channel_preferences")
214215
notification_type = models.CharField(max_length=50)
215216
channel = models.CharField(max_length=20)
217+
enabled = models.BooleanField(default=False)
216218

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

234236
# Check if trying to disable a required channel
235-
required_channel_keys = [cls.key for cls in notification_type_cls.required_channels]
236-
if self.channel in required_channel_keys:
237-
raise ValidationError(
238-
f"Cannot disable {self.channel} channel for {notification_type_cls.name} - this channel is required"
239-
)
237+
if not self.enabled:
238+
required_channel_keys = [cls.key for cls in notification_type_cls.required_channels]
239+
if self.channel in required_channel_keys:
240+
raise ValidationError(
241+
f"Cannot disable {self.channel} channel for {notification_type_cls.name} - this channel is required"
242+
)
243+
244+
# Check if trying to enable a forbidden channel
245+
if self.enabled:
246+
forbidden_channel_keys = [cls.key for cls in notification_type_cls.forbidden_channels]
247+
if self.channel in forbidden_channel_keys:
248+
raise ValidationError(
249+
f"Cannot enable {self.channel} channel for {notification_type_cls.name} - this channel is forbidden"
250+
)
240251

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

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

253265

254-
class NotificationFrequency(models.Model):
266+
class NotificationFrequencyPreference(models.Model):
255267
"""
256268
Delivery frequency preference per notification type.
257269
This applies to all channels that support the chosen frequency.
258270
Default is `NotificationType.default_frequency` if no row exists.
259271
"""
260272

261-
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="notification_frequencies")
273+
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="notification_frequency_preferences")
262274
notification_type = models.CharField(max_length=50)
263275
frequency = models.CharField(max_length=20)
264276

265277
class Meta:
266278
unique_together = ["user", "notification_type"]
267-
verbose_name_plural = "Notification frequencies"
279+
verbose_name_plural = "Notification frequency preferences"
268280

269281
def clean(self):
270282
if self.notification_type:

generic_notifications/preferences.py

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from django.contrib.auth.models import AbstractUser
44

5-
from .models import DisabledNotificationTypeChannel, NotificationFrequency
5+
from .models import NotificationFrequencyPreference, NotificationTypeChannelPreference
66
from .registry import registry
77

88

@@ -21,14 +21,15 @@ def get_notification_preferences(user: "AbstractUser") -> List[Dict[str, Any]]:
2121
notification_types = {nt.key: nt for nt in registry.get_all_types()}
2222
channels = {ch.key: ch for ch in registry.get_all_channels()}
2323

24-
# Get user's current disabled channels (opt-out system)
25-
disabled_channels = set(
26-
DisabledNotificationTypeChannel.objects.filter(user=user).values_list("notification_type", "channel")
27-
)
24+
# Get user's channel preferences
25+
channel_preferences = {
26+
(pref.notification_type, pref.channel): pref.enabled
27+
for pref in NotificationTypeChannelPreference.objects.filter(user=user)
28+
}
2829

2930
# Get user's notification frequency preferences
3031
notification_frequencies = dict(
31-
NotificationFrequency.objects.filter(user=user).values_list("notification_type", "frequency")
32+
NotificationFrequencyPreference.objects.filter(user=user).values_list("notification_type", "frequency")
3233
)
3334

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

4445
for channel in channels.values():
4546
channel_key = channel.key
46-
is_disabled = (type_key, channel_key) in disabled_channels
4747
is_required = channel_key in [ch.key for ch in notification_type.required_channels]
4848
is_forbidden = channel_key in [ch.key for ch in notification_type.forbidden_channels]
4949

50+
# Determine if channel is enabled using the same logic as get_enabled_channels
51+
if is_forbidden:
52+
is_enabled = False
53+
elif is_required:
54+
is_enabled = True
55+
elif (type_key, channel_key) in channel_preferences:
56+
# User has explicit preference
57+
is_enabled = channel_preferences[(type_key, channel_key)]
58+
else:
59+
# No user preference - use defaults
60+
if notification_type.default_channels is not None:
61+
is_enabled = channel in notification_type.default_channels
62+
else:
63+
is_enabled = channel.enabled_by_default
64+
5065
type_data["channels"][channel_key] = {
5166
"channel": channel,
52-
"enabled": not is_forbidden and (is_required or not is_disabled),
67+
"enabled": is_enabled,
5368
"required": is_required,
5469
"forbidden": is_forbidden,
5570
}
@@ -67,12 +82,11 @@ def save_notification_preferences(user: "AbstractUser", form_data: Dict[str, Any
6782
- For channels: "{notification_type_key}__{channel_key}" -> "on" (if enabled)
6883
- For notification frequencies: "{notification_type_key}__frequency" -> frequency_key
6984
70-
This function implements an opt-out model: channels are enabled by default
71-
and only disabled entries are stored in the database.
85+
This function stores explicit preferences for both enabled and disabled channels.
7286
"""
7387
# Clear existing preferences to rebuild from form data
74-
DisabledNotificationTypeChannel.objects.filter(user=user).delete()
75-
NotificationFrequency.objects.filter(user=user).delete()
88+
NotificationTypeChannelPreference.objects.filter(user=user).delete()
89+
NotificationFrequencyPreference.objects.filter(user=user).delete()
7690

7791
notification_types = {nt.key: nt for nt in registry.get_all_types()}
7892
channels = {ch.key: ch for ch in registry.get_all_channels()}
@@ -93,9 +107,20 @@ def save_notification_preferences(user: "AbstractUser", form_data: Dict[str, Any
93107
if channel_key in [ch.key for ch in notification_type.forbidden_channels]:
94108
continue
95109

96-
# If checkbox not checked, create disabled entry
97-
if form_key not in form_data:
98-
notification_type.disable_channel(user=user, channel=channel)
110+
# Determine what the default would be for this channel
111+
if notification_type.default_channels is not None:
112+
default_enabled = channel in notification_type.default_channels
113+
else:
114+
default_enabled = channel.enabled_by_default
115+
116+
# Check if form value differs from default
117+
form_enabled = form_key in form_data
118+
if form_enabled != default_enabled:
119+
# Store explicit preference since it differs from default
120+
if form_enabled:
121+
notification_type.enable_channel(user=user, channel=channel)
122+
else:
123+
notification_type.disable_channel(user=user, channel=channel)
99124

100125
# Handle notification frequency preference
101126
frequency_key = f"{type_key}__frequency"

0 commit comments

Comments
 (0)