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:
@@ -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__)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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="")
|
||||
|
||||
10
openedx/core/djangoapps/notifications/push/message_type.py
Normal file
10
openedx/core/djangoapps/notifications/push/message_type.py
Normal 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
|
||||
"""
|
||||
45
openedx/core/djangoapps/notifications/push/tasks.py
Normal file
45
openedx/core/djangoapps/notifications/push/tasks.py
Normal 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)
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user