feat: added immediate email notifications (#36749)

This commit is contained in:
Muhammad Adeel Tajamul
2025-05-20 17:15:26 +05:00
committed by GitHub
parent bfdba3c914
commit 4e55d72e75
12 changed files with 200 additions and 3 deletions

View File

@@ -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 Cadence> 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> 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'<Immediate Email> User is disabled {user.username}')
continue
if not is_email_notification_flag_enabled(user):
logger.info(f'<Immediate Email> Flag disabled for {user.username}')
continue
notification = email_notification_mapping.get(user.id, None)
if not notification:
logger.info(f'<Immediate Email> 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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@
<tr>
<td>
<p style="margin: 1.5rem 0 0 0;">
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" }}
</p>
<p style="margin: 0.625rem 0">
<a href="{{notification_settings_url}}" rel="noopener noreferrer" target="_blank" style="color: black">

View File

@@ -0,0 +1,20 @@
<head>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap'" rel="stylesheet" type="text/css">
</head>
<div style="margin:0; padding:0; min-width: 100%; background-color:#C9C9C9; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-smooth: never;">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color:#FFFFFF; line-height:1.5; max-width:600px; font-family:Inter, Arial, Verdana, sans-serif">
<tbody>
<tr>
<td style="padding: 1rem;">
{% include 'notifications/immediate_email_content.html' %}
</td>
</tr>
<tr>
<td style="padding: 1rem 2.5rem; background-color: #F2F0EF">
{% include 'notifications/digest_footer.html' %}
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1 @@
{{ content_title | safe }}

View File

@@ -0,0 +1,3 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title lang="en">{{ platform_name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>

View File

@@ -0,0 +1,28 @@
<blockquote style="margin: 0; height: 64px; padding: 24px 24px 8px">
<span style="float: left">
<img src="{{ logo_url }}" style="width: auto;" height="40" alt="Logo"/>
</span>
<span style="float: right">
<a href="{{ unsubscribe_url }}" style="color: #00262B; text-decoration: none;">Unsubscribe</a>
</span>
</blockquote>
<blockquote style="margin: 0; padding: 24px;">
<blockquote style="margin: 0;">
<p style="margin: 0 0 10px; font-size: 14px; font-weight: 500; line-height: 24px; color: #707070">
{{ course_name }}
</p>
<h3 style="margin: 0; font-size: 22px; font-weight: 700; line-height: 28px; color: #454545">
{{ content_title | safe }}
</h3>
</blockquote>
<blockquote style="margin: 24px 0">
<blockquote style="margin: 0; padding-left: 24px; border-left: 2px solid #E1DDDB"> {{ content | safe }}</blockquote>
</blockquote>
<blockquote style="margin: 0; padding: 10px 0">
<p style="margin: 0; padding: 10px 16px; background-color: #D63328; width: fit-content;">
<a href="{{ content_url }}" style="font-weight: 500; font-size: 18px; line-height: 24px; color: #FFFFFF; text-decoration: none">
View {{ view_text|default:""}}
</a>
</p>
</blockquote>
</blockquote>