feat: added immediate email notifications (#36749)
This commit is contained in:
committed by
GitHub
parent
bfdba3c914
commit
4e55d72e75
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Celery tasks for sending email notifications
|
Celery tasks for sending email notifications
|
||||||
"""
|
"""
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@@ -20,8 +21,11 @@ from .utils import (
|
|||||||
add_headers_to_email_message,
|
add_headers_to_email_message,
|
||||||
create_app_notifications_dict,
|
create_app_notifications_dict,
|
||||||
create_email_digest_context,
|
create_email_digest_context,
|
||||||
|
create_email_template_context,
|
||||||
filter_notification_with_email_enabled_preferences,
|
filter_notification_with_email_enabled_preferences,
|
||||||
|
get_course_info,
|
||||||
get_start_end_date,
|
get_start_end_date,
|
||||||
|
get_text_for_notification_type,
|
||||||
get_unique_course_ids,
|
get_unique_course_ids,
|
||||||
is_email_notification_flag_enabled
|
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"
|
app_label="notifications", name="email_digest"
|
||||||
).personalize(recipient, course_language, message_context)
|
).personalize(recipient, course_language, message_context)
|
||||||
message = add_headers_to_email_message(message, message_context)
|
message = add_headers_to_email_message(message, message_context)
|
||||||
|
message.options['skip_disable_user_policy'] = True
|
||||||
ace.send(message)
|
ace.send(message)
|
||||||
send_user_email_digest_sent_event(user, cadence_type, notifications, message_context)
|
send_user_email_digest_sent_event(user, cadence_type, notifications, message_context)
|
||||||
logger.info(f'<Email Cadence> Email sent to {user.username} ==Temp Log==')
|
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)}')
|
logger.info(f'<Email Cadence> Email Cadence Audience {len(users)}')
|
||||||
for user in users:
|
for user in users:
|
||||||
send_digest_email_to_user(user, cadence_type, start_date, end_date, courses_data=courses_data)
|
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)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from unittest.mock import patch
|
|||||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||||
|
|
||||||
from common.djangoapps.student.tests.factories import UserFactory
|
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_notifications import EmailCadence
|
||||||
from openedx.core.djangoapps.notifications.email.tasks import (
|
from openedx.core.djangoapps.notifications.email.tasks import (
|
||||||
get_audience_for_cadence_email,
|
get_audience_for_cadence_email,
|
||||||
@@ -258,3 +259,53 @@ class TestPreferences(ModuleStoreTestCase):
|
|||||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
||||||
send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date)
|
send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date)
|
||||||
assert not mock_func.called
|
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
|
||||||
|
|||||||
@@ -431,3 +431,18 @@ def is_notification_type_channel_editable(app_name, notification_type, channel):
|
|||||||
if notification_type == 'core':
|
if notification_type == 'core':
|
||||||
return channel not in COURSE_NOTIFICATION_APPS[app_name]['non_editable']
|
return channel not in COURSE_NOTIFICATION_APPS[app_name]['non_editable']
|
||||||
return channel not in COURSE_NOTIFICATION_TYPES[notification_type]['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, "")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from openedx.core.djangoapps.notifications.base_notification import (
|
|||||||
get_notification_content
|
get_notification_content
|
||||||
)
|
)
|
||||||
from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY
|
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
|
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -303,3 +304,19 @@ class CourseNotificationPreference(TimeStampedModel):
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
return self.get_notification_types(app_name).get('core', {})
|
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)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from openedx.core.djangoapps.notifications.base_notification import (
|
|||||||
get_notification_content
|
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
|
||||||
|
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.events import notification_generated_event
|
||||||
from openedx.core.djangoapps.notifications.grouping_notifications import (
|
from openedx.core.djangoapps.notifications.grouping_notifications import (
|
||||||
get_user_existing_notifications,
|
get_user_existing_notifications,
|
||||||
@@ -114,6 +116,7 @@ def delete_expired_notifications():
|
|||||||
logger.info(f'{total_deleted} Notifications deleted in {time_elapsed} seconds.')
|
logger.info(f'{total_deleted} Notifications deleted in {time_elapsed} seconds.')
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-many-statements
|
||||||
@shared_task
|
@shared_task
|
||||||
@set_code_owner_attribute
|
@set_code_owner_attribute
|
||||||
def send_notifications(user_ids, course_key: str, app_name, notification_type, context, content_url):
|
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)
|
sender_id = context.pop('sender_id', None)
|
||||||
default_web_config = get_default_values_of_preference(app_name, notification_type).get('web', False)
|
default_web_config = get_default_values_of_preference(app_name, notification_type).get('web', False)
|
||||||
generated_notification_audience = []
|
generated_notification_audience = []
|
||||||
|
email_notification_mapping = {}
|
||||||
|
|
||||||
if group_by_id and not grouping_enabled:
|
if group_by_id and not grouping_enabled:
|
||||||
logger.info(
|
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)
|
preference.get_app_config(app_name).get('enabled', False)
|
||||||
):
|
):
|
||||||
notification_preferences = preference.get_channels_for_notification_type(app_name, notification_type)
|
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(
|
new_notification = Notification(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
app_name=app_name,
|
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,
|
content_url=content_url,
|
||||||
course_id=course_key,
|
course_id=course_key,
|
||||||
web='web' in notification_preferences,
|
web='web' in notification_preferences,
|
||||||
email='email' in notification_preferences,
|
email=email_enabled,
|
||||||
group_by_id=group_by_id,
|
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):
|
if grouping_enabled and existing_notifications.get(user_id, None):
|
||||||
group_user_notifications(new_notification, existing_notifications[user_id])
|
group_user_notifications(new_notification, existing_notifications[user_id])
|
||||||
if not notifications_generated:
|
if not notifications_generated:
|
||||||
@@ -205,6 +214,9 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
|
|||||||
notifications_generated = True
|
notifications_generated = True
|
||||||
notification_content = notification_objects[0].content
|
notification_content = notification_objects[0].content
|
||||||
|
|
||||||
|
if email_notification_mapping:
|
||||||
|
send_immediate_cadence_email(email_notification_mapping, course_key)
|
||||||
|
|
||||||
if notifications_generated:
|
if notifications_generated:
|
||||||
notification_generated_event(
|
notification_generated_event(
|
||||||
generated_notification_audience, app_name, notification_type, course_key, content_url,
|
generated_notification_audience, app_name, notification_type, course_key, content_url,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<p style="margin: 1.5rem 0 0 0;">
|
<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>
|
||||||
<p style="margin: 0.625rem 0">
|
<p style="margin: 0.625rem 0">
|
||||||
<a href="{{notification_settings_url}}" rel="noopener noreferrer" target="_blank" style="color: black">
|
<a href="{{notification_settings_url}}" rel="noopener noreferrer" target="_blank" style="color: black">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{ content_title | safe }}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{ platform_name }}
|
||||||
@@ -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"/>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{ content_title | safe }}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user