Skip to content

feat: send mobile braze notifications LEARNER-10298 #36272

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions openedx/core/djangoapps/notifications/config/waffle.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,12 @@
# .. toggle_warning: When the flag is ON, Notifications Grouping feature is enabled.
# .. toggle_tickets: INF-1472
ENABLE_NOTIFICATION_GROUPING = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_notification_grouping', __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: open_edx
# .. toggle_creation_date: 2025-05-13
# .. toggle_warning: When the flag is ON, Notifications will go through using braze on mobile devices.
ENABLE_PUSH_NOTIFICATIONS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_push_notifications', __name__)
Original file line number Diff line number Diff line change
@@ -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', '0006_notification_group_by_id'),
]

operations = [
migrations.AddField(
model_name='notification',
name='push',
field=models.BooleanField(default=False),
),
]
1 change: 1 addition & 0 deletions openedx/core/djangoapps/notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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=42, db_index=True, null=False, default="")
Expand Down
Empty file.
10 changes: 10 additions & 0 deletions openedx/core/djangoapps/notifications/push/message_type.py
Original file line number Diff line number Diff line change
@@ -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
"""
53 changes: 53 additions & 0 deletions openedx/core/djangoapps/notifications/push/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
""" 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 edx_ace.recipient import Recipient

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
Comment on lines +23 to +24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain this with a comment.


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}
try:
sender = User.objects.get(id=sender_id)
# We don't need recipient since all recipients are in emails dict.
# But PushNotificationMessageType.personalize method requires us a recipient object.
# I have added sender here in case we might need it in future with the context.
recipient = Recipient(sender.id, sender.email)
except User.DoesNotExist:
recipient = None

message = PushNotificationMessageType(
app_label="notifications", name="push"
).personalize(recipient, 'en', context)
message.options['emails'] = emails
message.options['braze_campaign'] = notification_type
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is better to add

message.options['skip_disable_user_policy'] = True

See PR

message.options['skip_disable_user_policy'] = True

ace.send(message, limit_to_channels=getattr(settings, 'ACE_PUSH_CHANNELS', []))
logger.info('Sent mobile notification for %s to ace push channel.', notification_type)
Empty file.
73 changes: 73 additions & 0 deletions openedx/core/djangoapps/notifications/push/tests/test_tasks.py
Original file line number Diff line number Diff line change
@@ -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['braze_campaign'] == '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()
37 changes: 24 additions & 13 deletions openedx/core/djangoapps/notifications/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,25 @@
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.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


logger = get_task_logger(__name__)


Expand Down Expand Up @@ -120,6 +125,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
Expand All @@ -133,11 +139,12 @@ 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 = []
push_notification_audience = []
is_push_notification_enabled = ENABLE_PUSH_NOTIFICATIONS.is_enabled(course_key)

if group_by_id and not grouping_enabled:
logger.info(
Expand Down Expand Up @@ -179,6 +186,7 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
preference.get_app_config(app_name).get('enabled', False)
):
notification_preferences = preference.get_channels_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,
Expand All @@ -188,28 +196,31 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
course_id=course_key,
web='web' in notification_preferences,
email='email' in notification_preferences,
push=push_notification,
group_by_id=group_by_id,
)
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 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):
Expand Down
22 changes: 14 additions & 8 deletions openedx/core/djangoapps/notifications/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
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,
Expand Down Expand Up @@ -116,6 +116,7 @@ def setUp(self):
)

@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
Expand Down Expand Up @@ -168,6 +169,7 @@ def test_enable_notification_flag(self, flag_value):
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
Expand All @@ -192,6 +194,7 @@ def test_notification_not_send_with_preference_disabled(self):

@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.
Expand Down Expand Up @@ -292,9 +295,9 @@ def _create_users(self, num_of_users):

@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):
Expand Down Expand Up @@ -323,6 +326,7 @@ def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, n
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
Expand All @@ -344,7 +348,7 @@ def test_preference_not_created_for_default_off_preference(self):
"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")

Expand All @@ -363,9 +367,10 @@ def test_preference_created_for_default_on_preference(self):
"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(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):
"""
Expand All @@ -377,6 +382,7 @@ def _update_user_preference(self, user_id, pref_exists):
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),
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ drf-yasg==1.21.10
# via
# django-user-tasks
# edx-api-doc-tools
edx-ace==1.11.4
edx-ace==1.14.0
# via -r requirements/edx/kernel.in
edx-api-doc-tools==2.0.0
# via
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ drf-yasg==1.21.10
# -r requirements/edx/testing.txt
# django-user-tasks
# edx-api-doc-tools
edx-ace==1.11.4
edx-ace==1.14.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ drf-yasg==1.21.10
# -r requirements/edx/base.txt
# django-user-tasks
# edx-api-doc-tools
edx-ace==1.11.4
edx-ace==1.14.0
# via -r requirements/edx/base.txt
edx-api-doc-tools==2.0.0
# via
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ drf-yasg==1.21.10
# -r requirements/edx/base.txt
# django-user-tasks
# edx-api-doc-tools
edx-ace==1.11.4
edx-ace==1.14.0
# via -r requirements/edx/base.txt
edx-api-doc-tools==2.0.0
# via
Expand Down
Loading