From 748a9f4d62f89226e2ccc7b834d2c56c35f0dcae Mon Sep 17 00:00:00 2001 From: Kevin Renskers Date: Mon, 20 Oct 2025 22:05:14 +0200 Subject: [PATCH 1/6] Set default channels per notification type and/or an "enabled by default" flag per channel This also means switching away from DisabledNotificationTypeChannel which only stored opt-outs Closes #20 --- docs/customizing.md | 110 ++++++++++++++ docs/preferences.md | 35 ++++- example/notifications/admin.py | 16 +- example/uv.lock | 2 +- generic_notifications/channels.py | 1 + ...ationfrequency_unique_together_and_more.py | 53 +++++++ generic_notifications/models.py | 38 +++-- generic_notifications/preferences.py | 55 +++++-- generic_notifications/types.py | 85 ++++++----- tests/test_channel_defaults.py | 138 ++++++++++++++++++ tests/test_channels.py | 61 +++----- tests/test_management_commands.py | 56 +++++-- tests/test_models.py | 134 ++++------------- tests/test_preferences.py | 40 +++-- tests/test_types.py | 111 +++++--------- 15 files changed, 605 insertions(+), 330 deletions(-) create mode 100644 generic_notifications/migrations/0006_alter_notificationfrequency_unique_together_and_more.py create mode 100644 tests/test_channel_defaults.py diff --git a/docs/customizing.md b/docs/customizing.md index 6821f15..2900696 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -20,6 +20,90 @@ class SMSChannel(BaseChannel): ) ``` +## Channel Defaults + +Control which channels are enabled by default for different notification types and channels. + +### Per-Channel Defaults + +Set whether a channel is enabled by default across all notification types: + +```python +@register +class SMSChannel(BaseChannel): + key = "sms" + name = "SMS" + enabled_by_default = False # Opt-in only - users must explicitly enable + supports_realtime = True + +@register +class PushChannel(BaseChannel): + key = "push" + name = "Push Notifications" + enabled_by_default = True # Opt-out - enabled unless user disables + supports_realtime = True +``` + +The default value for `enabled_by_default` is `True`, maintaining backward compatibility. + +### Per-NotificationType Defaults + +Override channel defaults for specific notification types: + +```python +@register +class MarketingEmail(NotificationType): + key = "marketing" + name = "Marketing Updates" + # Only enable email by default, disable website notifications + default_channels = [EmailChannel] + +@register +class UrgentAlert(NotificationType): + key = "urgent_alert" + name = "Urgent Alerts" + # Enable all channels including normally opt-in ones + default_channels = [EmailChannel, WebsiteChannel, SMSChannel, PushChannel] +``` + +When `default_channels` is specified, it overrides the global `enabled_by_default` settings. If `default_channels` is `None` (the default), the system uses each channel's `enabled_by_default` setting. + +### 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 +4. **NotificationType.default_channels** - Per-type defaults (if specified) +5. **BaseChannel.enabled_by_default** - Global channel defaults + +### Examples + +```python +# Example: Marketing emails are opt-in only +@register +class MarketingEmail(NotificationType): + key = "marketing" + name = "Marketing Emails" + default_channels = [] # No channels enabled by default + +# Example: Critical alerts use all available channels +@register +class SecurityBreach(NotificationType): + key = "security_breach" + name = "Security Breach Alert" + default_channels = [EmailChannel, SMSChannel, PushChannel] + required_channels = [EmailChannel] # Email cannot be disabled + +# Example: Social notifications only on website by default +@register +class SocialNotification(NotificationType): + key = "social" + name = "Social Updates" + default_channels = [WebsiteChannel] # Only website, not email +``` + ## Required Channels Make certain channels mandatory for critical notifications: @@ -35,6 +119,32 @@ 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.channels import SMSChannel, WebsiteChannel + +@register +class InternalAuditLog(NotificationType): + key = "audit_log" + name = "Internal Audit Log" + description = "Internal system audit events" + forbidden_channels = [SMSChannel] # Never send audit logs via SMS + default_channels = [WebsiteChannel] # Only show in web interface + +@register +class PrivacySensitiveNotification(NotificationType): + key = "privacy_sensitive" + name = "Privacy Sensitive Alert" + description = "Contains sensitive personal information" + forbidden_channels = [WebsiteChannel] # Don't show in UI where others might see + required_channels = [EmailChannel] # Must go to private email +``` + +Forbidden channels take highest priority - they cannot be enabled even if specified in `default_channels`, `required_channels`, or user preferences. + ## Custom Frequencies Add custom email frequencies: diff --git a/docs/preferences.md b/docs/preferences.md index 546e5ff..495b521 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -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. 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). @@ -30,7 +33,7 @@ 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 @@ -38,6 +41,28 @@ 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 -CommentNotification.set_frequency(user=user, frequency=RealtimeFrequency) +# 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) + +# Set frequency preference directly in the database +NotificationFrequencyPreference.objects.update_or_create( + user=user, + notification_type=CommentNotification.key, + defaults={'frequency': RealtimeFrequency.key} +) ``` + +### How defaults work + +The system determines which channels are enabled using this priority order: + +1. **Forbidden channels** - Always disabled (defined in `NotificationType.forbidden_channels`) +2. **Required channels** - Always enabled (defined in `NotificationType.required_channels`) +3. **User preferences** - Explicit user choices stored in `NotificationTypeChannelPreference` +4. **NotificationType defaults** - Per-type defaults (defined in `NotificationType.default_channels`) +5. **Channel defaults** - Global defaults (defined in `BaseChannel.enabled_by_default`) + +This allows for flexible configuration where notification types can have different default behaviors while still allowing user customization. diff --git a/example/notifications/admin.py b/example/notifications/admin.py index ac28304..85fd1a1 100644 --- a/example/notifications/admin.py +++ b/example/notifications/admin.py @@ -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) @@ -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"] diff --git a/example/uv.lock b/example/uv.lock index f28613c..5569983 100644 --- a/example/uv.lock +++ b/example/uv.lock @@ -65,7 +65,7 @@ wheels = [ [[package]] name = "django-generic-notifications" -version = "2.1.0" +version = "2.3.0" source = { editable = "../" } dependencies = [ { name = "django" }, diff --git a/generic_notifications/channels.py b/generic_notifications/channels.py index 91382d2..6ffc845 100644 --- a/generic_notifications/channels.py +++ b/generic_notifications/channels.py @@ -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: diff --git a/generic_notifications/migrations/0006_alter_notificationfrequency_unique_together_and_more.py b/generic_notifications/migrations/0006_alter_notificationfrequency_unique_together_and_more.py new file mode 100644 index 0000000..25ee939 --- /dev/null +++ b/generic_notifications/migrations/0006_alter_notificationfrequency_unique_together_and_more.py @@ -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), + ), + ] diff --git a/generic_notifications/models.py b/generic_notifications/models.py index 3a733ad..e1f2407 100644 --- a/generic_notifications/models.py +++ b/generic_notifications/models.py @@ -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"] @@ -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) @@ -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: diff --git a/generic_notifications/preferences.py b/generic_notifications/preferences.py index abec04a..fb73cff 100644 --- a/generic_notifications/preferences.py +++ b/generic_notifications/preferences.py @@ -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 @@ -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 @@ -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, } @@ -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()} @@ -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" diff --git a/generic_notifications/types.py b/generic_notifications/types.py index 6ad9f00..ccb80f9 100644 --- a/generic_notifications/types.py +++ b/generic_notifications/types.py @@ -20,6 +20,7 @@ class NotificationType(ABC): default_frequency: Type[BaseFrequency] = DailyFrequency required_channels: list[Type[BaseChannel]] = [] forbidden_channels: list[Type[BaseChannel]] = [] + default_channels: list[Type[BaseChannel]] | None = None def __str__(self) -> str: return self.name @@ -60,9 +61,9 @@ def set_frequency(cls, user: Any, frequency: Type[BaseFrequency]) -> None: user: The user to set the frequency for frequency: BaseFrequency class """ - from .models import NotificationFrequency + from .models import NotificationFrequencyPreference - NotificationFrequency.objects.update_or_create( + NotificationFrequencyPreference.objects.update_or_create( user=user, notification_type=cls.key, defaults={"frequency": frequency.key} ) @@ -77,12 +78,12 @@ def get_frequency(cls, user: Any) -> Type[BaseFrequency]: Returns: BaseFrequency class (either user preference or default) """ - from .models import NotificationFrequency + from .models import NotificationFrequencyPreference try: - user_frequency = NotificationFrequency.objects.get(user=user, notification_type=cls.key) + user_frequency = NotificationFrequencyPreference.objects.get(user=user, notification_type=cls.key) return registry.get_frequency(user_frequency.frequency) - except NotificationFrequency.DoesNotExist: + except NotificationFrequencyPreference.DoesNotExist: return cls.default_frequency @classmethod @@ -93,9 +94,9 @@ def reset_frequency_to_default(cls, user: Any) -> None: Args: user: The user to reset the frequency for """ - from .models import NotificationFrequency + from .models import NotificationFrequencyPreference - NotificationFrequency.objects.filter(user=user, notification_type=cls.key).delete() + NotificationFrequencyPreference.objects.filter(user=user, notification_type=cls.key).delete() @classmethod def get_enabled_channels(cls, user: Any) -> list[Type[BaseChannel]]: @@ -109,44 +110,46 @@ def get_enabled_channels(cls, user: Any) -> list[Type[BaseChannel]]: Returns: List of enabled BaseChannel classes """ - from .models import DisabledNotificationTypeChannel + from .models import NotificationTypeChannelPreference - # Get all disabled channel keys for this user/notification type in one query - disabled_channel_keys = set( - DisabledNotificationTypeChannel.objects.filter(user=user, notification_type=cls.key).values_list( - "channel", flat=True - ) - ) + # Get all user preferences for this notification type in one query + preferences = { + pref.channel: pref.enabled + for pref in NotificationTypeChannelPreference.objects.filter(user=user, notification_type=cls.key) + } # Get all forbidden channel keys forbidden_channel_keys = {channel_cls.key for channel_cls in cls.forbidden_channels} - # Filter out disabled and forbidden channels enabled_channels = [] for channel_cls in registry.get_all_channels(): - if channel_cls.key not in disabled_channel_keys and channel_cls.key not in forbidden_channel_keys: + # Skip forbidden channels always + if channel_cls.key in forbidden_channel_keys: + continue + + # Required channels are always enabled + if channel_cls in cls.required_channels: enabled_channels.append(channel_cls) + continue + + # Check user preference first + if channel_cls.key in preferences: + if preferences[channel_cls.key]: + enabled_channels.append(channel_cls) + continue + + # No user preference - use defaults + if cls.default_channels is not None: + # Use explicitly configured default channels + if channel_cls in cls.default_channels: + enabled_channels.append(channel_cls) + else: + # Use global channel defaults + if channel_cls.enabled_by_default: + enabled_channels.append(channel_cls) return enabled_channels - @classmethod - def is_channel_enabled(cls, user: Any, channel: Type[BaseChannel]) -> bool: - """ - Check if a channel is enabled for this notification type for a user. - - Args: - user: User instance - channel: BaseChannel class - - Returns: - True if channel is enabled, False if disabled - """ - from .models import DisabledNotificationTypeChannel - - return not DisabledNotificationTypeChannel.objects.filter( - user=user, notification_type=cls.key, channel=channel.key - ).exists() - @classmethod def disable_channel(cls, user: Any, channel: Type[BaseChannel]) -> None: """ @@ -156,9 +159,11 @@ def disable_channel(cls, user: Any, channel: Type[BaseChannel]) -> None: user: User instance channel: BaseChannel class """ - from .models import DisabledNotificationTypeChannel + from .models import NotificationTypeChannelPreference - DisabledNotificationTypeChannel.objects.get_or_create(user=user, notification_type=cls.key, channel=channel.key) + NotificationTypeChannelPreference.objects.update_or_create( + user=user, notification_type=cls.key, channel=channel.key, defaults={"enabled": False} + ) @classmethod def enable_channel(cls, user: Any, channel: Type[BaseChannel]) -> None: @@ -169,11 +174,11 @@ def enable_channel(cls, user: Any, channel: Type[BaseChannel]) -> None: user: User instance channel: BaseChannel class """ - from .models import DisabledNotificationTypeChannel + from .models import NotificationTypeChannelPreference - DisabledNotificationTypeChannel.objects.filter( - user=user, notification_type=cls.key, channel=channel.key - ).delete() + NotificationTypeChannelPreference.objects.update_or_create( + user=user, notification_type=cls.key, channel=channel.key, defaults={"enabled": True} + ) def register(cls: Type[NotificationType]) -> Type[NotificationType]: diff --git a/tests/test_channel_defaults.py b/tests/test_channel_defaults.py new file mode 100644 index 0000000..84b689f --- /dev/null +++ b/tests/test_channel_defaults.py @@ -0,0 +1,138 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from generic_notifications.channels import BaseChannel, EmailChannel, WebsiteChannel +from generic_notifications.channels import register as register_channel +from generic_notifications.models import NotificationTypeChannelPreference +from generic_notifications.registry import registry +from generic_notifications.types import NotificationType + +User = get_user_model() + + +class TestChannelDefaults(TestCase): + """Test the new default channel behavior""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = User.objects.create_user(username="defaults_test", email="defaults@example.com", password="testpass") + + def setUp(self): + NotificationTypeChannelPreference.objects.filter(user=self.user).delete() + + def test_global_defaults_vs_explicit_defaults(self): + """Test both global defaults (None) and explicit default_channels""" + + class GlobalDefaultType(NotificationType): + key = "global_default" + name = "Global Default" + default_channels = None # Use global defaults + + class ExplicitDefaultType(NotificationType): + key = "explicit_default" + name = "Explicit Default" + default_channels = [WebsiteChannel] # Only website + + registry.register_type(GlobalDefaultType) + registry.register_type(ExplicitDefaultType) + + # Global defaults should include all enabled_by_default=True channels + global_channels = GlobalDefaultType.get_enabled_channels(self.user) + global_keys = [ch.key for ch in global_channels] + self.assertIn(WebsiteChannel.key, global_keys) + self.assertIn(EmailChannel.key, global_keys) + + # Explicit defaults should only include specified channels + explicit_channels = ExplicitDefaultType.get_enabled_channels(self.user) + explicit_keys = [ch.key for ch in explicit_channels] + self.assertIn(WebsiteChannel.key, explicit_keys) + self.assertNotIn(EmailChannel.key, explicit_keys) + + def test_required_and_forbidden_channel_interactions(self): + """Test how required/forbidden channels interact with defaults""" + + class ComplexType(NotificationType): + key = "complex_type" + name = "Complex Type" + default_channels = [WebsiteChannel] + required_channels = [EmailChannel] # Always included + forbidden_channels = [WebsiteChannel] # Never included + + registry.register_type(ComplexType) + + enabled_channels = ComplexType.get_enabled_channels(self.user) + enabled_keys = [ch.key for ch in enabled_channels] + + # Required should be included, forbidden should be excluded + self.assertIn(EmailChannel.key, enabled_keys) + self.assertNotIn(WebsiteChannel.key, enabled_keys) + + def test_user_overrides_and_empty_defaults(self): + """Test user can disable defaults and empty default_channels works""" + + class EmptyDefaultType(NotificationType): + key = "empty_default" + name = "Empty Default" + default_channels = [] # No defaults + required_channels = [EmailChannel] # But required email + + class DisableableType(NotificationType): + key = "disableable" + name = "Disableable" + default_channels = [WebsiteChannel, EmailChannel] + + registry.register_type(EmptyDefaultType) + registry.register_type(DisableableType) + + # Empty defaults should only have required channels + empty_channels = EmptyDefaultType.get_enabled_channels(self.user) + empty_keys = [ch.key for ch in empty_channels] + self.assertEqual(len(empty_channels), 1) + self.assertIn(EmailChannel.key, empty_keys) + + # User should be able to disable default channels + NotificationTypeChannelPreference.objects.create( + user=self.user, notification_type=DisableableType.key, channel=EmailChannel.key, enabled=False + ) + + disableable_channels = DisableableType.get_enabled_channels(self.user) + disableable_keys = [ch.key for ch in disableable_channels] + self.assertIn(WebsiteChannel.key, disableable_keys) + self.assertNotIn(EmailChannel.key, disableable_keys) + + def test_custom_channel_enabled_by_default_false(self): + """Test custom channels with enabled_by_default=False""" + + @register_channel + class CustomChannel(BaseChannel): + key = "custom_disabled" + name = "Custom Disabled" + enabled_by_default = False + + def send_now(self, notification): + pass + + class GlobalDefaultType(NotificationType): + key = "uses_global" + name = "Uses Global" + default_channels = None + + class ExplicitCustomType(NotificationType): + key = "uses_custom" + name = "Uses Custom" + default_channels = [CustomChannel] + + registry.register_type(GlobalDefaultType) + registry.register_type(ExplicitCustomType) + + # Global defaults shouldn't include disabled-by-default channels + global_channels = GlobalDefaultType.get_enabled_channels(self.user) + global_keys = [ch.key for ch in global_channels] + self.assertNotIn(CustomChannel.key, global_keys) + + # Explicit defaults can include disabled-by-default channels + custom_channels = ExplicitCustomType.get_enabled_channels(self.user) + custom_keys = [ch.key for ch in custom_channels] + self.assertIn(CustomChannel.key, custom_keys) + self.assertNotIn(WebsiteChannel.key, custom_keys) # Not in explicit list diff --git a/tests/test_channels.py b/tests/test_channels.py index c4a4862..482607d 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -6,9 +6,13 @@ from django.template import TemplateDoesNotExist from django.test import TestCase, override_settings -from generic_notifications.channels import BaseChannel, EmailChannel +from generic_notifications.channels import BaseChannel, EmailChannel, WebsiteChannel from generic_notifications.frequencies import DailyFrequency, RealtimeFrequency -from generic_notifications.models import DisabledNotificationTypeChannel, Notification, NotificationFrequency +from generic_notifications.models import ( + Notification, + NotificationFrequencyPreference, + NotificationTypeChannelPreference, +) from generic_notifications.registry import registry from generic_notifications.types import NotificationType @@ -45,45 +49,22 @@ def process(self, notification): self.assertEqual(channel.key, "test") self.assertEqual(channel.name, "Test") - def test_is_enabled_default_true(self): - class TestChannel(BaseChannel): - key = "test" - name = "Test" - - def process(self, notification): - pass - - # By default, all notifications are enabled - self.assertTrue(TestNotificationType.is_channel_enabled(self.user, TestChannel)) - - def test_is_enabled_with_disabled_notification(self): - class TestChannel(BaseChannel): - key = "test" - name = "Test" - - def process(self, notification): - pass - - class DisabledNotificationType(NotificationType): - key = "disabled_type" - name = "Disabled Type" - - class OtherNotificationType(NotificationType): - key = "other_type" - name = "Other Type" - - # Disable notification channel for this user - DisabledNotificationTypeChannel.objects.create( + def test_channel_preferences_work_correctly(self): + """Test that channel preferences correctly enable/disable channels per notification type.""" + # Disable website channel for test_type notifications + NotificationTypeChannelPreference.objects.create( user=self.user, - notification_type="disabled_type", - channel="test", + notification_type="test_type", + channel="website", + enabled=False, ) - # Should be disabled for this type - self.assertFalse(DisabledNotificationType.is_channel_enabled(self.user, TestChannel)) + # Should be disabled for test_type + enabled_channels = TestNotificationType.get_enabled_channels(self.user) + self.assertNotIn(WebsiteChannel, enabled_channels) - # But enabled for other types - self.assertTrue(OtherNotificationType.is_channel_enabled(self.user, TestChannel)) + # But should still include email channel + self.assertIn(EmailChannel, enabled_channels) def test_digest_only_channel_never_sends_immediately(self): """Test that channels with supports_realtime=False never send immediately.""" @@ -150,7 +131,7 @@ def test_process_realtime_frequency(self): def test_process_digest_frequency(self): # Set user preference to daily (non-realtime) - NotificationFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + NotificationFrequencyPreference.objects.create(user=self.user, notification_type="test_type", frequency="daily") notification = create_notification_with_channels( user=self.user, @@ -338,7 +319,7 @@ def test_send_now_template_error_fallback(self): @override_settings(DEFAULT_FROM_EMAIL="test@example.com") def test_send_digest_emails_basic(self): # Set user to daily frequency to prevent realtime sending - NotificationFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + NotificationFrequencyPreference.objects.create(user=self.user, notification_type="test_type", frequency="daily") # Create test notifications without email_sent_at (unsent) for i in range(3): @@ -372,7 +353,7 @@ def test_send_digest_emails_basic(self): @override_settings(DEFAULT_FROM_EMAIL="test@example.com") def test_send_digest_emails_with_frequency(self): # Set user to daily frequency to prevent realtime sending - NotificationFrequency.objects.create(user=self.user, notification_type="test_type", frequency="daily") + NotificationFrequencyPreference.objects.create(user=self.user, notification_type="test_type", frequency="daily") create_notification_with_channels( user=self.user, diff --git a/tests/test_management_commands.py b/tests/test_management_commands.py index 5eb61ed..9ba783d 100644 --- a/tests/test_management_commands.py +++ b/tests/test_management_commands.py @@ -8,7 +8,7 @@ from generic_notifications.channels import EmailChannel from generic_notifications.frequencies import BaseFrequency, DailyFrequency, RealtimeFrequency -from generic_notifications.models import Notification, NotificationFrequency +from generic_notifications.models import Notification, NotificationFrequencyPreference from generic_notifications.registry import registry from generic_notifications.types import NotificationType @@ -79,7 +79,9 @@ def test_target_frequency_is_realtime(self): def test_send_digest_emails_basic_flow(self): # Set up user with daily frequency preference - NotificationFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + NotificationFrequencyPreference.objects.create( + user=self.user1, notification_type="test_type", frequency="daily" + ) # Create a notification notification = create_notification_with_channels( @@ -109,7 +111,9 @@ def test_send_digest_emails_basic_flow(self): self.assertTrue(notification.is_sent_on_channel(EmailChannel)) def test_dry_run_does_not_send_emails(self): - NotificationFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + NotificationFrequencyPreference.objects.create( + user=self.user1, notification_type="test_type", frequency="daily" + ) notification = create_notification_with_channels( user=self.user1, @@ -131,7 +135,9 @@ def test_dry_run_does_not_send_emails(self): self.assertFalse(notification.is_sent_on_channel(EmailChannel)) def test_only_includes_unread_notifications(self): - NotificationFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + NotificationFrequencyPreference.objects.create( + user=self.user1, notification_type="test_type", frequency="daily" + ) # Create read and unread notifications read_notification = create_notification_with_channels( @@ -164,7 +170,9 @@ def test_only_includes_unread_notifications(self): self.assertTrue(unread_notification.is_sent_on_channel(EmailChannel)) # Now sent def test_only_includes_unsent_notifications(self): - NotificationFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + NotificationFrequencyPreference.objects.create( + user=self.user1, notification_type="test_type", frequency="daily" + ) # Create sent and unsent notifications sent_notification = create_notification_with_channels( @@ -195,7 +203,9 @@ def test_only_includes_unsent_notifications(self): self.assertTrue(unsent_notification.is_sent_on_channel(EmailChannel)) def test_sends_all_unsent_notifications(self): - NotificationFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + NotificationFrequencyPreference.objects.create( + user=self.user1, notification_type="test_type", frequency="daily" + ) # Create notification older than time window (>1 day ago) old_notification = create_notification_with_channels( @@ -235,8 +245,12 @@ def test_sends_all_unsent_notifications(self): def test_specific_frequency_filter(self): # Set up users with different frequency preferences - NotificationFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") - NotificationFrequency.objects.create(user=self.user2, notification_type="test_type", frequency="weekly") + NotificationFrequencyPreference.objects.create( + user=self.user1, notification_type="test_type", frequency="daily" + ) + NotificationFrequencyPreference.objects.create( + user=self.user2, notification_type="test_type", frequency="weekly" + ) # Create notifications for both create_notification_with_channels( @@ -272,8 +286,12 @@ def test_specific_frequency_filter(self): def test_multiple_notification_types_for_user(self): # Set up user with multiple notification types for daily frequency - NotificationFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") - NotificationFrequency.objects.create(user=self.user1, notification_type="other_type", frequency="daily") + NotificationFrequencyPreference.objects.create( + user=self.user1, notification_type="test_type", frequency="daily" + ) + NotificationFrequencyPreference.objects.create( + user=self.user1, notification_type="other_type", frequency="daily" + ) # Create notifications of both types notification1 = create_notification_with_channels( @@ -308,7 +326,9 @@ def test_multiple_notification_types_for_user(self): self.assertTrue(notification2.is_sent_on_channel(EmailChannel)) def test_no_notifications_to_send(self): - NotificationFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="daily") + NotificationFrequencyPreference.objects.create( + user=self.user1, notification_type="test_type", frequency="daily" + ) # No notifications created @@ -334,7 +354,7 @@ def test_users_with_disabled_email_channel_dont_get_digest(self): def test_users_with_default_frequencies_get_digest(self): """Test that users without explicit preferences get digest emails based on default frequencies.""" - # Don't create any NotificationFrequency preferences - user will use defaults + # Don't create any NotificationFrequencyPreference preferences - user will use defaults # Create a test_type notification (defaults to daily) test_notification = create_notification_with_channels( @@ -370,7 +390,9 @@ def test_users_with_default_frequencies_get_digest(self): def test_mixed_explicit_and_default_preferences(self): """Test that users with some explicit preferences and some defaults work correctly.""" # User explicitly sets test_type to weekly - NotificationFrequency.objects.create(user=self.user1, notification_type="test_type", frequency="weekly") + NotificationFrequencyPreference.objects.create( + user=self.user1, notification_type="test_type", frequency="weekly" + ) # other_type will use its default (realtime) # Create notifications @@ -403,8 +425,12 @@ def test_multiple_users_default_and_explicit_mix(self): # user2: Explicit preference (test_type=weekly, other_type uses default=realtime) # user3: Mixed (test_type=daily explicit, other_type uses default=realtime) - NotificationFrequency.objects.create(user=self.user2, notification_type="test_type", frequency="weekly") - NotificationFrequency.objects.create(user=self.user3, notification_type="test_type", frequency="daily") + NotificationFrequencyPreference.objects.create( + user=self.user2, notification_type="test_type", frequency="weekly" + ) + NotificationFrequencyPreference.objects.create( + user=self.user3, notification_type="test_type", frequency="daily" + ) # Create test notifications for all users for i, user in enumerate([self.user1, self.user2, self.user3], 1): diff --git a/tests/test_models.py b/tests/test_models.py index 121bbc7..5f63698 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,7 +9,11 @@ from generic_notifications.channels import EmailChannel, WebsiteChannel from generic_notifications.frequencies import DailyFrequency -from generic_notifications.models import DisabledNotificationTypeChannel, Notification, NotificationFrequency +from generic_notifications.models import ( + Notification, + NotificationFrequencyPreference, + NotificationTypeChannelPreference, +) from generic_notifications.registry import registry from generic_notifications.types import NotificationType, SystemMessage @@ -38,7 +42,7 @@ class TestChannel: name = "Website" -class DisabledNotificationTypeChannelModelTest(TestCase): +class NotificationTypeChannelPreferenceModelTest(TestCase): user: Any # User model instance created in setUpClass @classmethod @@ -49,77 +53,45 @@ def setUpClass(cls): # Register test notification types and import channels for validation registry.register_type(TestNotificationType) - def test_create_disabled_notification(self): - disabled = DisabledNotificationTypeChannel.objects.create( - user=self.user, - notification_type=TestNotificationType.key, - channel=WebsiteChannel.key, - ) - - self.assertEqual(disabled.user, self.user) - self.assertEqual(disabled.notification_type, TestNotificationType.key) - self.assertEqual(disabled.channel, WebsiteChannel.key) - - def test_clean_with_invalid_notification_type(self): - disabled = DisabledNotificationTypeChannel( - user=self.user, - notification_type="invalid_type", - channel=WebsiteChannel.key, - ) - - with self.assertRaises(ValidationError) as cm: - disabled.clean() - - self.assertIn("Unknown notification type: invalid_type", str(cm.exception)) - - def test_clean_with_invalid_channel(self): - disabled = DisabledNotificationTypeChannel( - user=self.user, - notification_type=TestNotificationType.key, - channel="invalid_channel", - ) - - with self.assertRaises(ValidationError) as cm: - disabled.clean() - - self.assertIn("Unknown channel: invalid_channel", str(cm.exception)) - - def test_clean_with_valid_data(self): - disabled = DisabledNotificationTypeChannel( - user=self.user, - notification_type=TestNotificationType.key, - channel=WebsiteChannel.key, - ) - - # Should not raise any exception - disabled.clean() - def test_clean_prevents_disabling_required_channel(self): """Test that users cannot disable required channels for notification types""" - disabled = DisabledNotificationTypeChannel( + preference = NotificationTypeChannelPreference( user=self.user, notification_type=SystemMessage.key, channel=EmailChannel.key, + enabled=False, # Trying to disable ) with self.assertRaises(ValidationError) as cm: - disabled.clean() + preference.clean() self.assertIn("Cannot disable email channel for System Message - this channel is required", str(cm.exception)) - def test_clean_allows_disabling_non_required_channel(self): - """Test that users can disable non-required channels for notification types with required channels""" - disabled = DisabledNotificationTypeChannel( + def test_clean_prevents_enabling_forbidden_channel(self): + """Test that users cannot enable forbidden channels for notification types""" + + # We need a notification type with forbidden channels for this test + class ForbiddenTestType(NotificationType): + key = "forbidden_test" + name = "Forbidden Test" + forbidden_channels = [WebsiteChannel] + + registry.register_type(ForbiddenTestType) + + preference = NotificationTypeChannelPreference( user=self.user, - notification_type=SystemMessage.key, + notification_type=ForbiddenTestType.key, channel=WebsiteChannel.key, + enabled=True, # Trying to enable forbidden channel ) - # Should not raise any exception - website is not required for system_message - disabled.clean() + with self.assertRaises(ValidationError) as cm: + preference.clean() + + self.assertIn("Cannot enable website channel for Forbidden Test - this channel is forbidden", str(cm.exception)) -class NotificationFrequencyModelTest(TestCase): +class NotificationFrequencyPreferenceModelTest(TestCase): user: Any # User model instance created in setUpClass @classmethod @@ -132,65 +104,21 @@ def setUpClass(cls): # Re-register DailyFrequency in case it was cleared by other tests registry.register_frequency(DailyFrequency, force=True) - def test_create_email_frequency(self): - frequency = NotificationFrequency.objects.create( - user=self.user, - notification_type=TestNotificationType.key, - frequency=DailyFrequency.key, - ) - - self.assertEqual(frequency.user, self.user) - self.assertEqual(frequency.notification_type, TestNotificationType.key) - self.assertEqual(frequency.frequency, DailyFrequency.key) - def test_unique_together_constraint(self): - NotificationFrequency.objects.create( + """Test that users can only have one frequency preference per notification type""" + NotificationFrequencyPreference.objects.create( user=self.user, notification_type=TestNotificationType.key, frequency=DailyFrequency.key, ) with self.assertRaises(IntegrityError): - NotificationFrequency.objects.create( + NotificationFrequencyPreference.objects.create( user=self.user, notification_type=TestNotificationType.key, frequency=DailyFrequency.key, ) - def test_clean_with_invalid_notification_type(self): - frequency = NotificationFrequency( - user=self.user, - notification_type="invalid_type", - frequency=DailyFrequency.key, - ) - - with self.assertRaises(ValidationError) as cm: - frequency.clean() - - self.assertIn("Unknown notification type: invalid_type", str(cm.exception)) - - def test_clean_with_invalid_frequency(self): - frequency = NotificationFrequency( - user=self.user, - notification_type=TestNotificationType.key, - frequency="invalid_frequency", - ) - - with self.assertRaises(ValidationError) as cm: - frequency.clean() - - self.assertIn("Unknown frequency: invalid_frequency", str(cm.exception)) - - def test_clean_with_valid_data(self): - frequency = NotificationFrequency( - user=self.user, - notification_type=TestNotificationType.key, - frequency=DailyFrequency.key, - ) - - # Should not raise any exception - frequency.clean() - class NotificationModelTest(TestCase): user: Any # User model instance created in setUpClass diff --git a/tests/test_preferences.py b/tests/test_preferences.py index 8d97d51..1cdafbb 100644 --- a/tests/test_preferences.py +++ b/tests/test_preferences.py @@ -5,7 +5,7 @@ from generic_notifications.channels import WebsiteChannel from generic_notifications.frequencies import DailyFrequency, RealtimeFrequency -from generic_notifications.models import DisabledNotificationTypeChannel, NotificationFrequency +from generic_notifications.models import NotificationFrequencyPreference, NotificationTypeChannelPreference from generic_notifications.preferences import get_notification_preferences, save_notification_preferences from generic_notifications.registry import registry from generic_notifications.types import NotificationType @@ -55,7 +55,7 @@ def test_opt_out_model_all_channels_enabled_by_default(self): def test_disabled_channels_are_reflected_in_preferences(self): """Test that disabled channels are properly reflected.""" # Disable email for test notification - DisabledNotificationTypeChannel.objects.create( + NotificationTypeChannelPreference.objects.create( user=self.user, notification_type="test_notification", channel="email" ) @@ -78,7 +78,9 @@ def test_email_frequency_defaults_and_overrides(self): self.assertEqual(pref["notification_frequency"], "daily") # Now override one - NotificationFrequency.objects.create(user=self.user, notification_type="test_notification", frequency="daily") + NotificationFrequencyPreference.objects.create( + user=self.user, notification_type="test_notification", frequency="daily" + ) preferences = get_notification_preferences(self.user) @@ -116,12 +118,14 @@ def test_complete_form_save_workflow(self): 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") + disabled = NotificationTypeChannelPreference.objects.filter( + user=self.user, notification_type="test_notification", enabled=False + ) self.assertEqual(disabled.count(), 1) self.assertEqual(disabled.first().channel, "email") # Verify frequencies (only non-defaults saved) - frequencies = NotificationFrequency.objects.filter(user=self.user).order_by("notification_type") + frequencies = NotificationFrequencyPreference.objects.filter(user=self.user).order_by("notification_type") self.assertEqual(frequencies.count(), 2) test_freq = frequencies.filter(notification_type="test_notification").first() @@ -133,10 +137,12 @@ def test_complete_form_save_workflow(self): 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" + NotificationTypeChannelPreference.objects.create( + user=self.user, notification_type="test_notification", channel="website", enabled=False + ) + NotificationFrequencyPreference.objects.create( + user=self.user, notification_type="test_notification", frequency="daily" ) - NotificationFrequency.objects.create(user=self.user, notification_type="test_notification", frequency="daily") # Save completely different preferences form_data = { @@ -148,11 +154,13 @@ def test_preferences_cleared_before_saving_new_ones(self): 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") + disabled = NotificationTypeChannelPreference.objects.filter( + user=self.user, notification_type="test_notification" + ) self.assertEqual(disabled.count(), 0) # Old frequency should be gone - frequencies = NotificationFrequency.objects.filter(user=self.user) + frequencies = NotificationFrequencyPreference.objects.filter(user=self.user) self.assertEqual(frequencies.count(), 0) def test_required_channels_ignored_in_form_data(self): @@ -165,7 +173,7 @@ def test_required_channels_ignored_in_form_data(self): save_notification_preferences(self.user, form_data) # Website should NOT be in disabled channels because it's required - disabled = DisabledNotificationTypeChannel.objects.filter( + disabled = NotificationTypeChannelPreference.objects.filter( user=self.user, notification_type="required_channel_notification", channel="website" ) self.assertEqual(disabled.count(), 0) @@ -181,15 +189,15 @@ def test_user_preferences_are_isolated(self): 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" + user_disabled = NotificationTypeChannelPreference.objects.filter( + user=self.user, notification_type="test_notification", enabled=False ) 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" + other_disabled = NotificationTypeChannelPreference.objects.filter( + user=self.other_user, notification_type="test_notification", enabled=False ) self.assertEqual(other_disabled.count(), 1) self.assertEqual(other_disabled.first().channel, "email") @@ -206,7 +214,7 @@ def test_only_non_default_frequencies_are_saved(self): save_notification_preferences(self.user, form_data) # Only the non-default frequency should be saved - frequencies = NotificationFrequency.objects.filter(user=self.user) + frequencies = NotificationFrequencyPreference.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") diff --git a/tests/test_types.py b/tests/test_types.py index 685304d..34647d0 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -5,7 +5,7 @@ from generic_notifications.channels import EmailChannel, WebsiteChannel from generic_notifications.frequencies import DailyFrequency, RealtimeFrequency -from generic_notifications.models import DisabledNotificationTypeChannel, NotificationFrequency +from generic_notifications.models import NotificationFrequencyPreference, NotificationTypeChannelPreference from generic_notifications.registry import registry from generic_notifications.types import NotificationType @@ -39,25 +39,27 @@ def setUpClass(cls): def test_disable_channel(self): """Test the disable_channel class method""" # Verify channel is enabled initially - self.assertTrue(TestNotificationType.is_channel_enabled(self.user, WebsiteChannel)) + enabled_channels = TestNotificationType.get_enabled_channels(self.user) + self.assertIn(WebsiteChannel, enabled_channels) # Disable the channel TestNotificationType.disable_channel(self.user, WebsiteChannel) - # Verify it was created + # Verify preference was created self.assertTrue( - DisabledNotificationTypeChannel.objects.filter( - user=self.user, notification_type=TestNotificationType.key, channel=WebsiteChannel.key + NotificationTypeChannelPreference.objects.filter( + user=self.user, notification_type=TestNotificationType.key, channel=WebsiteChannel.key, enabled=False ).exists() ) # Verify channel is now disabled - self.assertFalse(TestNotificationType.is_channel_enabled(self.user, WebsiteChannel)) + enabled_channels = TestNotificationType.get_enabled_channels(self.user) + self.assertNotIn(WebsiteChannel, enabled_channels) - # Disabling again should not create duplicate (get_or_create behavior) + # Disabling again should update existing preference TestNotificationType.disable_channel(self.user, WebsiteChannel) self.assertEqual( - DisabledNotificationTypeChannel.objects.filter( + NotificationTypeChannelPreference.objects.filter( user=self.user, notification_type=TestNotificationType.key, channel=WebsiteChannel.key ).count(), 1, @@ -66,85 +68,35 @@ def test_disable_channel(self): def test_enable_channel(self): """Test the enable_channel class method""" # First disable the channel - DisabledNotificationTypeChannel.objects.create( - user=self.user, notification_type=TestNotificationType.key, channel=WebsiteChannel.key - ) - self.assertFalse(TestNotificationType.is_channel_enabled(self.user, WebsiteChannel)) + TestNotificationType.disable_channel(self.user, WebsiteChannel) + enabled_channels = TestNotificationType.get_enabled_channels(self.user) + self.assertNotIn(WebsiteChannel, enabled_channels) # Enable the channel TestNotificationType.enable_channel(self.user, WebsiteChannel) - # Verify the disabled entry was removed - self.assertFalse( - DisabledNotificationTypeChannel.objects.filter( - user=self.user, notification_type=TestNotificationType.key, channel=WebsiteChannel.key + # Verify preference was updated + self.assertTrue( + NotificationTypeChannelPreference.objects.filter( + user=self.user, notification_type=TestNotificationType.key, channel=WebsiteChannel.key, enabled=True ).exists() ) # Verify channel is now enabled - self.assertTrue(TestNotificationType.is_channel_enabled(self.user, WebsiteChannel)) + enabled_channels = TestNotificationType.get_enabled_channels(self.user) + self.assertIn(WebsiteChannel, enabled_channels) # Enabling an already enabled channel should work without error TestNotificationType.enable_channel(self.user, WebsiteChannel) - self.assertTrue(TestNotificationType.is_channel_enabled(self.user, WebsiteChannel)) - - def test_is_channel_enabled(self): - """Test the is_channel_enabled class method""" - # By default, all channels should be enabled - self.assertTrue(TestNotificationType.is_channel_enabled(self.user, WebsiteChannel)) - self.assertTrue(TestNotificationType.is_channel_enabled(self.user, EmailChannel)) - - # Disable website channel - DisabledNotificationTypeChannel.objects.create( - user=self.user, notification_type=TestNotificationType.key, channel=WebsiteChannel.key - ) - - # Website should be disabled, email should still be enabled - self.assertFalse(TestNotificationType.is_channel_enabled(self.user, WebsiteChannel)) - self.assertTrue(TestNotificationType.is_channel_enabled(self.user, EmailChannel)) - - # Different user should not be affected - other_user = User.objects.create_user(username="other", email="other@example.com", password="pass") - self.assertTrue(TestNotificationType.is_channel_enabled(other_user, WebsiteChannel)) - - def test_get_enabled_channels(self): - """Test the get_enabled_channels optimization method""" - # By default, all channels should be enabled enabled_channels = TestNotificationType.get_enabled_channels(self.user) - enabled_channel_keys = [ch.key for ch in enabled_channels] - - self.assertIn(WebsiteChannel.key, enabled_channel_keys) - self.assertIn(EmailChannel.key, enabled_channel_keys) - self.assertEqual(len(enabled_channels), 2) - - # Disable website channel - DisabledNotificationTypeChannel.objects.create( - user=self.user, notification_type=TestNotificationType.key, channel=WebsiteChannel.key - ) - - # Should now only return email channel - enabled_channels = TestNotificationType.get_enabled_channels(self.user) - enabled_channel_keys = [ch.key for ch in enabled_channels] - - self.assertNotIn(WebsiteChannel.key, enabled_channel_keys) - self.assertIn(EmailChannel.key, enabled_channel_keys) - self.assertEqual(len(enabled_channels), 1) - - # Different user should not be affected - other_user = User.objects.create_user(username="other2", email="other2@example.com", password="pass") - other_enabled_channels = TestNotificationType.get_enabled_channels(other_user) - other_enabled_channel_keys = [ch.key for ch in other_enabled_channels] - - self.assertIn(WebsiteChannel.key, other_enabled_channel_keys) - self.assertIn(EmailChannel.key, other_enabled_channel_keys) - self.assertEqual(len(other_enabled_channels), 2) + self.assertIn(WebsiteChannel, enabled_channels) def test_set_frequency(self): # Set frequency for the first time TestNotificationType.set_frequency(self.user, DailyFrequency) # Verify it was created - freq = NotificationFrequency.objects.get(user=self.user, notification_type=TestNotificationType.key) + freq = NotificationFrequencyPreference.objects.get(user=self.user, notification_type=TestNotificationType.key) self.assertEqual(freq.frequency, DailyFrequency.key) # Update to a different frequency @@ -157,12 +109,15 @@ def test_set_frequency(self): # Verify there's still only one record self.assertEqual( - NotificationFrequency.objects.filter(user=self.user, notification_type=TestNotificationType.key).count(), 1 + NotificationFrequencyPreference.objects.filter( + user=self.user, notification_type=TestNotificationType.key + ).count(), + 1, ) def test_get_frequency_with_user_preference(self): # Set user preference - NotificationFrequency.objects.create( + NotificationFrequencyPreference.objects.create( user=self.user, notification_type=TestNotificationType.key, frequency=DailyFrequency.key ) @@ -195,11 +150,13 @@ class RealtimeNotificationType(NotificationType): def test_reset_to_default(self): # First set a custom preference - NotificationFrequency.objects.create( + NotificationFrequencyPreference.objects.create( user=self.user, notification_type=TestNotificationType.key, frequency=DailyFrequency.key ) self.assertTrue( - NotificationFrequency.objects.filter(user=self.user, notification_type=TestNotificationType.key).exists() + NotificationFrequencyPreference.objects.filter( + user=self.user, notification_type=TestNotificationType.key + ).exists() ) # Reset to default @@ -207,7 +164,9 @@ def test_reset_to_default(self): # Verify the custom preference was removed self.assertFalse( - NotificationFrequency.objects.filter(user=self.user, notification_type=TestNotificationType.key).exists() + NotificationFrequencyPreference.objects.filter( + user=self.user, notification_type=TestNotificationType.key + ).exists() ) # Getting frequency should now return the default @@ -256,8 +215,8 @@ def test_forbidden_channels_filtered_even_when_explicitly_enabled(self): def test_forbidden_channels_filtered_when_not_disabled(self): """Test that forbidden channels are filtered out regardless of disabled state""" - # Ensure no disabled entry exists for the forbidden channel - DisabledNotificationTypeChannel.objects.filter( + # Ensure no preference exists for the forbidden channel + NotificationTypeChannelPreference.objects.filter( user=self.user, notification_type=ForbiddenChannelsNotificationType.key, channel=WebsiteChannel.key ).delete() From 6a1f549c1586e994ab0ede54f425f783f7c9c085 Mon Sep 17 00:00:00 2001 From: Kevin Renskers Date: Mon, 20 Oct 2025 22:30:21 +0200 Subject: [PATCH 2/6] Clean up docs --- docs/customizing.md | 138 +++++++++++++++----------------------------- docs/preferences.md | 24 ++------ 2 files changed, 50 insertions(+), 112 deletions(-) diff --git a/docs/customizing.md b/docs/customizing.md index 2900696..2a90d2a 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -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( @@ -20,131 +23,82 @@ class SMSChannel(BaseChannel): ) ``` -## Channel Defaults +## Required Channels + +Make certain channels mandatory for critical notifications: + +```python +from generic_notifications.types import NotificationType +from generic_notifications.channels import EmailChannel + +@register +class SecurityAlert(NotificationType): + key = "security_alert" + name = "Security Alerts" + description = "Important security notifications" + 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 -Control which channels are enabled by default for different notification types and 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 -Set whether a channel is enabled by default across all notification types: +Disable a channel by default across all notification types: ```python @register class SMSChannel(BaseChannel): key = "sms" name = "SMS" - enabled_by_default = False # Opt-in only - users must explicitly enable - supports_realtime = True - -@register -class PushChannel(BaseChannel): - key = "push" - name = "Push Notifications" - enabled_by_default = True # Opt-out - enabled unless user disables supports_realtime = True + supports_digest = False + enabled_by_default = False # Opt-in only - users must explicitly enable ``` -The default value for `enabled_by_default` is `True`, maintaining backward compatibility. - ### Per-NotificationType Defaults -Override channel defaults for specific notification types: +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, disable website notifications + # Only enable email by default + # (users can still opt-in to enable other channels) default_channels = [EmailChannel] - -@register -class UrgentAlert(NotificationType): - key = "urgent_alert" - name = "Urgent Alerts" - # Enable all channels including normally opt-in ones - default_channels = [EmailChannel, WebsiteChannel, SMSChannel, PushChannel] ``` -When `default_channels` is specified, it overrides the global `enabled_by_default` settings. If `default_channels` is `None` (the default), the system uses each channel's `enabled_by_default` setting. - ### 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 +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 -### Examples - -```python -# Example: Marketing emails are opt-in only -@register -class MarketingEmail(NotificationType): - key = "marketing" - name = "Marketing Emails" - default_channels = [] # No channels enabled by default - -# Example: Critical alerts use all available channels -@register -class SecurityBreach(NotificationType): - key = "security_breach" - name = "Security Breach Alert" - default_channels = [EmailChannel, SMSChannel, PushChannel] - required_channels = [EmailChannel] # Email cannot be disabled - -# Example: Social notifications only on website by default -@register -class SocialNotification(NotificationType): - key = "social" - name = "Social Updates" - default_channels = [WebsiteChannel] # Only website, not email -``` - -## Required Channels - -Make certain channels mandatory for critical notifications: - -```python -from generic_notifications.channels import EmailChannel - -@register -class SecurityAlert(NotificationType): - key = "security_alert" - name = "Security Alerts" - description = "Important security notifications" - required_channels = [EmailChannel] # Cannot be disabled -``` - -## Forbidden Channels - -Prevent certain channels from being used for specific notification types: - -```python -from generic_notifications.channels import SMSChannel, WebsiteChannel - -@register -class InternalAuditLog(NotificationType): - key = "audit_log" - name = "Internal Audit Log" - description = "Internal system audit events" - forbidden_channels = [SMSChannel] # Never send audit logs via SMS - default_channels = [WebsiteChannel] # Only show in web interface - -@register -class PrivacySensitiveNotification(NotificationType): - key = "privacy_sensitive" - name = "Privacy Sensitive Alert" - description = "Contains sensitive personal information" - forbidden_channels = [WebsiteChannel] # Don't show in UI where others might see - required_channels = [EmailChannel] # Must go to private email -``` - -Forbidden channels take highest priority - they cannot be enabled even if specified in `default_channels`, `required_channels`, or user preferences. - ## Custom Frequencies Add custom email frequencies: diff --git a/docs/preferences.md b/docs/preferences.md index 495b521..9d96bec 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -1,6 +1,6 @@ ## User Preferences -By default, users receive notifications based on the channel defaults configured for each notification type and channel. Users can then customize their preferences by explicitly enabling or disabling specific channels for each notification type. +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. The system supports both: @@ -47,22 +47,6 @@ CommentNotification.enable_channel(user=user, channel=EmailChannel) # Check which channels are enabled for a user enabled_channels = CommentNotification.get_enabled_channels(user) -# Set frequency preference directly in the database -NotificationFrequencyPreference.objects.update_or_create( - user=user, - notification_type=CommentNotification.key, - defaults={'frequency': RealtimeFrequency.key} -) -``` - -### How defaults work - -The system determines which channels are enabled using this priority order: - -1. **Forbidden channels** - Always disabled (defined in `NotificationType.forbidden_channels`) -2. **Required channels** - Always enabled (defined in `NotificationType.required_channels`) -3. **User preferences** - Explicit user choices stored in `NotificationTypeChannelPreference` -4. **NotificationType defaults** - Per-type defaults (defined in `NotificationType.default_channels`) -5. **Channel defaults** - Global defaults (defined in `BaseChannel.enabled_by_default`) - -This allows for flexible configuration where notification types can have different default behaviors while still allowing user customization. +# Change to realtime frequency for a notification type +CommentNotification.set_frequency(user=user, frequency=RealtimeFrequency) +``` \ No newline at end of file From d2e71698dbcda07a910bf8fa59ae9d3e387f186d Mon Sep 17 00:00:00 2001 From: Kevin Renskers Date: Mon, 20 Oct 2025 23:03:46 +0200 Subject: [PATCH 3/6] Improve tests --- tests/test_channel_defaults.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/test_channel_defaults.py b/tests/test_channel_defaults.py index 84b689f..db17da0 100644 --- a/tests/test_channel_defaults.py +++ b/tests/test_channel_defaults.py @@ -52,21 +52,34 @@ class ExplicitDefaultType(NotificationType): def test_required_and_forbidden_channel_interactions(self): """Test how required/forbidden channels interact with defaults""" + # First create a custom channel that's disabled by default + @register_channel + class CustomRequiredChannel(BaseChannel): + key = "custom_required" + name = "Custom Required" + enabled_by_default = False + + def send_now(self, notification): + pass + class ComplexType(NotificationType): key = "complex_type" name = "Complex Type" - default_channels = [WebsiteChannel] - required_channels = [EmailChannel] # Always included - forbidden_channels = [WebsiteChannel] # Never included + required_channels = [CustomRequiredChannel] # Force inclusion of normally-disabled channel + forbidden_channels = [WebsiteChannel] # Force exclusion of normally-enabled channel registry.register_type(ComplexType) enabled_channels = ComplexType.get_enabled_channels(self.user) enabled_keys = [ch.key for ch in enabled_channels] - # Required should be included, forbidden should be excluded - self.assertIn(EmailChannel.key, enabled_keys) + self.assertEqual(len(enabled_keys), 2) + # Required channel should be included even though disabled by default + self.assertIn(CustomRequiredChannel.key, enabled_keys) + # Forbidden channel should be excluded even though enabled by default self.assertNotIn(WebsiteChannel.key, enabled_keys) + # EmailChannel should still be included (default behavior) + self.assertIn(EmailChannel.key, enabled_keys) def test_user_overrides_and_empty_defaults(self): """Test user can disable defaults and empty default_channels works""" @@ -116,7 +129,6 @@ def send_now(self, notification): class GlobalDefaultType(NotificationType): key = "uses_global" name = "Uses Global" - default_channels = None class ExplicitCustomType(NotificationType): key = "uses_custom" From 51861bbbfda352f091f88e2cae751356cf58b5b2 Mon Sep 17 00:00:00 2001 From: Kevin Renskers Date: Mon, 20 Oct 2025 23:09:57 +0200 Subject: [PATCH 4/6] Clean up tests --- tests/test_channels.py | 192 +---------------------------------------- 1 file changed, 2 insertions(+), 190 deletions(-) diff --git a/tests/test_channels.py b/tests/test_channels.py index 482607d..02e27d3 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -6,13 +6,9 @@ from django.template import TemplateDoesNotExist from django.test import TestCase, override_settings -from generic_notifications.channels import BaseChannel, EmailChannel, WebsiteChannel +from generic_notifications.channels import BaseChannel, EmailChannel from generic_notifications.frequencies import DailyFrequency, RealtimeFrequency -from generic_notifications.models import ( - Notification, - NotificationFrequencyPreference, - NotificationTypeChannelPreference, -) +from generic_notifications.models import Notification, NotificationFrequencyPreference from generic_notifications.registry import registry from generic_notifications.types import NotificationType @@ -37,35 +33,6 @@ def setUpClass(cls): super().setUpClass() cls.user = User.objects.create_user(username="user1", email="test@example.com", password="testpass") - def test_notification_channel_is_abstract(self): - class TestChannel(BaseChannel): - key = "test" - name = "Test" - - def process(self, notification): - pass - - channel = TestChannel() - self.assertEqual(channel.key, "test") - self.assertEqual(channel.name, "Test") - - def test_channel_preferences_work_correctly(self): - """Test that channel preferences correctly enable/disable channels per notification type.""" - # Disable website channel for test_type notifications - NotificationTypeChannelPreference.objects.create( - user=self.user, - notification_type="test_type", - channel="website", - enabled=False, - ) - - # Should be disabled for test_type - enabled_channels = TestNotificationType.get_enabled_channels(self.user) - self.assertNotIn(WebsiteChannel, enabled_channels) - - # But should still include email channel - self.assertIn(EmailChannel, enabled_channels) - def test_digest_only_channel_never_sends_immediately(self): """Test that channels with supports_realtime=False never send immediately.""" @@ -85,21 +52,6 @@ def send_now(self, notification): channel.process(notification) -class WebsiteChannelTest(TestCase): - def setUp(self): - self.user = User.objects.create_user(username="user2", email="test@example.com", password="testpass") - registry.register_type(TestNotificationType) - - self.notification = Notification.objects.create( - recipient=self.user, - notification_type="test_type", - subject="Test Subject", - ) - - def tearDown(self): - pass - - class EmailChannelTest(TestCase): user: Any # User model instance created in setUp @@ -430,143 +382,3 @@ def test_send_digest_emails_fallback_includes_urls(self): - First notification https://example.com/url/1""" self.assertEqual(sent_email.body, expected_body) - - -class CustomEmailChannelTest(TestCase): - """Test that custom EmailChannel subclasses work correctly with digest functionality.""" - - user: Any - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.user = User.objects.create_user(username="user1", email="test@example.com", password="testpass") - - def setUp(self): - # Clear any existing emails - mail.outbox.clear() - - def test_custom_email_channel_send_email_override(self): - """Test that a custom EmailChannel subclass can override _send_email method.""" - - class TestEmailChannel(EmailChannel): - """Custom email channel that tracks calls to _send_email.""" - - key = "test_email" - name = "Test Email" - - def __init__(self): - super().__init__() - self.sent_emails = [] - - def send_email( - self, recipient: str, subject: str, text_message: str, html_message: str | None = None - ) -> None: - """Override to track calls instead of actually sending.""" - self.sent_emails.append( - { - "recipient": recipient, - "subject": subject, - "text_message": text_message, - "html_message": html_message, - } - ) - # Don't call super() - we don't want to actually send emails - - # Create notifications - notification1 = create_notification_with_channels( - user=self.user, - channels=["test_email"], - notification_type=TestNotificationType.key, - subject="Test Subject 1", - text="Test notification 1", - ) - - notification2 = create_notification_with_channels( - user=self.user, - channels=["test_email"], - notification_type=TestNotificationType.key, - subject="Test Subject 2", - text="Test notification 2", - ) - - notifications = Notification.objects.filter(id__in=[notification1.id, notification2.id]) - - # Test the custom channel - custom_channel = TestEmailChannel() - custom_channel.send_digest(notifications, DailyFrequency) - - # Verify the custom _send_email method was called - self.assertEqual(len(custom_channel.sent_emails), 1) - sent_email = custom_channel.sent_emails[0] - - # Check the email details - self.assertEqual(sent_email["recipient"], "test@example.com") - self.assertIn("2 new notifications", sent_email["subject"]) - self.assertIn("Test notification 1", sent_email["text_message"]) - self.assertIn("Test notification 2", sent_email["text_message"]) - - # Verify no actual emails were sent via Django's mail system - self.assertEqual(len(mail.outbox), 0) - - # Check that notifications were marked as sent - notification1.refresh_from_db() - notification2.refresh_from_db() - self.assertTrue(notification1.is_sent_on_channel(TestEmailChannel)) - self.assertTrue(notification2.is_sent_on_channel(TestEmailChannel)) - - def test_custom_email_channel_send_now_override(self): - """Test that a custom EmailChannel subclass works with send_now.""" - - class AsyncEmailChannel(EmailChannel): - """Custom email channel that queues emails instead of sending immediately.""" - - key = "async_email" - name = "Async Email" - - def __init__(self): - super().__init__() - self.queued_emails = [] - - def send_email( - self, recipient: str, subject: str, text_message: str, html_message: str | None = None - ) -> None: - """Queue email for later processing instead of sending immediately.""" - self.queued_emails.append( - { - "recipient": recipient, - "subject": subject, - "text_message": text_message, - "html_message": html_message, - "queued_at": "now", # In real implementation, would use timezone.now() - } - ) - - # Create notification - notification = create_notification_with_channels( - user=self.user, - channels=["async_email"], - notification_type=TestNotificationType.key, - subject="Realtime Test", - text="Realtime notification", - ) - - # Test the custom channel with send_now - custom_channel = AsyncEmailChannel() - custom_channel.send_now(notification) - - # Verify the email was queued instead of sent - self.assertEqual(len(custom_channel.queued_emails), 1) - queued_email = custom_channel.queued_emails[0] - - self.assertEqual(queued_email["recipient"], "test@example.com") - self.assertEqual(queued_email["subject"], "Realtime Test") - self.assertIn("Realtime notification", queued_email["text_message"]) - self.assertIsNotNone(queued_email["queued_at"]) - - # Verify no actual emails were sent - self.assertEqual(len(mail.outbox), 0) - - # Check that notification was marked as sent - notification.refresh_from_db() - self.assertTrue(notification.is_sent_on_channel(AsyncEmailChannel)) From abace3329a7ad7167a27b86859c846eef919b48c Mon Sep 17 00:00:00 2001 From: Kevin Renskers Date: Mon, 20 Oct 2025 23:12:27 +0200 Subject: [PATCH 5/6] Restore 2 tests --- tests/test_models.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_models.py b/tests/test_models.py index 5f63698..01b3c31 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -53,6 +53,32 @@ def setUpClass(cls): # Register test notification types and import channels for validation registry.register_type(TestNotificationType) + def test_clean_with_invalid_notification_type(self): + disabled = NotificationTypeChannelPreference( + user=self.user, + notification_type="invalid_type", + channel=WebsiteChannel.key, + enabled=False, + ) + + with self.assertRaises(ValidationError) as cm: + disabled.clean() + + self.assertIn("Unknown notification type: invalid_type", str(cm.exception)) + + def test_clean_with_invalid_channel(self): + disabled = NotificationTypeChannelPreference( + user=self.user, + notification_type=TestNotificationType.key, + channel="invalid_channel", + enabled=False, + ) + + with self.assertRaises(ValidationError) as cm: + disabled.clean() + + self.assertIn("Unknown channel: invalid_channel", str(cm.exception)) + def test_clean_prevents_disabling_required_channel(self): """Test that users cannot disable required channels for notification types""" preference = NotificationTypeChannelPreference( From aa91632641562e9ace8e9f949fa777756f9dd8f2 Mon Sep 17 00:00:00 2001 From: Kevin Renskers Date: Mon, 20 Oct 2025 23:16:41 +0200 Subject: [PATCH 6/6] Restore 2 tests --- tests/test_models.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_models.py b/tests/test_models.py index 01b3c31..acec635 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -145,6 +145,30 @@ def test_unique_together_constraint(self): frequency=DailyFrequency.key, ) + def test_clean_with_invalid_notification_type(self): + frequency = NotificationFrequencyPreference( + user=self.user, + notification_type="invalid_type", + frequency=DailyFrequency.key, + ) + + with self.assertRaises(ValidationError) as cm: + frequency.clean() + + self.assertIn("Unknown notification type: invalid_type", str(cm.exception)) + + def test_clean_with_invalid_frequency(self): + frequency = NotificationFrequencyPreference( + user=self.user, + notification_type=TestNotificationType.key, + frequency="invalid_frequency", + ) + + with self.assertRaises(ValidationError) as cm: + frequency.clean() + + self.assertIn("Unknown frequency: invalid_frequency", str(cm.exception)) + class NotificationModelTest(TestCase): user: Any # User model instance created in setUpClass