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
55 changes: 29 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,13 @@ NOTIFICATION_BASE_URL = "www.example.com"
```

**Protocol handling**: If you omit the protocol, it's automatically added:
- `https://` in production (`DEBUG = False`)

- `https://` in production (`DEBUG = False`)
- `http://` in development (`DEBUG = True`)

**Fallback order** if `NOTIFICATION_BASE_URL` is not set:
1. `BASE_URL` setting

1. `BASE_URL` setting
2. `SITE_URL` setting
3. Django Sites framework (if `django.contrib.sites` is installed)
4. URLs remain relative if no base URL is found (not ideal in emails!)
Expand Down Expand Up @@ -102,14 +104,14 @@ Create a cron job to send daily digests:

```bash
# Send daily digests at 9 AM
0 9 * * * cd /path/to/project && uv run ./manage.py send_digest_emails --frequency daily
0 9 * * * cd /path/to/project && uv run ./manage.py send_notification_digests --frequency daily
```

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

All notification types default to daily digest, except for `SystemMessage` which defaults to real-time. Users can choose different frequency per 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.

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 @@ -137,7 +139,7 @@ save_notification_preferences(user, request.POST)
You can also manage preferences directly:

```python
from generic_notifications.models import DisabledNotificationTypeChannel, EmailFrequency
from generic_notifications.models import DisabledNotificationTypeChannel, NotificationFrequency
from generic_notifications.channels import EmailChannel
from generic_notifications.frequencies import RealtimeFrequency
from myapp.notifications import CommentNotification
Expand All @@ -146,18 +148,18 @@ from myapp.notifications import CommentNotification
CommentNotification.disable_channel(user=user, channel=EmailChannel)

# Change to realtime digest for a notification type
CommentNotification.set_email_frequency(user=user, frequency=RealtimeFrequency)
CommentNotification.set_frequency(user=user, frequency=RealtimeFrequency)
```

## Custom Channels

Create custom delivery channels:

```python
from generic_notifications.channels import NotificationChannel, register
from generic_notifications.channels import BaseChannel, register

@register
class SMSChannel(NotificationChannel):
class SMSChannel(BaseChannel):
key = "sms"
name = "SMS"

Expand All @@ -174,21 +176,21 @@ class SMSChannel(NotificationChannel):
Add custom email frequencies:

```python
from generic_notifications.frequencies import NotificationFrequency, register
from generic_notifications.frequencies import BaseFrequency, register

@register
class WeeklyFrequency(NotificationFrequency):
class WeeklyFrequency(BaseFrequency):
key = "weekly"
name = "Weekly digest"
is_realtime = False
description = "Receive a weekly summary every Monday"
```

When you add custom email frequencies youll have to run `send_digest_emails` for them as well. For example, if you created that weekly digest:
When you add custom email frequencies you'll have to run `send_notification_digests` for them as well. For example, if you created that weekly digest:

```bash
# Send weekly digest every Monday at 9 AM
0 9 * * 1 cd /path/to/project && uv run ./manage.py send_digest_emails --frequency weekly
0 9 * * 1 cd /path/to/project && uv run ./manage.py send_notification_digests --frequency weekly
```

## Email Templates
Expand Down Expand Up @@ -242,7 +244,7 @@ unread_count = get_unread_count(user=user, channel=WebsiteChannel)
unread_notifications = get_notifications(user=user, channel=WebsiteChannel, unread_only=True)

# Get notifications by channel
website_notifications = Notification.objects.for_channel(WebsiteChannel)
website_notifications = Notification.objects.prefetch().for_channel(WebsiteChannel)

# Mark as read
notification = website_notifications.first()
Expand Down Expand Up @@ -303,9 +305,9 @@ The `should_save` method is called before saving each notification. Return `Fals

The `target` field is a GenericForeignKey that can point to any Django model instance. While convenient, accessing targets requires careful consideration for performance.

When using Django 5.0+, this library automatically includes `.prefetch_related("target")` when using the standard query methods. This efficiently fetches target objects, but only the *direct* targets - accessing relationships *through* the target will still cause additional queries.
When using Django 5.0+, this library automatically includes `.prefetch_related("target")` when using the standard query methods. This efficiently fetches target objects, but only the _direct_ targets - accessing relationships _through_ the target will still cause additional queries.

*On Django 4.2, you'll need to manually deal with prefetching the `target` relationship.*
_On Django 4.2, you'll need to manually deal with prefetching the `target` relationship._

Consider this problematic example that will cause N+1 queries:

Expand All @@ -331,7 +333,7 @@ class CommentNotificationType(NotificationType):
actor_name = notification.actor.full_name
article = notification.target.article
comment_text = notification.target.comment_text

# This causes an extra query per notification!
return f'{actor_name} commented on your article "{article.title}": "{comment_text}"'
```
Expand Down Expand Up @@ -365,14 +367,15 @@ However, this only works if you don’t need to dynamically generate the text -
If you must access relationships through the target, you can prefetch them:

```python
# On Django 5.0+ the library already prefetches targets,
# On Django 5.0+ the library already prefetches targets,
# but you need to add deeper relationships yourself
notifications = get_notifications(user).prefetch_related(
"target__article" # This prevents the N+1 problem
)
```

**Note**: This approach has limitations:

- You need to know the target's type and relationships in advance
- It won't work efficiently with heterogeneous targets (different model types)
- Each additional relationship level requires explicit prefetching
Expand Down Expand Up @@ -408,7 +411,7 @@ class CommentNotificationType(NotificationType):
current_lang = get_language()
# Get parameters for current language, fallback to English
lang_params = notification.metadata.get(current_lang, notification.metadata.get("en", {}))

return _("%(commenter_name)s commented on %(page_title)s") % lang_params

# When creating the notification
Expand All @@ -418,17 +421,17 @@ from django.utils.translation import activate, get_language
def create_multilingual_notification(recipient, commenter, page):
current_lang = get_language()
multilingual_metadata = {}

# Store parameters for each language
for lang_code, _ in settings.LANGUAGES:
activate(lang_code)
multilingual_metadata[lang_code] = {
"commenter_name": commenter.get_full_name(),
"page_title": page.get_title(), # Assumes this returns translated title
}

activate(current_lang) # Restore original language

send_notification(
recipient=recipient,
notification_type=CommentNotificationType,
Expand All @@ -454,7 +457,7 @@ class CommentNotificationType(NotificationType):

def get_text(self, notification):
from django.utils.translation import gettext as _

# Access current language data from the target
if notification.target:
return _("%(commenter)s commented on %(page_title)s") % {
Expand All @@ -477,10 +480,10 @@ send_notification(

### Performance considerations

| Approach | Storage Overhead | Query Performance | Translation Freshness |
|----------|------------------|-------------------|----------------------|
| Approach 1 | Moderate | Excellent | Frozen when created |
| Approach 2 | None | Good (with prefetching) | Always current |
| Approach | Storage Overhead | Query Performance | Translation Freshness |
| ---------- | ---------------- | ----------------------- | --------------------- |
| Approach 1 | Moderate | Excellent | Frozen when created |
| Approach 2 | None | Good (with prefetching) | Always current |

- Use **approach 1** if you have performance-critical displays and can accept that text is frozen when the notification is created
- Use **approach 2** if you need always-current data
Expand Down
16 changes: 12 additions & 4 deletions example/notifications/admin.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
from django.contrib import admin
from generic_notifications.models import DisabledNotificationTypeChannel, EmailFrequency, Notification
from generic_notifications.models import DisabledNotificationTypeChannel, Notification, NotificationFrequency


@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = ["recipient", "notification_type", "added", "channels"]
list_display = ["recipient", "notification_type", "added", "get_channels"]

def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("channels")

@admin.display(description="Channels")
def get_channels(self, obj):
channels = obj.channels.values_list("channel", flat=True)
return ", ".join(channels) if channels else "-"


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


@admin.register(EmailFrequency)
class EmailFrequencyAdmin(admin.ModelAdmin):
@admin.register(NotificationFrequency)
class NotificationFrequencyAdmin(admin.ModelAdmin):
list_display = ["user", "notification_type", "frequency"]
4 changes: 2 additions & 2 deletions example/notifications/templates/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ <h1 class="text-xl">Notification settings</h1>
</td>
{% endfor %}

<!-- Email Frequency Select -->
<!-- Notification Frequency Select -->
{% if "email" in channels %}
<td class="text-center">
<select name="{{ type_data.notification_type.key }}__frequency"
class="select select-bordered select-sm"
:disabled="!emailEnabled"
:class="{ 'opacity-50': !emailEnabled }">
{% for freq_key, frequency in frequencies.items %}
<option value="{{ freq_key }}" {% if freq_key == type_data.email_frequency %}selected{% endif %}>{{ frequency.name }}</option>
<option value="{{ freq_key }}" {% if freq_key == type_data.notification_frequency %}selected{% endif %}>{{ frequency.name }}</option>
{% endfor %}
</select>
</td>
Expand Down
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.

12 changes: 9 additions & 3 deletions generic_notifications/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def send_notification(
Raises:
ValueError: If notification_type is not registered
"""
from .models import Notification
from .models import Notification, NotificationChannel
from .registry import registry

# Validate notification type is registered
Expand All @@ -62,13 +62,12 @@ def send_notification(
if not enabled_channel_classes:
return None

# Create the notification record with enabled channels
# Create the notification record
notification = Notification(
recipient=recipient,
notification_type=notification_type.key,
actor=actor,
target=target,
channels=[channel_cls.key for channel_cls in enabled_channel_classes],
subject=subject,
text=text,
url=url,
Expand All @@ -81,6 +80,13 @@ def send_notification(
if notification_type.should_save(notification):
notification.save()

# Create NotificationChannel entries for each enabled channel
for channel_cls in enabled_channel_classes:
NotificationChannel.objects.create(
notification=notification,
channel=channel_cls.key,
)

# Process through enabled channels only
enabled_channel_instances = [channel_cls() for channel_cls in enabled_channel_classes]
for channel_instance in enabled_channel_instances:
Expand Down
1 change: 1 addition & 0 deletions generic_notifications/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
class GenericNotificationsConfig(AppConfig):
name = "generic_notifications"
verbose_name = "Generic Notifications"
default_auto_field = "django.db.models.BigAutoField"
Loading