diff --git a/openedx/core/djangoapps/notifications/config/waffle.py b/openedx/core/djangoapps/notifications/config/waffle.py index cfc7e1039f..12b0ef5ee1 100644 --- a/openedx/core/djangoapps/notifications/config/waffle.py +++ b/openedx/core/djangoapps/notifications/config/waffle.py @@ -59,3 +59,13 @@ ENABLE_NOTIFICATION_GROUPING = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_noti # .. toggle_warning: When the flag is ON, notification to all learners feature is enabled on discussion post. # .. toggle_tickets: INF-1917 ENABLE_NOTIFY_ALL_LEARNERS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_post_notify_all_learners', __name__) + +# .. toggle_name: notifications.enable_push_notifications +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable push Notifications feature on mobile devices +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2025-05-27 +# .. 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__) diff --git a/openedx/core/djangoapps/notifications/migrations/0009_notification_push.py b/openedx/core/djangoapps/notifications/migrations/0009_notification_push.py new file mode 100644 index 0000000000..8314f8fe59 --- /dev/null +++ b/openedx/core/djangoapps/notifications/migrations/0009_notification_push.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.18 on 2025-03-12 22:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0008_notificationpreference'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='push', + field=models.BooleanField(default=False), + ), + ] diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index fc994b46ea..0f8539ccbf 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -110,6 +110,7 @@ class Notification(TimeStampedModel): content_url = models.URLField(null=True, blank=True) web = models.BooleanField(default=True, null=False, blank=False) email = models.BooleanField(default=False, null=False, blank=False) + push = models.BooleanField(default=False, null=False, blank=False) last_read = models.DateTimeField(null=True, blank=True) last_seen = models.DateTimeField(null=True, blank=True) group_by_id = models.CharField(max_length=255, db_index=True, null=False, default="") diff --git a/openedx/core/djangoapps/notifications/push/__init__.py b/openedx/core/djangoapps/notifications/push/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/notifications/push/message_type.py b/openedx/core/djangoapps/notifications/push/message_type.py new file mode 100644 index 0000000000..dd061e092d --- /dev/null +++ b/openedx/core/djangoapps/notifications/push/message_type.py @@ -0,0 +1,10 @@ +""" +Push notifications MessageType +""" +from openedx.core.djangoapps.ace_common.message import BaseMessageType + + +class PushNotificationMessageType(BaseMessageType): + """ + Edx-ace MessageType for Push Notifications + """ diff --git a/openedx/core/djangoapps/notifications/push/tasks.py b/openedx/core/djangoapps/notifications/push/tasks.py new file mode 100644 index 0000000000..fb24dfc19d --- /dev/null +++ b/openedx/core/djangoapps/notifications/push/tasks.py @@ -0,0 +1,45 @@ +""" Tasks for sending notification to ace push channel """ +from celery.utils.log import get_task_logger +from django.conf import settings +from django.contrib.auth import get_user_model +from edx_ace import ace + +from .message_type import PushNotificationMessageType + +User = get_user_model() +logger = get_task_logger(__name__) + + +def send_ace_msg_to_push_channel(audience_ids, notification_object, sender_id): + """ + Send mobile notifications using ace to push channels. + """ + if not audience_ids: + return + + # We are releasing this feature gradually. For now, it is only tested with the discussion app. + # We might have a list here in the future. + if notification_object.app_name != 'discussion': + return + + notification_type = notification_object.notification_type + + post_data = { + 'notification_type': notification_type, + 'course_id': str(notification_object.course_id), + 'content_url': notification_object.content_url, + **notification_object.content_context + } + emails = list(User.objects.filter(id__in=audience_ids).values_list('email', flat=True)) + context = {'post_data': post_data} + + message = PushNotificationMessageType( + app_label="notifications", name="push" + ).personalize(None, 'en', context) + message.options['emails'] = emails + message.options['notification_type'] = notification_type + message.options['skip_disable_user_policy'] = True + + ace.send(message, limit_to_channels=getattr(settings, 'ACE_PUSH_CHANNELS', [])) + log_msg = 'Sent mobile notification for %s to ace push channel. Audience IDs: %s' + logger.info(log_msg, notification_type, audience_ids) diff --git a/openedx/core/djangoapps/notifications/push/tests/__init__.py b/openedx/core/djangoapps/notifications/push/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/notifications/push/tests/test_tasks.py b/openedx/core/djangoapps/notifications/push/tests/test_tasks.py new file mode 100644 index 0000000000..5f3458ac5a --- /dev/null +++ b/openedx/core/djangoapps/notifications/push/tests/test_tasks.py @@ -0,0 +1,73 @@ +""" +Tests for push notifications tasks. +""" +from unittest import mock + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.notifications.push.tasks import send_ace_msg_to_push_channel +from openedx.core.djangoapps.notifications.tests.utils import create_notification +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class SendNotificationsTest(ModuleStoreTestCase): + """ + Tests for send_notifications. + """ + + def setUp(self): + """ + Create a course and users for the course. + """ + + super().setUp() + self.user_1 = UserFactory() + self.user_2 = UserFactory() + self.course_1 = CourseFactory.create( + org='testorg', + number='testcourse', + run='testrun' + ) + + self.notification = create_notification( + self.user, self.course_1.id, app_name='discussion', notification_type='new_comment' + ) + + @mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send') + def test_send_ace_msg_success(self, mock_ace_send): + """ Test send_ace_msg_success """ + send_ace_msg_to_push_channel( + [self.user_1.id, self.user_2.id], + self.notification, + sender_id=self.user_1.id + ) + + mock_ace_send.assert_called_once() + message_sent = mock_ace_send.call_args[0][0] + assert message_sent.options['emails'] == [self.user_1.email, self.user_2.email] + assert message_sent.options['notification_type'] == 'new_comment' + + @mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send') + def test_send_ace_msg_no_sender(self, mock_ace_send): + """ Test when sender is not valid """ + send_ace_msg_to_push_channel( + [self.user_1.id, self.user_2.id], + self.notification, + sender_id=999 + ) + + mock_ace_send.assert_called_once() + + @mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send') + def test_send_ace_msg_empty_audience(self, mock_ace_send): + """ Test send_ace_msg_success with empty audience """ + send_ace_msg_to_push_channel([], self.notification, sender_id=self.user_1.id) + mock_ace_send.assert_not_called() + + @mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send') + def test_send_ace_msg_non_discussion_app(self, mock_ace_send): + """ Test send_ace_msg_success with non-discussion app """ + self.notification.app_name = 'ecommerce' + self.notification.save() + send_ace_msg_to_push_channel([1], self.notification, sender_id=self.user_1.id) + mock_ace_send.assert_not_called() diff --git a/openedx/core/djangoapps/notifications/tasks.py b/openedx/core/djangoapps/notifications/tasks.py index fbb6a871a1..d08614b821 100644 --- a/openedx/core/djangoapps/notifications/tasks.py +++ b/openedx/core/djangoapps/notifications/tasks.py @@ -19,19 +19,25 @@ from openedx.core.djangoapps.notifications.base_notification import ( get_default_values_of_preference, get_notification_content ) -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATION_GROUPING, ENABLE_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.config.waffle import ( + ENABLE_NOTIFICATION_GROUPING, + ENABLE_NOTIFICATIONS, + ENABLE_PUSH_NOTIFICATIONS +) from openedx.core.djangoapps.notifications.events import notification_generated_event from openedx.core.djangoapps.notifications.grouping_notifications import ( + NotificationRegistry, get_user_existing_notifications, - group_user_notifications, NotificationRegistry, + group_user_notifications ) from openedx.core.djangoapps.notifications.models import ( CourseNotificationPreference, Notification, 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 @@ -123,6 +129,7 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c """ Send notifications to the users. """ + # pylint: disable=too-many-statements course_key = CourseKey.from_string(course_key) if not ENABLE_NOTIFICATIONS.is_enabled(course_key): return @@ -136,12 +143,13 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c grouping_function = NotificationRegistry.get_grouper(notification_type) waffle_flag_enabled = ENABLE_NOTIFICATION_GROUPING.is_enabled(course_key) grouping_enabled = waffle_flag_enabled and group_by_id and grouping_function is not None - notifications_generated = False - notification_content = '' + generated_notification = None sender_id = context.pop('sender_id', None) default_web_config = get_default_values_of_preference(app_name, notification_type).get('web', False) generated_notification_audience = [] email_notification_mapping = {} + push_notification_audience = [] + is_push_notification_enabled = ENABLE_PUSH_NOTIFICATIONS.is_enabled(course_key) if group_by_id and not grouping_enabled: logger.info( @@ -185,6 +193,7 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c 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_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( user_id=user_id, app_name=app_name, @@ -194,34 +203,37 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c course_id=course_key, web='web' in notification_preferences, email=email_enabled, + push=push_notification, group_by_id=group_by_id, ) if email_enabled and (email_cadence == EmailCadence.IMMEDIATELY): email_notification_mapping[user_id] = new_notification + if push_notification: + push_notification_audience.append(user_id) + if grouping_enabled and existing_notifications.get(user_id, None): group_user_notifications(new_notification, existing_notifications[user_id]) - if not notifications_generated: - notifications_generated = True - notification_content = new_notification.content + if not generated_notification: + generated_notification = new_notification else: notifications.append(new_notification) generated_notification_audience.append(user_id) # send notification to users but use bulk_create notification_objects = Notification.objects.bulk_create(notifications) - if notification_objects and not notifications_generated: - notifications_generated = True - notification_content = notification_objects[0].content + if notification_objects and not generated_notification: + generated_notification = notification_objects[0] if email_notification_mapping: send_immediate_cadence_email(email_notification_mapping, course_key) - if notifications_generated: + if generated_notification: notification_generated_event( generated_notification_audience, app_name, notification_type, course_key, content_url, - notification_content, sender_id=sender_id + generated_notification.content, sender_id=sender_id ) + send_ace_msg_to_push_channel(push_notification_audience, generated_notification, sender_id) def is_notification_valid(notification_type, context): diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/push/push/body.txt b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/push/push/body.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/push/push/title.txt b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/push/push/title.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/notifications/tests/test_tasks.py b/openedx/core/djangoapps/notifications/tests/test_tasks.py index 14608a2135..04e5ae052c 100644 --- a/openedx/core/djangoapps/notifications/tests/test_tasks.py +++ b/openedx/core/djangoapps/notifications/tests/test_tasks.py @@ -15,7 +15,7 @@ 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_NOTIFICATIONS, ENABLE_NOTIFICATION_GROUPING +from ..config.waffle import ENABLE_NOTIFICATION_GROUPING, ENABLE_NOTIFICATIONS, ENABLE_PUSH_NOTIFICATIONS from ..models import CourseNotificationPreference, Notification from ..tasks import ( create_notification_pref_if_not_exists, @@ -116,6 +116,7 @@ class SendNotificationsTest(ModuleStoreTestCase): ) @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) @ddt.data( ('discussion', 'new_comment_on_response'), # core notification ('discussion', 'new_response'), # non core notification @@ -168,6 +169,7 @@ class SendNotificationsTest(ModuleStoreTestCase): self.assertEqual(len(Notification.objects.all()), created_notifications_count) @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) def test_notification_not_send_with_preference_disabled(self): """ Tests notification not send if preference is disabled @@ -192,6 +194,7 @@ class SendNotificationsTest(ModuleStoreTestCase): @override_waffle_flag(ENABLE_NOTIFICATION_GROUPING, True) @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) def test_send_notification_with_grouping_enabled(self): """ Test send_notifications with grouping enabled. @@ -292,9 +295,9 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) @ddt.data( - (settings.NOTIFICATION_CREATION_BATCH_SIZE, 10, 4), - (settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 12, 7), - (settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 10, 4), + (settings.NOTIFICATION_CREATION_BATCH_SIZE, 13, 6), + (settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 15, 9), + (settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 13, 5), ) @ddt.unpack def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, notifications_query_count): @@ -323,6 +326,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): for preference in preferences: discussion_config = preference.notification_preference_config['discussion'] discussion_config['notification_types'][notification_type]['web'] = True + discussion_config['notification_types'][notification_type]['push'] = True preference.save() # Creating notifications and asserting query count @@ -344,7 +348,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): "username": "Test Author" } with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): - with self.assertNumQueries(10): + with self.assertNumQueries(13): send_notifications(user_ids, str(self.course.id), notification_app, notification_type, context, "http://test.url") @@ -363,9 +367,10 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): "replier_name": "Replier Name" } with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): - with self.assertNumQueries(12): - send_notifications(user_ids, str(self.course.id), notification_app, notification_type, - context, "http://test.url") + with override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True): + with self.assertNumQueries(15): + 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): """ @@ -377,6 +382,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): CourseNotificationPreference.objects.filter(user_id=user_id, course_id=self.course.id).delete() @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) @ddt.data( ("new_response", True, True, 2), ("new_response", False, False, 2), diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ce3567fceb..075aedce9f 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -409,7 +409,7 @@ drf-yasg==1.21.10 # via # django-user-tasks # edx-api-doc-tools -edx-ace==1.14.0 +edx-ace==1.15.0 # via -r requirements/edx/kernel.in edx-api-doc-tools==2.1.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 66201eb877..e35e36089e 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -669,7 +669,7 @@ drf-yasg==1.21.10 # -r requirements/edx/testing.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.14.0 +edx-ace==1.15.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 675592a536..9c8ee794ca 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -491,7 +491,7 @@ drf-yasg==1.21.10 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.14.0 +edx-ace==1.15.0 # via -r requirements/edx/base.txt edx-api-doc-tools==2.1.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 0bbbe3f301..8f7f29df17 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -516,7 +516,7 @@ drf-yasg==1.21.10 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.14.0 +edx-ace==1.15.0 # via -r requirements/edx/base.txt edx-api-doc-tools==2.1.0 # via