feat: send mobile push notifications (#36272)

* feat: send mobile braze notifications

* fix: fixed pylint issues

* feat: Added push_notification flag and preferences

* fix: fixed pylint issues

* fix: Moved braze logic to edx-ace

* fix: Un delete admin file

* fix: Added review suggestions

* fix: Added review suggestions

* fix: updated migration file

* fix: Removed all braze references from changes

* fix: fixed test cases

* fix: removed braze metnion in code

* fix: fixed migration file issue

* fix: Added review suggestions

* fix: bumped edx-ace version
This commit is contained in:
jawad khan
2025-06-30 10:48:48 +05:00
committed by GitHub
parent 8fac3bc060
commit e101298fed
16 changed files with 199 additions and 24 deletions

View File

@@ -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__)

View File

@@ -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),
),
]

View File

@@ -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="")

View File

@@ -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
"""

View File

@@ -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)

View File

@@ -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()

View File

@@ -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):

View File

@@ -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),

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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