feat: added account level preference model in notification task (#36858)
feat: added account level pref model in notification task feat: added handler for preference creation on user signup feat: added unit tests for account level preferences * fix: removed app level check in notifications * fix: updated flaky test
This commit is contained in:
@@ -69,3 +69,14 @@ ENABLE_NOTIFY_ALL_LEARNERS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_post_n
|
||||
# .. toggle_target_removal_date: 2026-05-27
|
||||
# .. toggle_warning: When the flag is ON, Notifications will go through ace push channels.
|
||||
ENABLE_PUSH_NOTIFICATIONS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_push_notifications', __name__)
|
||||
|
||||
# .. toggle_name: notifications.enable_account_level_preferences
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Waffle flag to enable account level preferences for notifications
|
||||
# .. toggle_use_cases: temporary, open_edx
|
||||
# .. toggle_creation_date: 2025-04-29
|
||||
# .. toggle_target_removal_date: 2025-07-29
|
||||
# .. toggle_warning: When the flag is ON, account level preferences for notifications are enabled.
|
||||
# .. toggle_tickets: INF-1472
|
||||
ENABLE_ACCOUNT_LEVEL_PREFERENCES = WaffleFlag(f'{WAFFLE_NAMESPACE}.enable_account_level_preferences', __name__)
|
||||
|
||||
@@ -3,8 +3,10 @@ Handlers for notifications
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db import IntegrityError, transaction, ProgrammingError
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from openedx_events.learning.signals import (
|
||||
COURSE_ENROLLMENT_CREATED,
|
||||
@@ -21,12 +23,14 @@ from openedx.core.djangoapps.notifications.audience_filters import (
|
||||
ForumRoleAudienceFilter,
|
||||
TeamAudienceFilter
|
||||
)
|
||||
from openedx.core.djangoapps.notifications.base_notification import NotificationAppManager
|
||||
from openedx.core.djangoapps.notifications.base_notification import NotificationAppManager, COURSE_NOTIFICATION_TYPES
|
||||
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_ORA_GRADE_NOTIFICATION
|
||||
from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY
|
||||
from openedx.core.djangoapps.notifications.models import CourseNotificationPreference
|
||||
from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, NotificationPreference
|
||||
from openedx.core.djangoapps.notifications.tasks import create_notification_preference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
|
||||
User = get_user_model()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
AUDIENCE_FILTER_CLASSES = {
|
||||
@@ -63,6 +67,27 @@ def course_enrollment_post_save(signal, sender, enrollment, metadata, **kwargs):
|
||||
f'and course {enrollment.course.course_key}')
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_account_preferences(sender, instance, created, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Initialize user notification preferences when new user is created.
|
||||
"""
|
||||
preferences = []
|
||||
if created:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
for name in COURSE_NOTIFICATION_TYPES.keys():
|
||||
preferences.append(create_notification_preference(instance.id, name))
|
||||
NotificationPreference.objects.bulk_create(preferences, ignore_conflicts=True)
|
||||
except IntegrityError:
|
||||
log.info(f'Account-level CourseNotificationPreference already exists for user {instance.id}')
|
||||
except ProgrammingError as e:
|
||||
# This is here because there is a dependency issue in the migrations where
|
||||
# this signal handler tries to run before the NotificationPreference model is created.
|
||||
# In reality, this should never be hit because migrations will have already run.
|
||||
log.error(f'ProgrammingError encountered while creating user preferences: {e}')
|
||||
|
||||
|
||||
@receiver(COURSE_UNENROLLMENT_COMPLETED)
|
||||
def on_user_course_unenrollment(enrollment, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -5,15 +5,14 @@ Test for account level migration command
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.core.management import call_command
|
||||
from django.db.models.signals import post_save
|
||||
from django.test import TestCase
|
||||
|
||||
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
|
||||
from openedx.core.djangoapps.notifications.models import (
|
||||
CourseNotificationPreference,
|
||||
NotificationPreference
|
||||
)
|
||||
from openedx.core.djangoapps.notifications.handlers import create_user_account_preferences
|
||||
from openedx.core.djangoapps.notifications.management.commands.migrate_preferences_to_account_level_model import Command
|
||||
from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, NotificationPreference
|
||||
|
||||
User = get_user_model()
|
||||
COMMAND_MODULE = 'openedx.core.djangoapps.notifications.management.commands.migrate_preferences_to_account_level_model'
|
||||
@@ -24,6 +23,8 @@ class MigrateNotificationPreferencesTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
# Disconnect before creating users
|
||||
post_save.disconnect(create_user_account_preferences, sender=User)
|
||||
self.user1 = User.objects.create_user(username='user1', email='user1@example.com')
|
||||
self.user2 = User.objects.create_user(username='user2', email='user2@example.com')
|
||||
self.user3 = User.objects.create_user(username='user3', email='user3@example.com')
|
||||
|
||||
@@ -130,6 +130,7 @@ class NotificationPreference(TimeStampedModel):
|
||||
"""
|
||||
Model to store notification preferences for users at account level
|
||||
"""
|
||||
|
||||
class EmailCadenceChoices(models.TextChoices):
|
||||
DAILY = 'Daily'
|
||||
WEEKLY = 'Weekly'
|
||||
@@ -148,6 +149,33 @@ class NotificationPreference(TimeStampedModel):
|
||||
email_cadence = models.CharField(max_length=64, choices=EmailCadenceChoices.choices, null=False, blank=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def is_enabled_for_any_channel(self, *args, **kwargs) -> bool:
|
||||
"""
|
||||
Returns True if the notification preference is enabled for any channel.
|
||||
"""
|
||||
return self.web or self.push or self.email
|
||||
|
||||
def get_channels_for_notification_type(self, *args, **kwargs) -> list:
|
||||
"""
|
||||
Returns the channels for the given app name and notification type.
|
||||
Sample Response:
|
||||
['web', 'push']
|
||||
"""
|
||||
channels = []
|
||||
if self.web:
|
||||
channels.append('web')
|
||||
if self.push:
|
||||
channels.append('push')
|
||||
if self.email:
|
||||
channels.append('email')
|
||||
return channels
|
||||
|
||||
def get_email_cadence_for_notification_type(self, *args, **kwargs) -> str:
|
||||
"""
|
||||
Returns the email cadence for the notification type.
|
||||
"""
|
||||
return self.email_cadence
|
||||
|
||||
|
||||
class CourseNotificationPreference(TimeStampedModel):
|
||||
"""
|
||||
@@ -192,7 +220,7 @@ class CourseNotificationPreference(TimeStampedModel):
|
||||
preferences.config_version = current_config_version
|
||||
preferences.notification_preference_config = new_prefs
|
||||
preferences.save()
|
||||
# pylint: disable-next=broad-except
|
||||
# pylint: disable-next=broad-except
|
||||
except Exception as e:
|
||||
log.error(f'Unable to update notification preference to new config. {e}')
|
||||
return preferences
|
||||
|
||||
@@ -16,16 +16,19 @@ from pytz import UTC
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from openedx.core.djangoapps.notifications.audience_filters import NotificationFilter
|
||||
from openedx.core.djangoapps.notifications.base_notification import (
|
||||
COURSE_NOTIFICATION_APPS,
|
||||
COURSE_NOTIFICATION_TYPES,
|
||||
get_default_values_of_preference,
|
||||
get_notification_content
|
||||
)
|
||||
from openedx.core.djangoapps.notifications.email.tasks import send_immediate_cadence_email
|
||||
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
|
||||
from openedx.core.djangoapps.notifications.config.waffle import (
|
||||
ENABLE_ACCOUNT_LEVEL_PREFERENCES,
|
||||
ENABLE_NOTIFICATION_GROUPING,
|
||||
ENABLE_NOTIFICATIONS,
|
||||
ENABLE_PUSH_NOTIFICATIONS
|
||||
)
|
||||
from openedx.core.djangoapps.notifications.email.tasks import send_immediate_cadence_email
|
||||
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
|
||||
from openedx.core.djangoapps.notifications.events import notification_generated_event
|
||||
from openedx.core.djangoapps.notifications.grouping_notifications import (
|
||||
NotificationRegistry,
|
||||
@@ -35,12 +38,12 @@ from openedx.core.djangoapps.notifications.grouping_notifications import (
|
||||
from openedx.core.djangoapps.notifications.models import (
|
||||
CourseNotificationPreference,
|
||||
Notification,
|
||||
NotificationPreference,
|
||||
get_course_notification_preference_config_version
|
||||
)
|
||||
from openedx.core.djangoapps.notifications.push.tasks import send_ace_msg_to_push_channel
|
||||
from openedx.core.djangoapps.notifications.utils import clean_arguments, get_list_in_batches
|
||||
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
@@ -137,6 +140,8 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
|
||||
if not is_notification_valid(notification_type, context):
|
||||
raise ValidationError(f"Notification is not valid {app_name} {notification_type} {context}")
|
||||
|
||||
account_level_pref_enabled = ENABLE_ACCOUNT_LEVEL_PREFERENCES.is_enabled()
|
||||
|
||||
user_ids = list(set(user_ids))
|
||||
batch_size = settings.NOTIFICATION_CREATION_BATCH_SIZE
|
||||
group_by_id = context.pop('group_by_id', '')
|
||||
@@ -164,18 +169,31 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
|
||||
logger.debug(f'After applying filters, sending notifications to {len(batch_user_ids)} users in {course_key}')
|
||||
|
||||
existing_notifications = (
|
||||
get_user_existing_notifications(batch_user_ids, notification_type, group_by_id, course_key))\
|
||||
get_user_existing_notifications(batch_user_ids, notification_type, group_by_id, course_key)) \
|
||||
if grouping_enabled else {}
|
||||
|
||||
# check if what is preferences of user and make decision to send notification or not
|
||||
preferences = CourseNotificationPreference.objects.filter(
|
||||
user_id__in=batch_user_ids,
|
||||
course_id=course_key,
|
||||
)
|
||||
preferences = list(preferences)
|
||||
if account_level_pref_enabled:
|
||||
preferences = NotificationPreference.objects.filter(
|
||||
user_id__in=batch_user_ids,
|
||||
app=app_name,
|
||||
type=notification_type
|
||||
|
||||
)
|
||||
else:
|
||||
preferences = CourseNotificationPreference.objects.filter(
|
||||
user_id__in=batch_user_ids,
|
||||
course_id=course_key,
|
||||
)
|
||||
|
||||
preferences = list(preferences)
|
||||
if default_web_config:
|
||||
preferences = create_notification_pref_if_not_exists(batch_user_ids, preferences, course_key)
|
||||
if account_level_pref_enabled:
|
||||
preferences = create_account_notification_pref_if_not_exists(
|
||||
batch_user_ids, preferences, notification_type
|
||||
)
|
||||
else:
|
||||
preferences = create_notification_pref_if_not_exists(batch_user_ids, preferences, course_key)
|
||||
|
||||
if not preferences:
|
||||
continue
|
||||
@@ -183,15 +201,15 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
|
||||
notifications = []
|
||||
for preference in preferences:
|
||||
user_id = preference.user_id
|
||||
preference = update_user_preference(preference, user_id, course_key)
|
||||
if not account_level_pref_enabled:
|
||||
preference = update_user_preference(preference, user_id, course_key)
|
||||
|
||||
if (
|
||||
preference and
|
||||
preference.is_enabled_for_any_channel(app_name, notification_type) and
|
||||
preference.get_app_config(app_name).get('enabled', False)
|
||||
preference.is_enabled_for_any_channel(app_name, notification_type)
|
||||
):
|
||||
notification_preferences = preference.get_channels_for_notification_type(app_name, notification_type)
|
||||
email_enabled = 'email' in preference.get_channels_for_notification_type(app_name, notification_type)
|
||||
email_enabled = 'email' in notification_preferences
|
||||
email_cadence = preference.get_email_cadence_for_notification_type(app_name, notification_type)
|
||||
push_notification = is_push_notification_enabled and 'push' in notification_preferences
|
||||
new_notification = Notification(
|
||||
@@ -257,6 +275,82 @@ def update_user_preference(preference: CourseNotificationPreference, user_id, co
|
||||
return preference
|
||||
|
||||
|
||||
def update_account_user_preference(user_id: int) -> None:
|
||||
"""
|
||||
Update account level user preferences to ensure all notification types are present.
|
||||
"""
|
||||
notification_types = set(COURSE_NOTIFICATION_TYPES.keys())
|
||||
# Get existing notification types for the user
|
||||
existing_types = set(
|
||||
NotificationPreference.objects
|
||||
.filter(user_id=user_id, type__in=notification_types)
|
||||
.values_list('type', flat=True)
|
||||
)
|
||||
|
||||
# Find missing notification types
|
||||
missing_types = notification_types - existing_types
|
||||
|
||||
if not missing_types:
|
||||
return
|
||||
|
||||
# Create new preferences for missing types
|
||||
new_preferences = [
|
||||
create_notification_preference(user_id, notification_type)
|
||||
for notification_type in missing_types
|
||||
]
|
||||
|
||||
# Bulk create all new preferences
|
||||
NotificationPreference.objects.bulk_create(new_preferences)
|
||||
return
|
||||
|
||||
|
||||
def create_notification_preference(user_id: int, notification_type: str) -> NotificationPreference:
|
||||
"""
|
||||
Create a single notification preference with appropriate defaults.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
notification_type: Type of notification
|
||||
|
||||
Returns:
|
||||
NotificationPreference instance
|
||||
"""
|
||||
notification_config = COURSE_NOTIFICATION_TYPES.get(notification_type, {})
|
||||
is_core = notification_config.get('is_core', False)
|
||||
app = COURSE_NOTIFICATION_TYPES[notification_type]['notification_app']
|
||||
email_cadence = notification_config.get('email_cadence', EmailCadence.DAILY)
|
||||
if is_core:
|
||||
email_cadence = COURSE_NOTIFICATION_APPS[app]['core_email_cadence']
|
||||
return NotificationPreference(
|
||||
user_id=user_id,
|
||||
type=notification_type,
|
||||
app=app,
|
||||
web=_get_channel_default(is_core, notification_type, 'web'),
|
||||
push=_get_channel_default(is_core, notification_type, 'push'),
|
||||
email=_get_channel_default(is_core, notification_type, 'email'),
|
||||
email_cadence=email_cadence,
|
||||
)
|
||||
|
||||
|
||||
def _get_channel_default(is_core: bool, notification_type: str, channel: str) -> bool:
|
||||
"""
|
||||
Get the default value for a notification channel.
|
||||
|
||||
Args:
|
||||
is_core: Whether this is a core notification
|
||||
notification_type: Type of notification
|
||||
channel: Channel name (web, push, email)
|
||||
|
||||
Returns:
|
||||
Default boolean value for the channel
|
||||
"""
|
||||
if is_core:
|
||||
notification_app = COURSE_NOTIFICATION_TYPES[notification_type]['notification_app']
|
||||
return COURSE_NOTIFICATION_APPS[notification_app][f'core_{channel}']
|
||||
|
||||
return COURSE_NOTIFICATION_TYPES[notification_type][channel]
|
||||
|
||||
|
||||
def create_notification_pref_if_not_exists(user_ids: List, preferences: List, course_id: CourseKey):
|
||||
"""
|
||||
Create notification preference if not exist.
|
||||
@@ -275,3 +369,24 @@ def create_notification_pref_if_not_exists(user_ids: List, preferences: List, co
|
||||
CourseNotificationPreference.objects.bulk_create(new_preferences, ignore_conflicts=True)
|
||||
preferences = preferences + new_preferences
|
||||
return preferences
|
||||
|
||||
|
||||
def create_account_notification_pref_if_not_exists(user_ids: List, preferences: List, notification_type: str):
|
||||
"""
|
||||
Create account level notification preference if not exist.
|
||||
"""
|
||||
new_preferences = []
|
||||
|
||||
for user_id in user_ids:
|
||||
if not any(preference.user_id == int(user_id) for preference in preferences):
|
||||
new_preferences.append(create_notification_preference(
|
||||
user_id=int(user_id),
|
||||
notification_type=notification_type,
|
||||
|
||||
))
|
||||
if new_preferences:
|
||||
# ignoring conflicts because it is possible that preference is already created by another process
|
||||
# conflicts may arise because of constraint on user_id and course_id fields in model
|
||||
NotificationPreference.objects.bulk_create(new_preferences, ignore_conflicts=True)
|
||||
preferences = preferences + new_preferences
|
||||
return preferences
|
||||
|
||||
@@ -230,32 +230,6 @@ class SendNotificationsTest(ModuleStoreTestCase):
|
||||
self.assertEqual(Notification.objects.filter(user_id=self.user.id).count(), 1)
|
||||
user_notifications_mock.assert_called_once()
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
@ddt.data(
|
||||
('discussion', 'new_comment_on_response'), # core notification
|
||||
('discussion', 'new_response'), # non core notification
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_send_with_app_disabled_notifications(self, app_name, notification_type):
|
||||
"""
|
||||
Test send_notifications does not create a new notification if the app is disabled.
|
||||
"""
|
||||
self.preference_v1.notification_preference_config['discussion']['enabled'] = False
|
||||
self.preference_v1.save()
|
||||
|
||||
context = {
|
||||
'post_title': 'Post title',
|
||||
'replier_name': 'replier name',
|
||||
}
|
||||
content_url = 'https://example.com/'
|
||||
|
||||
# Call the `send_notifications` function.
|
||||
send_notifications([self.user.id], str(self.course_1.id), app_name, notification_type, context, content_url)
|
||||
|
||||
# Assert that `Notification` objects are not created for the users.
|
||||
notification = Notification.objects.filter(user_id=self.user.id).first()
|
||||
self.assertIsNone(notification)
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
def test_notification_not_created_when_context_is_incomplete(self):
|
||||
try:
|
||||
@@ -295,9 +269,9 @@ class SendBatchNotificationsTest(ModuleStoreTestCase):
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
@ddt.data(
|
||||
(settings.NOTIFICATION_CREATION_BATCH_SIZE, 13, 6),
|
||||
(settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 15, 9),
|
||||
(settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 13, 5),
|
||||
(settings.NOTIFICATION_CREATION_BATCH_SIZE, 14, 7),
|
||||
(settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 16, 10),
|
||||
(settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 14, 6),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, notifications_query_count):
|
||||
@@ -348,7 +322,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase):
|
||||
"username": "Test Author"
|
||||
}
|
||||
with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True):
|
||||
with self.assertNumQueries(13):
|
||||
with self.assertNumQueries(14):
|
||||
send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
|
||||
context, "http://test.url")
|
||||
|
||||
@@ -368,7 +342,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase):
|
||||
}
|
||||
with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True):
|
||||
with override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True):
|
||||
with self.assertNumQueries(15):
|
||||
with self.assertNumQueries(16):
|
||||
send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
|
||||
context, "http://test.url")
|
||||
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
"""
|
||||
Tests for notifications tasks.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from ..config.waffle import ENABLE_ACCOUNT_LEVEL_PREFERENCES, ENABLE_NOTIFICATION_GROUPING, ENABLE_NOTIFICATIONS
|
||||
from ..models import CourseNotificationPreference, Notification, NotificationPreference
|
||||
from ..tasks import (
|
||||
create_notification_pref_if_not_exists,
|
||||
delete_notifications,
|
||||
send_notifications,
|
||||
update_user_preference
|
||||
)
|
||||
from .utils import create_notification
|
||||
|
||||
|
||||
@patch('openedx.core.djangoapps.notifications.models.COURSE_NOTIFICATION_CONFIG_VERSION', 1)
|
||||
@override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, active=True)
|
||||
class TestNotificationsTasks(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for notifications tasks.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a course and users for the course.
|
||||
"""
|
||||
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.user_1 = UserFactory()
|
||||
self.user_2 = UserFactory()
|
||||
self.course_1 = CourseFactory.create(
|
||||
org='testorg',
|
||||
number='testcourse',
|
||||
run='testrun'
|
||||
)
|
||||
self.course_2 = CourseFactory.create(
|
||||
org='testorg',
|
||||
number='testcourse_2',
|
||||
run='testrun'
|
||||
)
|
||||
self.preference_v1 = CourseNotificationPreference.objects.create(
|
||||
user_id=self.user.id,
|
||||
course_id=self.course_1.id,
|
||||
config_version=0,
|
||||
)
|
||||
self.preference_v2 = CourseNotificationPreference.objects.create(
|
||||
user_id=self.user.id,
|
||||
course_id=self.course_2.id,
|
||||
config_version=1,
|
||||
)
|
||||
|
||||
def test_update_user_preference(self):
|
||||
"""
|
||||
Test whether update_user_preference updates the preference with the latest config version.
|
||||
"""
|
||||
# Test whether update_user_preference updates the preference with a different config version
|
||||
updated_preference = update_user_preference(self.preference_v1, self.user, self.course_1.id)
|
||||
self.assertEqual(updated_preference.config_version, 1)
|
||||
|
||||
# Test whether update_user_preference does not update the preference if the config version is the same
|
||||
updated_preference = update_user_preference(self.preference_v2, self.user, self.course_2.id)
|
||||
self.assertEqual(updated_preference.config_version, 1)
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
def test_create_notification_pref_if_not_exists(self):
|
||||
"""
|
||||
Test whether create_notification_pref_if_not_exists creates a new preference if it doesn't exist.
|
||||
"""
|
||||
# Test whether create_notification_pref_if_not_exists creates a new preference if it doesn't exist
|
||||
user_ids = [self.user.id, self.user_1.id, self.user_2.id]
|
||||
preferences = [self.preference_v2]
|
||||
updated_preferences = create_notification_pref_if_not_exists(user_ids, preferences, self.course_2.id)
|
||||
self.assertEqual(len(updated_preferences), 3) # Should have created two new preferences
|
||||
|
||||
# Test whether create_notification_pref_if_not_exists doesn't create a new preference if it already exists
|
||||
updated_preferences = create_notification_pref_if_not_exists(user_ids, preferences, self.course_2.id)
|
||||
self.assertEqual(len(updated_preferences), 3) # No new preferences should be created this time
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, active=True)
|
||||
class SendNotificationsTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for send_notifications.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a course and users for the course.
|
||||
"""
|
||||
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.course_1 = CourseFactory.create(
|
||||
org='testorg',
|
||||
number='testcourse',
|
||||
run='testrun'
|
||||
)
|
||||
|
||||
self.preference_v1 = CourseNotificationPreference.objects.create(
|
||||
user_id=self.user.id,
|
||||
course_id=self.course_1.id,
|
||||
config_version=0,
|
||||
)
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
@ddt.data(
|
||||
('discussion', 'new_comment_on_response'), # core notification
|
||||
('discussion', 'new_response'), # non core notification
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_send_notifications(self, app_name, notification_type):
|
||||
"""
|
||||
Test whether send_notifications creates a new notification.
|
||||
"""
|
||||
context = {
|
||||
'post_title': 'Post title',
|
||||
'replier_name': 'replier name',
|
||||
}
|
||||
content_url = 'https://example.com/'
|
||||
|
||||
# Call the `send_notifications` function.
|
||||
with patch('openedx.core.djangoapps.notifications.tasks.notification_generated_event') as event_mock:
|
||||
send_notifications([self.user.id], str(self.course_1.id), app_name, notification_type, context, content_url)
|
||||
assert event_mock.called
|
||||
assert event_mock.call_args[0][0] == [self.user.id]
|
||||
assert event_mock.call_args[0][1] == app_name
|
||||
assert event_mock.call_args[0][2] == notification_type
|
||||
|
||||
# Assert that `Notification` objects have been created for the users.
|
||||
notification = Notification.objects.filter(user_id=self.user.id).first()
|
||||
# Assert that the `Notification` objects have the correct properties.
|
||||
self.assertEqual(notification.user_id, self.user.id)
|
||||
self.assertEqual(notification.app_name, app_name)
|
||||
self.assertEqual(notification.notification_type, notification_type)
|
||||
self.assertEqual(notification.content_context, context)
|
||||
self.assertEqual(notification.content_url, content_url)
|
||||
self.assertEqual(notification.course_id, self.course_1.id)
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_enable_notification_flag(self, flag_value):
|
||||
"""
|
||||
Tests if notification is sent when flag is enabled and notification
|
||||
is not sent when flag is disabled
|
||||
"""
|
||||
app_name = "discussion"
|
||||
notification_type = "new_response"
|
||||
context = {
|
||||
'post_title': 'Post title',
|
||||
'replier_name': 'replier name',
|
||||
}
|
||||
content_url = 'https://example.com/'
|
||||
with override_waffle_flag(ENABLE_NOTIFICATIONS, active=flag_value):
|
||||
send_notifications([self.user.id], str(self.course_1.id), app_name, notification_type, context, content_url)
|
||||
created_notifications_count = 1 if flag_value else 0
|
||||
self.assertEqual(len(Notification.objects.all()), created_notifications_count)
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
def test_notification_not_send_with_preference_disabled(self):
|
||||
"""
|
||||
Tests notification not send if preference is disabled
|
||||
"""
|
||||
app_name = "discussion"
|
||||
notification_type = "new_response"
|
||||
context = {
|
||||
'post_title': 'Post title',
|
||||
'replier_name': 'replier name',
|
||||
}
|
||||
content_url = 'https://example.com/'
|
||||
|
||||
preference = CourseNotificationPreference.get_user_course_preference(self.user.id, self.course_1.id)
|
||||
app_prefs = preference.notification_preference_config[app_name]
|
||||
app_prefs['notification_types']['core']['web'] = False
|
||||
app_prefs['notification_types']['core']['email'] = False
|
||||
app_prefs['notification_types']['core']['push'] = False
|
||||
preference.save()
|
||||
account_preferences, __created = NotificationPreference.objects.get_or_create(
|
||||
user_id=self.user.id,
|
||||
app=app_name,
|
||||
type=notification_type,
|
||||
)
|
||||
account_preferences.web = False
|
||||
account_preferences.email = False
|
||||
account_preferences.push = False
|
||||
account_preferences.save()
|
||||
|
||||
send_notifications([self.user.id], str(self.course_1.id), app_name, notification_type, context, content_url)
|
||||
self.assertEqual(len(Notification.objects.all()), 0)
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATION_GROUPING, True)
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
def test_send_notification_with_grouping_enabled(self):
|
||||
"""
|
||||
Test send_notifications with grouping enabled.
|
||||
"""
|
||||
(
|
||||
self.preference_v1.notification_preference_config['discussion']
|
||||
['notification_types']['new_discussion_post']['web']
|
||||
) = True
|
||||
self.preference_v1.save()
|
||||
|
||||
account_preferences, __created = NotificationPreference.objects.get_or_create(
|
||||
user_id=self.user.id,
|
||||
app='discussion',
|
||||
type='new_discussion_post',
|
||||
)
|
||||
account_preferences.web = True
|
||||
account_preferences.save()
|
||||
with patch('openedx.core.djangoapps.notifications.tasks.group_user_notifications') as user_notifications_mock:
|
||||
context = {
|
||||
'post_title': 'Test Post',
|
||||
'username': 'Test Author',
|
||||
'group_by_id': 'group_by_id'
|
||||
}
|
||||
content_url = 'https://example.com/'
|
||||
send_notifications(
|
||||
[self.user.id],
|
||||
str(self.course_1.id),
|
||||
'discussion',
|
||||
'new_discussion_post',
|
||||
{**context},
|
||||
content_url
|
||||
)
|
||||
send_notifications(
|
||||
[self.user.id],
|
||||
str(self.course_1.id),
|
||||
'discussion',
|
||||
'new_discussion_post',
|
||||
{**context},
|
||||
content_url
|
||||
)
|
||||
self.assertEqual(Notification.objects.filter(user_id=self.user.id).count(), 1)
|
||||
user_notifications_mock.assert_called_once()
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
def test_notification_not_created_when_context_is_incomplete(self):
|
||||
try:
|
||||
send_notifications([self.user.id], str(self.course_1.id), "discussion", "new_comment", {}, "")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
assert isinstance(exc, ValidationError)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, active=True)
|
||||
class SendBatchNotificationsTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Test that notification and notification preferences are created in batches
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Setups test case
|
||||
"""
|
||||
super().setUp()
|
||||
self.course = CourseFactory.create(
|
||||
org='test_org',
|
||||
number='test_course',
|
||||
run='test_run'
|
||||
)
|
||||
|
||||
def _create_users(self, num_of_users):
|
||||
"""
|
||||
Create users and enroll them in course
|
||||
"""
|
||||
users = [
|
||||
UserFactory.create(username=f'user{i}', email=f'user{i}@example.com')
|
||||
for i in range(num_of_users)
|
||||
]
|
||||
for user in users:
|
||||
CourseEnrollment.enroll(user=user, course_key=self.course.id)
|
||||
return users
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
@ddt.data(
|
||||
(settings.NOTIFICATION_CREATION_BATCH_SIZE, 14, 5),
|
||||
(settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 16, 7),
|
||||
(settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 14, 5),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, notifications_query_count):
|
||||
"""
|
||||
Tests notifications and notification preferences are created in batches
|
||||
"""
|
||||
notification_app = "discussion"
|
||||
notification_type = "new_discussion_post"
|
||||
users = self._create_users(creation_size)
|
||||
user_ids = [user.id for user in users]
|
||||
context = {
|
||||
"post_title": "Test Post",
|
||||
"username": "Test Author"
|
||||
}
|
||||
|
||||
# Creating preferences and asserting query count
|
||||
with self.assertNumQueries(prefs_query_count):
|
||||
send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
|
||||
context, "http://test.url")
|
||||
|
||||
# Updating preferences for notification creation
|
||||
preferences = CourseNotificationPreference.objects.filter(
|
||||
user_id__in=user_ids,
|
||||
course_id=self.course.id
|
||||
)
|
||||
for preference in preferences:
|
||||
discussion_config = preference.notification_preference_config['discussion']
|
||||
discussion_config['notification_types'][notification_type]['web'] = True
|
||||
preference.save()
|
||||
|
||||
# Creating notifications and asserting query count
|
||||
with self.assertNumQueries(notifications_query_count):
|
||||
send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
|
||||
context, "http://test.url")
|
||||
|
||||
def test_preference_not_created_for_default_off_preference(self):
|
||||
"""
|
||||
Tests if new preferences are NOT created when default preference for
|
||||
notification type is False
|
||||
"""
|
||||
notification_app = "discussion"
|
||||
notification_type = "new_discussion_post"
|
||||
users = self._create_users(20)
|
||||
user_ids = [user.id for user in users]
|
||||
context = {
|
||||
"post_title": "Test Post",
|
||||
"username": "Test Author"
|
||||
}
|
||||
with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True):
|
||||
with self.assertNumQueries(14):
|
||||
send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
|
||||
context, "http://test.url")
|
||||
|
||||
def test_preference_created_for_default_on_preference(self):
|
||||
"""
|
||||
Tests if new preferences are created when default preference for
|
||||
notification type is True
|
||||
"""
|
||||
notification_app = "discussion"
|
||||
notification_type = "new_comment"
|
||||
users = self._create_users(20)
|
||||
NotificationPreference.objects.all().delete()
|
||||
user_ids = [user.id for user in users]
|
||||
context = {
|
||||
"post_title": "Test Post",
|
||||
"author_name": "Test Author",
|
||||
"replier_name": "Replier Name"
|
||||
}
|
||||
with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True):
|
||||
with self.assertNumQueries(16):
|
||||
send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
|
||||
context, "http://test.url")
|
||||
|
||||
def _update_user_preference(self, user_id, pref_exists):
|
||||
"""
|
||||
Removes or creates user preference based on pref_exists
|
||||
"""
|
||||
if pref_exists:
|
||||
CourseNotificationPreference.objects.get_or_create(user_id=user_id, course_id=self.course.id)
|
||||
else:
|
||||
CourseNotificationPreference.objects.filter(user_id=user_id, course_id=self.course.id).delete()
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
@ddt.data(
|
||||
("new_response", True, True, 2),
|
||||
("new_response", False, False, 2),
|
||||
("new_response", True, False, 2),
|
||||
("new_discussion_post", True, True, 0),
|
||||
("new_discussion_post", False, False, 0),
|
||||
("new_discussion_post", True, False, 0),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_preference_enabled_in_batch_audience(self, notification_type,
|
||||
user_1_pref_exists, user_2_pref_exists, generated_count):
|
||||
"""
|
||||
Tests if users with preference enabled in batch gets notification
|
||||
"""
|
||||
users = self._create_users(2)
|
||||
user_ids = [user.id for user in users]
|
||||
self._update_user_preference(user_ids[0], user_1_pref_exists)
|
||||
self._update_user_preference(user_ids[1], user_2_pref_exists)
|
||||
|
||||
app_name = "discussion"
|
||||
context = {
|
||||
'post_title': 'Post title',
|
||||
'username': 'Username',
|
||||
'replier_name': 'replier name',
|
||||
'author_name': 'Authorname'
|
||||
}
|
||||
content_url = 'https://example.com/'
|
||||
send_notifications(user_ids, str(self.course.id), app_name, notification_type, context, content_url)
|
||||
self.assertEqual(len(Notification.objects.all()), generated_count)
|
||||
|
||||
|
||||
@override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, active=True)
|
||||
class TestDeleteNotificationTask(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests delete_notification_function
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup
|
||||
"""
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.course_1 = CourseFactory.create(org='org', number='num', run='run_01')
|
||||
self.course_2 = CourseFactory.create(org='org', number='num', run='run_02')
|
||||
Notification.objects.all().delete()
|
||||
|
||||
def test_app_name_param(self):
|
||||
"""
|
||||
Tests if app_name parameter works as expected
|
||||
"""
|
||||
assert not Notification.objects.all()
|
||||
create_notification(self.user, self.course_1.id, app_name='discussion', notification_type='new_comment')
|
||||
create_notification(self.user, self.course_1.id, app_name='updates', notification_type='course_updates')
|
||||
delete_notifications({'app_name': 'discussion'})
|
||||
assert not Notification.objects.filter(app_name='discussion')
|
||||
assert Notification.objects.filter(app_name='updates')
|
||||
|
||||
def test_notification_type_param(self):
|
||||
"""
|
||||
Tests if notification_type parameter works as expected
|
||||
"""
|
||||
assert not Notification.objects.all()
|
||||
create_notification(self.user, self.course_1.id, app_name='discussion', notification_type='new_comment')
|
||||
create_notification(self.user, self.course_1.id, app_name='discussion', notification_type='new_response')
|
||||
delete_notifications({'notification_type': 'new_comment'})
|
||||
assert not Notification.objects.filter(notification_type='new_comment')
|
||||
assert Notification.objects.filter(notification_type='new_response')
|
||||
|
||||
def test_created_param(self):
|
||||
"""
|
||||
Tests if created parameter works as expected
|
||||
"""
|
||||
assert not Notification.objects.all()
|
||||
create_notification(self.user, self.course_1.id, created=datetime.datetime(2024, 2, 10))
|
||||
create_notification(self.user, self.course_2.id, created=datetime.datetime(2024, 3, 12, 5))
|
||||
kwargs = {
|
||||
'created': {
|
||||
'created__gte': datetime.datetime(2024, 3, 12, 0, 0, 0),
|
||||
'created__lte': datetime.datetime(2024, 3, 12, 23, 59, 59),
|
||||
}
|
||||
}
|
||||
delete_notifications(kwargs)
|
||||
self.assertEqual(Notification.objects.all().count(), 1)
|
||||
|
||||
def test_course_id_param(self):
|
||||
"""
|
||||
Tests if course_id parameter works as expected
|
||||
"""
|
||||
assert not Notification.objects.all()
|
||||
create_notification(self.user, self.course_1.id)
|
||||
create_notification(self.user, self.course_2.id)
|
||||
delete_notifications({'course_id': self.course_1.id})
|
||||
assert not Notification.objects.filter(course_id=self.course_1.id)
|
||||
assert Notification.objects.filter(course_id=self.course_2.id)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class NotificationCreationOnChannelsTests(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for notification creation and channels value.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a course and users for tests.
|
||||
"""
|
||||
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.course = CourseFactory.create(
|
||||
org='testorg',
|
||||
number='testcourse',
|
||||
run='testrun'
|
||||
)
|
||||
|
||||
self.preference = CourseNotificationPreference.objects.create(
|
||||
user_id=self.user.id,
|
||||
course_id=self.course.id,
|
||||
config_version=0,
|
||||
)
|
||||
self.account_preference, __created = NotificationPreference.objects.get_or_create(
|
||||
user_id=self.user.id,
|
||||
app='discussion',
|
||||
type='new_discussion_post',
|
||||
web=False,
|
||||
email=False,
|
||||
)
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
@ddt.data(
|
||||
(False, False, 0),
|
||||
(False, True, 1),
|
||||
(True, False, 1),
|
||||
(True, True, 1),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_notification_is_created_when_any_channel_is_enabled(self, web_value, email_value, generated_count):
|
||||
"""
|
||||
Tests if notification is created if any preference is enabled
|
||||
"""
|
||||
app_name = 'discussion'
|
||||
notification_type = 'new_discussion_post'
|
||||
app_prefs = self.preference.notification_preference_config[app_name]
|
||||
app_prefs['notification_types'][notification_type]['web'] = web_value
|
||||
app_prefs['notification_types'][notification_type]['email'] = email_value
|
||||
kwargs = {
|
||||
'user_ids': [self.user.id],
|
||||
'course_key': str(self.course.id),
|
||||
'app_name': app_name,
|
||||
'notification_type': notification_type,
|
||||
'content_url': 'https://example.com/',
|
||||
'context': {
|
||||
'post_title': 'Post title',
|
||||
'username': 'user name',
|
||||
},
|
||||
}
|
||||
self.preference.save()
|
||||
with patch('openedx.core.djangoapps.notifications.tasks.notification_generated_event') as event_mock:
|
||||
send_notifications(**kwargs)
|
||||
notifications = Notification.objects.all()
|
||||
assert len(notifications) == generated_count
|
||||
if notifications:
|
||||
notification = Notification.objects.all()[0]
|
||||
assert notification.web == web_value
|
||||
assert notification.email == email_value
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
@override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, active=True)
|
||||
@ddt.data(
|
||||
(False, False, 0),
|
||||
(False, True, 1),
|
||||
(True, False, 1),
|
||||
(True, True, 1),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_notification_is_created_when_any_channel_is_account_level(self, web_value, email_value, generated_count):
|
||||
"""
|
||||
Tests if notification is created if any preference is enabled on account level preferences
|
||||
"""
|
||||
app_name = 'discussion'
|
||||
notification_type = 'new_discussion_post'
|
||||
self.account_preference.web = web_value
|
||||
self.account_preference.email = email_value
|
||||
self.account_preference.save()
|
||||
kwargs = {
|
||||
'user_ids': [self.user.id],
|
||||
'course_key': str(self.course.id),
|
||||
'app_name': app_name,
|
||||
'notification_type': notification_type,
|
||||
'content_url': 'https://example.com/',
|
||||
'context': {
|
||||
'post_title': 'Post title',
|
||||
'username': 'user name',
|
||||
},
|
||||
}
|
||||
with patch('openedx.core.djangoapps.notifications.tasks.notification_generated_event') as event_mock:
|
||||
send_notifications(**kwargs)
|
||||
notifications = Notification.objects.all()
|
||||
assert len(notifications) == generated_count
|
||||
if notifications:
|
||||
notification = Notification.objects.all()[0]
|
||||
assert notification.web == web_value
|
||||
assert notification.email == email_value
|
||||
Reference in New Issue
Block a user