diff --git a/docs/customizing.md b/docs/customizing.md index 6821f15..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( @@ -25,6 +28,7 @@ class SMSChannel(BaseChannel): Make certain channels mandatory for critical notifications: ```python +from generic_notifications.types import NotificationType from generic_notifications.channels import EmailChannel @register @@ -35,6 +39,66 @@ class SecurityAlert(NotificationType): required_channels = [EmailChannel] # Cannot be disabled ``` +## Forbidden Channels + +Prevent certain channels from being used for specific notification types: + +```python +from generic_notifications.types import NotificationType +from generic_notifications.channels import SMSChannel + +@register +class CommentReceivedNotification(NotificationType): + key = "comment_received" + name = "Comment received" + description = "You received a comment" + forbidden_channels = [SMSChannel] # Never send via SMS +``` + +Forbidden channels take highest priority - they cannot be enabled even if specified in `default_channels`, `required_channels`, or user preferences. + +## Defaults Channels + +By default all channels are enabled for all users, and for all notifications types. Control which channels are enabled by default. + +### Per-Channel Defaults + +Disable a channel by default across all notification types: + +```python +@register +class SMSChannel(BaseChannel): + key = "sms" + name = "SMS" + supports_realtime = True + supports_digest = False + enabled_by_default = False # Opt-in only - users must explicitly enable +``` + +### Per-NotificationType Defaults + +By default all channels are enabled for every notification type. You can override channel defaults for specific notification types: + +```python +@register +class MarketingEmail(NotificationType): + key = "marketing" + name = "Marketing Updates" + # Only enable email by default + # (users can still opt-in to enable other channels) + default_channels = [EmailChannel] +``` + +### Priority Order + +The system determines enabled channels in this priority order: + +1. **Forbidden channels** - Always disabled (cannot be overridden) +2. **Required channels** - Always enabled (cannot be disabled) +3. **User preferences** - Explicit user enable/disable choices (see [preferences.md](https://github.com/loopwerk/django-generic-notifications/tree/main/docs/preferences.md)) +4. **NotificationType.default_channels** - Per-type defaults (if specified) +5. **BaseChannel.enabled_by_default** - Global channel defaults + ## Custom Frequencies Add custom email frequencies: diff --git a/docs/preferences.md b/docs/preferences.md index 546e5ff..9d96bec 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 (see [customizing.md](https://github.com/loopwerk/django-generic-notifications/tree/main/docs/customizing.md)). Users can then customize their preferences by explicitly enabling or disabling specific channels for each notification type. -All notification types default to daily digest, except for `SystemMessage` which defaults to real-time. Users can choose different frequency per notification type. +The system supports both: + +- **Channel preferences**: Enable/disable specific channels per notification type +- **Frequency preferences**: Choose between realtime delivery and digest delivery per notification type This project doesn't come with a UI (view + template) for managing user preferences, but an example is provided in the [example app](#example-app). @@ -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,12 @@ from myapp.notifications import CommentNotification # Disable email channel for comment notifications CommentNotification.disable_channel(user=user, channel=EmailChannel) -# Change to realtime digest for a notification type +# Enable email channel for comment notifications +CommentNotification.enable_channel(user=user, channel=EmailChannel) + +# Check which channels are enabled for a user +enabled_channels = CommentNotification.get_enabled_channels(user) + +# Change to realtime frequency for a notification type CommentNotification.set_frequency(user=user, frequency=RealtimeFrequency) -``` +``` \ No newline at end of file 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..db17da0 --- /dev/null +++ b/tests/test_channel_defaults.py @@ -0,0 +1,150 @@ +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""" + + # 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" + 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] + + 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""" + + 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" + + 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..02e27d3 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -8,7 +8,7 @@ from generic_notifications.channels import BaseChannel, EmailChannel from generic_notifications.frequencies import DailyFrequency, RealtimeFrequency -from generic_notifications.models import DisabledNotificationTypeChannel, Notification, NotificationFrequency +from generic_notifications.models import Notification, NotificationFrequencyPreference from generic_notifications.registry import registry from generic_notifications.types import NotificationType @@ -33,58 +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_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( - user=self.user, - notification_type="disabled_type", - channel="test", - ) - - # Should be disabled for this type - self.assertFalse(DisabledNotificationType.is_channel_enabled(self.user, TestChannel)) - - # But enabled for other types - self.assertTrue(OtherNotificationType.is_channel_enabled(self.user, TestChannel)) - def test_digest_only_channel_never_sends_immediately(self): """Test that channels with supports_realtime=False never send immediately.""" @@ -104,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 @@ -150,7 +83,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 +271,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 +305,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, @@ -449,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)) 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..acec635 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,22 +53,12 @@ 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( + disabled = NotificationTypeChannelPreference( user=self.user, notification_type="invalid_type", channel=WebsiteChannel.key, + enabled=False, ) with self.assertRaises(ValidationError) as cm: @@ -73,10 +67,11 @@ def test_clean_with_invalid_notification_type(self): self.assertIn("Unknown notification type: invalid_type", str(cm.exception)) def test_clean_with_invalid_channel(self): - disabled = DisabledNotificationTypeChannel( + disabled = NotificationTypeChannelPreference( user=self.user, notification_type=TestNotificationType.key, channel="invalid_channel", + enabled=False, ) with self.assertRaises(ValidationError) as cm: @@ -84,42 +79,45 @@ def test_clean_with_invalid_channel(self): 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,33 +130,23 @@ 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( + frequency = NotificationFrequencyPreference( user=self.user, notification_type="invalid_type", frequency=DailyFrequency.key, @@ -170,7 +158,7 @@ def test_clean_with_invalid_notification_type(self): self.assertIn("Unknown notification type: invalid_type", str(cm.exception)) def test_clean_with_invalid_frequency(self): - frequency = NotificationFrequency( + frequency = NotificationFrequencyPreference( user=self.user, notification_type=TestNotificationType.key, frequency="invalid_frequency", @@ -181,16 +169,6 @@ def test_clean_with_invalid_frequency(self): 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()