From 4e55d72e751e13d6f26817ba4ce938f3774b02cc Mon Sep 17 00:00:00 2001 From: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com> Date: Tue, 20 May 2025 17:15:26 +0500 Subject: [PATCH] feat: added immediate email notifications (#36749) --- .../djangoapps/notifications/email/tasks.py | 48 +++++++++++++++++ .../notifications/email/tests/test_tasks.py | 53 ++++++++++++++++++- .../djangoapps/notifications/email/utils.py | 15 ++++++ .../core/djangoapps/notifications/models.py | 17 ++++++ .../core/djangoapps/notifications/tasks.py | 14 ++++- .../notifications/digest_footer.html | 2 +- .../edx_ace/immediate_email/email/body.html | 20 +++++++ .../edx_ace/immediate_email/email/body.txt | 1 + .../immediate_email/email/from_name.txt | 1 + .../edx_ace/immediate_email/email/head.html | 3 ++ .../edx_ace/immediate_email/email/subject.txt | 1 + .../immediate_email_content.html | 28 ++++++++++ 12 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.html create mode 100644 openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.txt create mode 100644 openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/from_name.txt create mode 100644 openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/head.html create mode 100644 openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/subject.txt create mode 100644 openedx/core/djangoapps/notifications/templates/notifications/immediate_email_content.html diff --git a/openedx/core/djangoapps/notifications/email/tasks.py b/openedx/core/djangoapps/notifications/email/tasks.py index 25f993fe7c..c62348489c 100644 --- a/openedx/core/djangoapps/notifications/email/tasks.py +++ b/openedx/core/djangoapps/notifications/email/tasks.py @@ -1,6 +1,7 @@ """ Celery tasks for sending email notifications """ +from bs4 import BeautifulSoup from celery import shared_task from celery.utils.log import get_task_logger from django.contrib.auth import get_user_model @@ -20,8 +21,11 @@ from .utils import ( add_headers_to_email_message, create_app_notifications_dict, create_email_digest_context, + create_email_template_context, filter_notification_with_email_enabled_preferences, + get_course_info, get_start_end_date, + get_text_for_notification_type, get_unique_course_ids, is_email_notification_flag_enabled ) @@ -105,6 +109,7 @@ def send_digest_email_to_user(user, cadence_type, start_date, end_date, course_l app_label="notifications", name="email_digest" ).personalize(recipient, course_language, message_context) message = add_headers_to_email_message(message, message_context) + message.options['skip_disable_user_policy'] = True ace.send(message) send_user_email_digest_sent_event(user, cadence_type, notifications, message_context) logger.info(f' Email sent to {user.username} ==Temp Log==') @@ -123,3 +128,46 @@ def send_digest_email_to_all_users(cadence_type): logger.info(f' Email Cadence Audience {len(users)}') for user in users: send_digest_email_to_user(user, cadence_type, start_date, end_date, courses_data=courses_data) + + +def send_immediate_cadence_email(email_notification_mapping, course_key): + """ + Send immediate cadence email to users + Parameters: + email_notification_mapping: Dictionary of user_id and Notification object + course_key: Course key for which the email is sent + """ + if not email_notification_mapping: + return + user_list = email_notification_mapping.keys() + users = User.objects.filter(id__in=user_list) + course_name = get_course_info(course_key).get("name", course_key) + for user in users.iterator(chunk_size=100): + if not user.has_usable_password(): + logger.info(f' User is disabled {user.username}') + continue + if not is_email_notification_flag_enabled(user): + logger.info(f' Flag disabled for {user.username}') + continue + notification = email_notification_mapping.get(user.id, None) + if not notification: + logger.info(f' No notification for {user.username}') + continue + soup = BeautifulSoup(notification.content, "html.parser") + title = "New Course Update" if notification.notification_type == "course_updates" else soup.get_text() + message_context = create_email_template_context(user.username) + message_context.update({ + "course_id": course_key, + "course_name": course_name, + "content_url": notification.content_url, + "content_title": title, + "footer_email_reason": "You are receiving this email because you are enrolled in the edX course " + f"{course_name}", + "content": notification.content_context.get("email_content", notification.content), + "view_text": get_text_for_notification_type(notification.notification_type), + }) + message = EmailNotificationMessageType( + app_label="notifications", name="immediate_email" + ).personalize(Recipient(user.id, user.email), 'en', message_context) + message = add_headers_to_email_message(message, message_context) + ace.send(message) diff --git a/openedx/core/djangoapps/notifications/email/tests/test_tasks.py b/openedx/core/djangoapps/notifications/email/tests/test_tasks.py index bb7e3c0dda..b24213233c 100644 --- a/openedx/core/djangoapps/notifications/email/tests/test_tasks.py +++ b/openedx/core/djangoapps/notifications/email/tests/test_tasks.py @@ -9,7 +9,8 @@ from unittest.mock import patch from edx_toggles.toggles.testutils import override_waffle_flag from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_EMAIL_NOTIFICATIONS +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_EMAIL_NOTIFICATIONS +from openedx.core.djangoapps.notifications.tasks import send_notifications from openedx.core.djangoapps.notifications.email_notifications import EmailCadence from openedx.core.djangoapps.notifications.email.tasks import ( get_audience_for_cadence_email, @@ -258,3 +259,53 @@ class TestPreferences(ModuleStoreTestCase): with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) assert not mock_func.called + + +class TestImmediateEmail(ModuleStoreTestCase): + """ + Tests immediate email + """ + + def setUp(self): + """ + Setup + """ + super().setUp() + self.user = UserFactory() + self.course = CourseFactory.create(display_name='test course', run="Testing_course") + + @patch('edx_ace.ace.send') + def test_email_sent_when_cadence_is_immediate(self, mock_func): + """ + Tests email is sent when cadence is immediate + """ + preference = CourseNotificationPreference.objects.create(user=self.user, course_id=self.course.id) + app_prefs = preference.notification_preference_config['discussion']['notification_types'] + app_prefs['new_discussion_post']['email'] = True + app_prefs['new_discussion_post']['email_cadence'] = EmailCadence.IMMEDIATELY + preference.save() + context = { + 'username': 'User', + 'post_title': 'title' + } + with override_waffle_flag(ENABLE_NOTIFICATIONS, True): + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_notifications([self.user.id], str(self.course.id), 'discussion', + 'new_discussion_post', context, 'http://test.url') + assert mock_func.call_count == 1 + + @patch('edx_ace.ace.send') + def test_email_not_sent_when_cadence_is_not_immediate(self, mock_func): + """ + Tests email is not sent when cadence is not immediate + """ + CourseNotificationPreference.objects.create(user=self.user, course_id=self.course.id) + context = { + 'replier_name': 'User', + 'post_title': 'title' + } + with override_waffle_flag(ENABLE_NOTIFICATIONS, True): + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_notifications([self.user.id], str(self.course.id), 'discussion', + 'new_response', context, 'http://test.url') + assert mock_func.call_count == 0 diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py index 79535dbc21..c606201568 100644 --- a/openedx/core/djangoapps/notifications/email/utils.py +++ b/openedx/core/djangoapps/notifications/email/utils.py @@ -431,3 +431,18 @@ def is_notification_type_channel_editable(app_name, notification_type, channel): if notification_type == 'core': return channel not in COURSE_NOTIFICATION_APPS[app_name]['non_editable'] return channel not in COURSE_NOTIFICATION_TYPES[notification_type]['non_editable'] + + +def get_text_for_notification_type(notification_type): + """ + Returns text for notification type + """ + app_name = COURSE_NOTIFICATION_APPS.get(notification_type, {}).get('notification_app') + if not app_name: + return "" + mapping = { + 'discussion': 'post', + 'updates': 'update', + 'grading': 'assessment', + } + return mapping.get(app_name, "") diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index e4175130d1..d99f49fa94 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -15,6 +15,7 @@ from openedx.core.djangoapps.notifications.base_notification import ( get_notification_content ) from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY +from openedx.core.djangoapps.notifications.email_notifications import EmailCadence from openedx.core.djangoapps.user_api.models import UserPreference User = get_user_model() @@ -303,3 +304,19 @@ class CourseNotificationPreference(TimeStampedModel): } """ return self.get_notification_types(app_name).get('core', {}) + + def is_email_enabled_for_notification_type(self, app_name, notification_type) -> bool: + """ + Returns True if the email is enabled for the given app name and notification type. + """ + if self.is_core(app_name, notification_type): + return self.get_core_config(app_name).get('email', False) + return self.get_notification_type_config(app_name, notification_type).get('email', False) + + def get_email_cadence_for_notification_type(self, app_name, notification_type) -> str: + """ + Returns the email cadence for the given app name and notification type. + """ + if self.is_core(app_name, notification_type): + return self.get_core_config(app_name).get('email_cadence', EmailCadence.NEVER) + return self.get_notification_type_config(app_name, notification_type).get('email_cadence', EmailCadence.NEVER) diff --git a/openedx/core/djangoapps/notifications/tasks.py b/openedx/core/djangoapps/notifications/tasks.py index 7c3e991e82..fbb6a871a1 100644 --- a/openedx/core/djangoapps/notifications/tasks.py +++ b/openedx/core/djangoapps/notifications/tasks.py @@ -20,6 +20,8 @@ from openedx.core.djangoapps.notifications.base_notification import ( 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.events import notification_generated_event from openedx.core.djangoapps.notifications.grouping_notifications import ( get_user_existing_notifications, @@ -114,6 +116,7 @@ def delete_expired_notifications(): logger.info(f'{total_deleted} Notifications deleted in {time_elapsed} seconds.') +# pylint: disable=too-many-statements @shared_task @set_code_owner_attribute def send_notifications(user_ids, course_key: str, app_name, notification_type, context, content_url): @@ -138,6 +141,7 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c 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 = {} if group_by_id and not grouping_enabled: logger.info( @@ -179,6 +183,8 @@ 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) + 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) new_notification = Notification( user_id=user_id, app_name=app_name, @@ -187,9 +193,12 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c content_url=content_url, course_id=course_key, web='web' in notification_preferences, - email='email' in notification_preferences, + email=email_enabled, group_by_id=group_by_id, ) + if email_enabled and (email_cadence == EmailCadence.IMMEDIATELY): + email_notification_mapping[user_id] = new_notification + if grouping_enabled and existing_notifications.get(user_id, None): group_user_notifications(new_notification, existing_notifications[user_id]) if not notifications_generated: @@ -205,6 +214,9 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c notifications_generated = True notification_content = notification_objects[0].content + if email_notification_mapping: + send_immediate_cadence_email(email_notification_mapping, course_key) + if notifications_generated: notification_generated_event( generated_notification_audience, app_name, notification_type, course_key, content_url, diff --git a/openedx/core/djangoapps/notifications/templates/notifications/digest_footer.html b/openedx/core/djangoapps/notifications/templates/notifications/digest_footer.html index 34f4bf09d8..d47906786b 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/digest_footer.html +++ b/openedx/core/djangoapps/notifications/templates/notifications/digest_footer.html @@ -31,7 +31,7 @@

- You are receiving this email because you have subscribed to email digest + {{ footer_email_reason|default:"You are receiving this email because you have subscribed to email digest" }}

diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.html b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.html new file mode 100644 index 0000000000..c500109bec --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.html @@ -0,0 +1,20 @@ + + + + +

diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.txt b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.txt new file mode 100644 index 0000000000..79b3b245f2 --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.txt @@ -0,0 +1 @@ +{{ content_title | safe }} diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/from_name.txt b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/from_name.txt new file mode 100644 index 0000000000..dcbc23c004 --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/from_name.txt @@ -0,0 +1 @@ +{{ platform_name }} diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/head.html b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/head.html new file mode 100644 index 0000000000..8d63916b7a --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/head.html @@ -0,0 +1,3 @@ + +{{ platform_name }} + diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/subject.txt b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/subject.txt new file mode 100644 index 0000000000..79b3b245f2 --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/subject.txt @@ -0,0 +1 @@ +{{ content_title | safe }} diff --git a/openedx/core/djangoapps/notifications/templates/notifications/immediate_email_content.html b/openedx/core/djangoapps/notifications/templates/notifications/immediate_email_content.html new file mode 100644 index 0000000000..7608b352ca --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/immediate_email_content.html @@ -0,0 +1,28 @@ +
+ + Logo + + + Unsubscribe + +
+
+
+

+ {{ course_name }} +

+

+ {{ content_title | safe }} +

+
+
+
{{ content | safe }}
+
+
+

+ + View {{ view_text|default:""}} + +

+
+