feat: added daily and weekly email digest (#34539)
* feat: added daily and weekly email digest
This commit is contained in:
committed by
GitHub
parent
96f210559b
commit
a454da9ca6
@@ -3,7 +3,7 @@ This module contains various configuration settings via
|
||||
waffle switches for the notifications app.
|
||||
"""
|
||||
|
||||
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
|
||||
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlag
|
||||
|
||||
WAFFLE_NAMESPACE = 'notifications'
|
||||
|
||||
@@ -48,7 +48,6 @@ ENABLE_NOTIFICATIONS_FILTERS = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_noti
|
||||
# .. toggle_tickets: INF-1145
|
||||
ENABLE_COURSEWIDE_NOTIFICATIONS = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_coursewide_notifications", __name__)
|
||||
|
||||
|
||||
# .. toggle_name: notifications.enable_ora_staff_notifications
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
@@ -58,3 +57,14 @@ ENABLE_COURSEWIDE_NOTIFICATIONS = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_c
|
||||
# .. toggle_target_removal_date: 2024-06-04
|
||||
# .. toggle_tickets: INF-1304
|
||||
ENABLE_ORA_STAFF_NOTIFICATION = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_ora_staff_notifications", __name__)
|
||||
|
||||
# .. toggle_name: notifications.enable_email_notifications
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Waffle flag to enable the Email Notifications feature
|
||||
# .. toggle_use_cases: temporary, open_edx
|
||||
# .. toggle_creation_date: 2024-03-25
|
||||
# .. toggle_target_removal_date: 2025-06-01
|
||||
# .. toggle_warning: When the flag is ON, Email Notifications feature is enabled.
|
||||
# .. toggle_tickets: INF-1259
|
||||
ENABLE_EMAIL_NOTIFICATIONS = WaffleFlag(f'{WAFFLE_NAMESPACE}.enable_email_notifications', __name__)
|
||||
|
||||
116
openedx/core/djangoapps/notifications/email/tasks.py
Normal file
116
openedx/core/djangoapps/notifications/email/tasks.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Celery tasks for sending email notifications
|
||||
"""
|
||||
from celery import shared_task
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.contrib.auth import get_user_model
|
||||
from edx_ace import ace
|
||||
from edx_ace.recipient import Recipient
|
||||
from edx_django_utils.monitoring import set_code_owner_attribute
|
||||
|
||||
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
|
||||
from openedx.core.djangoapps.notifications.models import (
|
||||
CourseNotificationPreference,
|
||||
Notification,
|
||||
get_course_notification_preference_config_version
|
||||
)
|
||||
from .message_type import EmailNotificationMessageType
|
||||
from .utils import (
|
||||
create_app_notifications_dict,
|
||||
create_email_digest_context,
|
||||
filter_notification_with_email_enabled_preferences,
|
||||
get_start_end_date,
|
||||
get_unique_course_ids,
|
||||
is_email_notification_flag_enabled
|
||||
)
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def get_audience_for_cadence_email(cadence_type):
|
||||
"""
|
||||
Returns users that are eligible to receive cadence email
|
||||
"""
|
||||
if cadence_type not in [EmailCadence.DAILY, EmailCadence.WEEKLY]:
|
||||
raise ValueError("Invalid value for parameter cadence_type")
|
||||
start_date, end_date = get_start_end_date(cadence_type)
|
||||
user_ids = Notification.objects.filter(
|
||||
email=True,
|
||||
created__gte=start_date,
|
||||
created__lte=end_date
|
||||
).values_list('user__id', flat=True).distinct()
|
||||
users = User.objects.filter(id__in=user_ids)
|
||||
return users
|
||||
|
||||
|
||||
def get_user_preferences_for_courses(course_ids, user):
|
||||
"""
|
||||
Returns updated user preference for course_ids
|
||||
"""
|
||||
# Create new preferences
|
||||
new_preferences = []
|
||||
preferences = CourseNotificationPreference.objects.filter(user=user, course_id__in=course_ids)
|
||||
preferences = list(preferences)
|
||||
for course_id in course_ids:
|
||||
if not any(preference.course_id == course_id for preference in preferences):
|
||||
pref = CourseNotificationPreference(user=user, course_id=course_id)
|
||||
new_preferences.append(pref)
|
||||
if new_preferences:
|
||||
CourseNotificationPreference.objects.bulk_create(new_preferences, ignore_conflicts=True)
|
||||
# Update preferences to latest config version
|
||||
current_version = get_course_notification_preference_config_version()
|
||||
for preference in preferences:
|
||||
if preference.config_version != current_version:
|
||||
preference = preference.get_user_course_preference(user.id, preference.course_id)
|
||||
new_preferences.append(preference)
|
||||
return new_preferences
|
||||
|
||||
|
||||
def send_digest_email_to_user(user, cadence_type, course_language='en', courses_data=None):
|
||||
"""
|
||||
Send [cadence_type] email to user.
|
||||
Cadence Type can be EmailCadence.DAILY or EmailCadence.WEEKLY
|
||||
"""
|
||||
if cadence_type not in [EmailCadence.DAILY, EmailCadence.WEEKLY]:
|
||||
raise ValueError('Invalid cadence_type')
|
||||
logger.info(f'<Email Cadence> Sending email to user {user.username} ==Temp Log==')
|
||||
if not is_email_notification_flag_enabled(user):
|
||||
logger.info(f'<Email Cadence> Flag disabled for {user.username} ==Temp Log==')
|
||||
return
|
||||
start_date, end_date = get_start_end_date(cadence_type)
|
||||
notifications = Notification.objects.filter(user=user, email=True,
|
||||
created__gte=start_date, created__lte=end_date)
|
||||
if not notifications:
|
||||
logger.info(f'<Email Cadence> No notification for {user.username} ==Temp Log==')
|
||||
return
|
||||
course_ids = get_unique_course_ids(notifications)
|
||||
preferences = get_user_preferences_for_courses(course_ids, user)
|
||||
notifications = filter_notification_with_email_enabled_preferences(notifications, preferences, cadence_type)
|
||||
if not notifications:
|
||||
logger.info(f'<Email Cadence> No filtered notification for {user.username} ==Temp Log==')
|
||||
return
|
||||
apps_dict = create_app_notifications_dict(notifications)
|
||||
message_context = create_email_digest_context(apps_dict, start_date, end_date, cadence_type,
|
||||
courses_data=courses_data)
|
||||
recipient = Recipient(user.id, user.email)
|
||||
message = EmailNotificationMessageType(
|
||||
app_label="notifications", name="email_digest"
|
||||
).personalize(recipient, course_language, message_context)
|
||||
ace.send(message)
|
||||
logger.info(f'<Email Cadence> Email sent to {user.username} ==Temp Log==')
|
||||
|
||||
|
||||
@shared_task(ignore_result=True)
|
||||
@set_code_owner_attribute
|
||||
def send_digest_email_to_all_users(cadence_type):
|
||||
"""
|
||||
Send email digest to all eligible users
|
||||
"""
|
||||
logger.info(f'<Email Cadence> Sending cadence email of type {cadence_type}')
|
||||
users = get_audience_for_cadence_email(cadence_type)
|
||||
courses_data = {}
|
||||
logger.info(f'<Email Cadence> Email Cadence Audience {len(users)}')
|
||||
for user in users:
|
||||
send_digest_email_to_user(user, cadence_type, courses_data=courses_data)
|
||||
195
openedx/core/djangoapps/notifications/email/tests/test_tasks.py
Normal file
195
openedx/core/djangoapps/notifications/email/tests/test_tasks.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Test cases for notifications/email/tasks.py
|
||||
"""
|
||||
import datetime
|
||||
import ddt
|
||||
|
||||
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.email_notifications import EmailCadence
|
||||
from openedx.core.djangoapps.notifications.email.tasks import (
|
||||
get_audience_for_cadence_email,
|
||||
send_digest_email_to_all_users,
|
||||
send_digest_email_to_user
|
||||
)
|
||||
from openedx.core.djangoapps.notifications.models import CourseNotificationPreference
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from .utils import create_notification
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestEmailDigestForUser(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests email notification for a specific user
|
||||
"""
|
||||
|
||||
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_is_not_sent_if_no_notifications(self, mock_func):
|
||||
"""
|
||||
Tests email is sent iff waffle flag is enabled
|
||||
"""
|
||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
||||
send_digest_email_to_user(self.user, EmailCadence.DAILY)
|
||||
assert not mock_func.called
|
||||
|
||||
@ddt.data(True, False)
|
||||
@patch('edx_ace.ace.send')
|
||||
def test_email_is_sent_iff_flag_enabled(self, flag_value, mock_func):
|
||||
"""
|
||||
Tests email is sent iff waffle flag is enabled
|
||||
"""
|
||||
created_date = datetime.datetime.now() - datetime.timedelta(days=1)
|
||||
create_notification(self.user, self.course.id, created=created_date)
|
||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, flag_value):
|
||||
send_digest_email_to_user(self.user, EmailCadence.DAILY)
|
||||
assert mock_func.called is flag_value
|
||||
|
||||
@patch('edx_ace.ace.send')
|
||||
def test_notification_not_send_if_created_on_next_day(self, mock_func):
|
||||
"""
|
||||
Tests email is not sent if notification is created on next day
|
||||
"""
|
||||
create_notification(self.user, self.course.id)
|
||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
||||
send_digest_email_to_user(self.user, EmailCadence.DAILY)
|
||||
assert not mock_func.called
|
||||
|
||||
@patch('edx_ace.ace.send')
|
||||
def test_notification_not_send_if_created_day_before_yesterday(self, mock_func):
|
||||
"""
|
||||
Tests email is not sent if notification is created day before yesterday
|
||||
"""
|
||||
created_date = datetime.datetime.now() - datetime.timedelta(days=2)
|
||||
create_notification(self.user, self.course.id, created=created_date)
|
||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
||||
send_digest_email_to_user(self.user, EmailCadence.DAILY)
|
||||
assert not mock_func.called
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestEmailDigestAudience(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests audience for notification digest email
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup
|
||||
"""
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.course = CourseFactory.create(display_name='test course', run="Testing_course")
|
||||
|
||||
@patch('openedx.core.djangoapps.notifications.email.tasks.send_digest_email_to_user')
|
||||
def test_email_func_not_called_if_no_notification(self, mock_func):
|
||||
"""
|
||||
Tests email sending function is not called if user has no notifications
|
||||
"""
|
||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
||||
send_digest_email_to_all_users(EmailCadence.DAILY)
|
||||
assert not mock_func.called
|
||||
|
||||
@patch('openedx.core.djangoapps.notifications.email.tasks.send_digest_email_to_user')
|
||||
def test_email_func_called_if_user_has_notification(self, mock_func):
|
||||
"""
|
||||
Tests email sending function is called if user has notification
|
||||
"""
|
||||
created_date = datetime.datetime.now() - datetime.timedelta(days=1)
|
||||
create_notification(self.user, self.course.id, created=created_date)
|
||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
||||
send_digest_email_to_all_users(EmailCadence.DAILY)
|
||||
assert mock_func.called
|
||||
|
||||
@patch('openedx.core.djangoapps.notifications.email.tasks.send_digest_email_to_user')
|
||||
def test_email_func_not_called_if_user_notification_is_not_duration(self, mock_func):
|
||||
"""
|
||||
Tests email sending function is not called if user has notification
|
||||
which is not in duration
|
||||
"""
|
||||
created_date = datetime.datetime.now() - datetime.timedelta(days=10)
|
||||
create_notification(self.user, self.course.id, created=created_date)
|
||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
||||
send_digest_email_to_all_users(EmailCadence.DAILY)
|
||||
assert not mock_func.called
|
||||
|
||||
@patch('edx_ace.ace.send')
|
||||
def test_email_is_sent_to_user_when_task_is_called(self, mock_func):
|
||||
created_date = datetime.datetime.now() - datetime.timedelta(days=1)
|
||||
create_notification(self.user, self.course.id, created=created_date)
|
||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
||||
send_digest_email_to_all_users(EmailCadence.DAILY)
|
||||
assert mock_func.called
|
||||
assert mock_func.call_count == 1
|
||||
|
||||
def test_audience_query_count(self):
|
||||
with self.assertNumQueries(1):
|
||||
audience = get_audience_for_cadence_email(EmailCadence.DAILY)
|
||||
list(audience) # evaluating queryset
|
||||
|
||||
@ddt.data(True, False)
|
||||
@patch('edx_ace.ace.send')
|
||||
def test_digest_should_contain_email_enabled_notifications(self, email_value, mock_func):
|
||||
"""
|
||||
Tests email is sent only when notifications with email=True exists
|
||||
"""
|
||||
created_date = datetime.datetime.now() - datetime.timedelta(days=1)
|
||||
create_notification(self.user, self.course.id, created=created_date, email=email_value)
|
||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
||||
send_digest_email_to_user(self.user, EmailCadence.DAILY)
|
||||
assert mock_func.called is email_value
|
||||
|
||||
|
||||
class TestPreferences(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests preferences
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup
|
||||
"""
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.course = CourseFactory.create(display_name='test course', run="Testing_course")
|
||||
self.preference = CourseNotificationPreference.objects.create(user=self.user, course_id=self.course.id)
|
||||
created_date = datetime.datetime.now() - datetime.timedelta(days=1)
|
||||
create_notification(self.user, self.course.id, notification_type='new_discussion_post', created=created_date)
|
||||
|
||||
@patch('edx_ace.ace.send')
|
||||
def test_email_send_for_digest_preference(self, mock_func):
|
||||
"""
|
||||
Tests email is send for digest notification preference
|
||||
"""
|
||||
config = self.preference.notification_preference_config
|
||||
types = config['discussion']['notification_types']
|
||||
types['new_discussion_post']['email_cadence'] = EmailCadence.DAILY
|
||||
self.preference.save()
|
||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
||||
send_digest_email_to_user(self.user, EmailCadence.DAILY)
|
||||
assert mock_func.called
|
||||
|
||||
@patch('edx_ace.ace.send')
|
||||
def test_email_not_send_if_different_digest_preference(self, mock_func):
|
||||
"""
|
||||
Tests email is not send if digest notification preference doesnot match
|
||||
"""
|
||||
config = self.preference.notification_preference_config
|
||||
types = config['discussion']['notification_types']
|
||||
types['new_discussion_post']['email_cadence'] = EmailCadence.WEEKLY
|
||||
self.preference.save()
|
||||
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
|
||||
send_digest_email_to_user(self.user, EmailCadence.DAILY)
|
||||
assert not mock_func.called
|
||||
196
openedx/core/djangoapps/notifications/email/tests/test_utils.py
Normal file
196
openedx/core/djangoapps/notifications/email/tests/test_utils.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Test utils.py
|
||||
"""
|
||||
import datetime
|
||||
import ddt
|
||||
|
||||
from pytz import utc
|
||||
from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_EMAIL_NOTIFICATIONS
|
||||
from openedx.core.djangoapps.notifications.models import Notification
|
||||
from openedx.core.djangoapps.notifications.email.utils import (
|
||||
add_additional_attributes_to_notifications,
|
||||
create_app_notifications_dict,
|
||||
create_datetime_string,
|
||||
create_email_digest_context,
|
||||
create_email_template_context,
|
||||
get_course_info,
|
||||
get_time_ago,
|
||||
is_email_notification_flag_enabled,
|
||||
)
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from .utils import assert_list_equal, create_notification
|
||||
|
||||
|
||||
class TestUtilFunctions(ModuleStoreTestCase):
|
||||
"""
|
||||
Test utils functions
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup
|
||||
"""
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.course = CourseFactory.create(display_name='test course', run="Testing_course")
|
||||
|
||||
def test_additional_attributes(self):
|
||||
"""
|
||||
Tests additional attributes are added when notifications list is passed to
|
||||
add_additional_attributes_to_notifications function
|
||||
"""
|
||||
notification = create_notification(self.user, self.course.id)
|
||||
additional_params = ['course_name', 'icon', 'time_ago']
|
||||
for param in additional_params:
|
||||
assert not hasattr(notification, param)
|
||||
add_additional_attributes_to_notifications([notification])
|
||||
for param in additional_params:
|
||||
assert hasattr(notification, param)
|
||||
|
||||
def test_create_app_notifications_dict(self):
|
||||
"""
|
||||
Tests notifications are divided based on their app_name
|
||||
"""
|
||||
Notification.objects.all().delete()
|
||||
create_notification(self.user, self.course.id, app_name='discussion', notification_type='new_comment')
|
||||
create_notification(self.user, self.course.id, app_name='updates', notification_type='course_update')
|
||||
app_dict = create_app_notifications_dict(Notification.objects.all())
|
||||
assert len(app_dict.keys()) == 2
|
||||
for key in ['discussion', 'updates']:
|
||||
assert key in app_dict.keys()
|
||||
assert app_dict[key]['count'] == 1
|
||||
assert len(app_dict[key]['notifications']) == 1
|
||||
|
||||
def test_get_course_info(self):
|
||||
"""
|
||||
Tests get_course_info function
|
||||
"""
|
||||
assert get_course_info(self.course.id) == {'name': 'test course'}
|
||||
|
||||
def test_get_time_ago(self):
|
||||
"""
|
||||
Tests time_ago string
|
||||
"""
|
||||
current_datetime = utc.localize(datetime.datetime.now())
|
||||
assert "Today" == get_time_ago(current_datetime)
|
||||
assert "1d" == get_time_ago(current_datetime - datetime.timedelta(days=1))
|
||||
assert "1w" == get_time_ago(current_datetime - datetime.timedelta(days=7))
|
||||
|
||||
def test_datetime_string(self):
|
||||
dt = datetime.datetime(2024, 3, 25)
|
||||
assert create_datetime_string(dt) == "Monday, Mar 25"
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestContextFunctions(ModuleStoreTestCase):
|
||||
"""
|
||||
Test template context functions in utils.py
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup
|
||||
"""
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.course = CourseFactory.create(display_name='test course', run="Testing_course")
|
||||
|
||||
def test_email_template_context(self):
|
||||
"""
|
||||
Tests common header and footer context
|
||||
"""
|
||||
context = create_email_template_context()
|
||||
keys = ['platform_name', 'mailing_address', 'logo_url', 'social_media', 'notification_settings_url']
|
||||
for key in keys:
|
||||
assert key in context
|
||||
|
||||
@ddt.data('Daily', 'Weekly')
|
||||
def test_email_digest_context(self, digest_frequency):
|
||||
"""
|
||||
Tests context for email digest
|
||||
"""
|
||||
Notification.objects.all().delete()
|
||||
discussion_notification = create_notification(self.user, self.course.id, app_name='discussion',
|
||||
notification_type='new_comment')
|
||||
update_notification = create_notification(self.user, self.course.id, app_name='updates',
|
||||
notification_type='course_update')
|
||||
app_dict = create_app_notifications_dict(Notification.objects.all())
|
||||
end_date = datetime.datetime(2024, 3, 24, 12, 0)
|
||||
params = {
|
||||
"app_notifications_dict": app_dict,
|
||||
"start_date": end_date - datetime.timedelta(days=0 if digest_frequency == "Daily" else 6),
|
||||
"end_date": end_date,
|
||||
"digest_frequency": digest_frequency,
|
||||
"courses_data": None
|
||||
}
|
||||
context = create_email_digest_context(**params)
|
||||
expected_start_date = 'Sunday, Mar 24' if digest_frequency == 'Daily' else 'Monday, Mar 18'
|
||||
expected_digest_updates = [
|
||||
{'title': 'Total Notifications', 'count': 2},
|
||||
{'title': 'Discussion', 'count': 1},
|
||||
{'title': 'Updates', 'count': 1},
|
||||
]
|
||||
expected_email_content = [
|
||||
{'title': 'Discussion', 'help_text': '', 'help_text_url': '', 'notifications': [discussion_notification]},
|
||||
{'title': 'Updates', 'help_text': '', 'help_text_url': '', 'notifications': [update_notification]}
|
||||
]
|
||||
assert context['start_date'] == expected_start_date
|
||||
assert context['end_date'] == 'Sunday, Mar 24'
|
||||
assert context['digest_frequency'] == digest_frequency
|
||||
assert_list_equal(context['email_digest_updates'], expected_digest_updates)
|
||||
assert_list_equal(context['email_content'], expected_email_content)
|
||||
|
||||
|
||||
class TestWaffleFlag(ModuleStoreTestCase):
|
||||
"""
|
||||
Test user level email notifications waffle flag
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup
|
||||
"""
|
||||
super().setUp()
|
||||
self.user_1 = UserFactory()
|
||||
self.user_2 = UserFactory()
|
||||
self.course_1 = CourseFactory.create(display_name='test course 1', run="Testing_course_1")
|
||||
self.course_1 = CourseFactory.create(display_name='test course 2', run="Testing_course_2")
|
||||
|
||||
def test_waffle_flag_for_everyone(self):
|
||||
"""
|
||||
Tests if waffle flag is enabled for everyone
|
||||
"""
|
||||
assert is_email_notification_flag_enabled() is False
|
||||
waffle_model = get_waffle_flag_model()
|
||||
flag, _ = waffle_model.objects.get_or_create(name=ENABLE_EMAIL_NOTIFICATIONS.name)
|
||||
flag.everyone = True
|
||||
flag.save()
|
||||
assert is_email_notification_flag_enabled() is True
|
||||
|
||||
def test_waffle_flag_for_user(self):
|
||||
"""
|
||||
Tests user level waffle flag
|
||||
"""
|
||||
assert is_email_notification_flag_enabled() is False
|
||||
waffle_model = get_waffle_flag_model()
|
||||
flag, _ = waffle_model.objects.get_or_create(name=ENABLE_EMAIL_NOTIFICATIONS.name)
|
||||
flag.users.add(self.user_1)
|
||||
flag.save()
|
||||
assert is_email_notification_flag_enabled(self.user_1) is True
|
||||
assert is_email_notification_flag_enabled(self.user_2) is False
|
||||
|
||||
def test_waffle_flag_everyone_priority(self):
|
||||
"""
|
||||
Tests if everyone field has more priority over user field
|
||||
"""
|
||||
assert is_email_notification_flag_enabled() is False
|
||||
waffle_model = get_waffle_flag_model()
|
||||
flag, _ = waffle_model.objects.get_or_create(name=ENABLE_EMAIL_NOTIFICATIONS.name)
|
||||
flag.everyone = False
|
||||
flag.users.add(self.user_1)
|
||||
flag.save()
|
||||
assert is_email_notification_flag_enabled() is False
|
||||
assert is_email_notification_flag_enabled(self.user_1) is False
|
||||
assert is_email_notification_flag_enabled(self.user_2) is False
|
||||
40
openedx/core/djangoapps/notifications/email/tests/utils.py
Normal file
40
openedx/core/djangoapps/notifications/email/tests/utils.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Utils for tests
|
||||
"""
|
||||
from openedx.core.djangoapps.notifications.models import Notification
|
||||
|
||||
|
||||
def create_notification(user, course_key, **kwargs):
|
||||
"""
|
||||
Create a test notification
|
||||
"""
|
||||
notification_params = {
|
||||
'user': user,
|
||||
'course_id': course_key,
|
||||
'app_name': "discussion",
|
||||
'notification_type': "new_comment",
|
||||
'content_url': '',
|
||||
'content_context': {
|
||||
"replier_name": "replier",
|
||||
"username": "username",
|
||||
"author_name": "author_name",
|
||||
"post_title": "post_title",
|
||||
"course_update_content": "Course update content",
|
||||
"content_type": 'post',
|
||||
"content": "post_title"
|
||||
},
|
||||
'email': True,
|
||||
'web': True
|
||||
}
|
||||
notification_params.update(kwargs)
|
||||
notification = Notification.objects.create(**notification_params)
|
||||
return notification
|
||||
|
||||
|
||||
def assert_list_equal(list_1, list_2):
|
||||
"""
|
||||
Asserts if list is equal
|
||||
"""
|
||||
assert len(list_1) == len(list_2)
|
||||
for element in list_1:
|
||||
assert element in list_2
|
||||
@@ -1,12 +1,46 @@
|
||||
"""
|
||||
Email Notifications Utils
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from pytz import utc
|
||||
from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import
|
||||
|
||||
from lms.djangoapps.branding.api import get_logo_url_for_email
|
||||
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_EMAIL_NOTIFICATIONS
|
||||
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from .notification_icons import NotificationTypeIcons
|
||||
|
||||
|
||||
def is_email_notification_flag_enabled(user=None):
|
||||
"""
|
||||
Returns if waffle flag is enabled for user or not
|
||||
"""
|
||||
flag_model = get_waffle_flag_model()
|
||||
try:
|
||||
flag = flag_model.objects.get(name=ENABLE_EMAIL_NOTIFICATIONS.name)
|
||||
except flag_model.DoesNotExist:
|
||||
return False
|
||||
if flag.everyone is not None:
|
||||
return flag.everyone
|
||||
if user:
|
||||
role_value = flag.is_active_for_user(user)
|
||||
if role_value is not None:
|
||||
return role_value
|
||||
try:
|
||||
return flag.users.contains(user)
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def create_datetime_string(datetime_instance):
|
||||
"""
|
||||
Returns string for datetime object
|
||||
"""
|
||||
return datetime_instance.strftime('%A, %b %d')
|
||||
|
||||
|
||||
@@ -40,25 +74,172 @@ def create_email_template_context():
|
||||
}
|
||||
|
||||
|
||||
def create_email_digest_content(start_date, end_date=None, digest_frequency="Daily",
|
||||
notifications_count=0, updates_count=0, email_content=None):
|
||||
def create_email_digest_context(app_notifications_dict, start_date, end_date=None, digest_frequency="Daily",
|
||||
courses_data=None):
|
||||
"""
|
||||
Creates email context based on content
|
||||
app_notifications_dict: Mapping of notification app and its count, title and notifications
|
||||
start_date: datetime instance
|
||||
end_date: datetime instance
|
||||
digest_frequency: EmailCadence.DAILY or EmailCadence.WEEKLY
|
||||
courses_data: Dictionary to cache course info (avoid additional db calls)
|
||||
"""
|
||||
context = create_email_template_context()
|
||||
start_date_str = create_datetime_string(start_date)
|
||||
end_date_str = create_datetime_string(end_date if end_date else start_date)
|
||||
email_digest_updates = [{
|
||||
'title': 'Total Notifications',
|
||||
'count': sum(value['count'] for value in app_notifications_dict.values())
|
||||
}]
|
||||
email_digest_updates.extend([
|
||||
{
|
||||
'title': value['title'],
|
||||
'count': value['count'],
|
||||
}
|
||||
for key, value in app_notifications_dict.items()
|
||||
])
|
||||
email_content = [
|
||||
{
|
||||
'title': value['title'],
|
||||
'help_text': value.get('help_text', ''),
|
||||
'help_text_url': value.get('help_text_url', ''),
|
||||
'notifications': add_additional_attributes_to_notifications(
|
||||
value.get('notifications', []), courses_data=courses_data
|
||||
)
|
||||
}
|
||||
for key, value in app_notifications_dict.items()
|
||||
]
|
||||
context.update({
|
||||
"start_date": start_date_str,
|
||||
"end_date": end_date_str,
|
||||
"digest_frequency": digest_frequency,
|
||||
"updates": [
|
||||
{"count": updates_count, "type": "Updates"},
|
||||
{"count": notifications_count, "type": "Notifications"}
|
||||
],
|
||||
"email_content": email_content if email_content else [],
|
||||
"get_icon_url_for_notification_type": get_icon_url_for_notification_type,
|
||||
"email_digest_updates": email_digest_updates,
|
||||
"email_content": email_content,
|
||||
})
|
||||
return context
|
||||
|
||||
|
||||
def get_start_end_date(cadence_type):
|
||||
"""
|
||||
Returns start_date and end_date for email digest
|
||||
"""
|
||||
if cadence_type not in [EmailCadence.DAILY, EmailCadence.WEEKLY]:
|
||||
raise ValueError('Invalid cadence_type')
|
||||
date_today = datetime.datetime.now()
|
||||
yesterday = date_today - datetime.timedelta(days=1)
|
||||
end_date = datetime.datetime.combine(yesterday, datetime.time.max)
|
||||
start_date = end_date
|
||||
if cadence_type == EmailCadence.WEEKLY:
|
||||
start_date = end_date - datetime.timedelta(days=6)
|
||||
start_date = datetime.datetime.combine(start_date, datetime.time.min)
|
||||
return utc.localize(start_date), utc.localize(end_date)
|
||||
|
||||
|
||||
def get_course_info(course_key):
|
||||
"""
|
||||
Returns course info for course_key
|
||||
"""
|
||||
store = modulestore()
|
||||
course = store.get_course(course_key)
|
||||
return {'name': course.display_name}
|
||||
|
||||
|
||||
def get_time_ago(datetime_obj):
|
||||
"""
|
||||
Returns time_ago for datetime instance
|
||||
"""
|
||||
current_date = utc.localize(datetime.datetime.today())
|
||||
days_diff = (current_date - datetime_obj).days
|
||||
if days_diff == 0:
|
||||
return "Today"
|
||||
if days_diff >= 7:
|
||||
return f"{int(days_diff / 7)}w"
|
||||
return f"{days_diff}d"
|
||||
|
||||
|
||||
def add_additional_attributes_to_notifications(notifications, courses_data=None):
|
||||
"""
|
||||
Add attributes required for email content to notification instance
|
||||
notifications: list[Notification]
|
||||
course_data: Cache course info
|
||||
"""
|
||||
if courses_data is None:
|
||||
courses_data = {}
|
||||
|
||||
for notification in notifications:
|
||||
notification_type = notification.notification_type
|
||||
course_key = notification.course_id
|
||||
course_key_str = str(course_key)
|
||||
if course_key_str not in courses_data.keys():
|
||||
courses_data[course_key_str] = get_course_info(course_key)
|
||||
course_info = courses_data[course_key_str]
|
||||
notification.course_name = course_info.get('name', '')
|
||||
notification.icon = get_icon_url_for_notification_type(notification_type)
|
||||
notification.time_ago = get_time_ago(notification.created)
|
||||
return notifications
|
||||
|
||||
|
||||
def create_app_notifications_dict(notifications):
|
||||
"""
|
||||
Return a dictionary with notification app as key and
|
||||
title, count and notifications as its value
|
||||
"""
|
||||
app_names = list({notification.app_name for notification in notifications})
|
||||
app_notifications = {
|
||||
name: {
|
||||
'count': 0,
|
||||
'title': name.title(),
|
||||
'notifications': []
|
||||
}
|
||||
for name in app_names
|
||||
}
|
||||
for notification in notifications:
|
||||
app_data = app_notifications[notification.app_name]
|
||||
app_data['count'] += 1
|
||||
app_data['notifications'].append(notification)
|
||||
return app_notifications
|
||||
|
||||
|
||||
def get_unique_course_ids(notifications):
|
||||
"""
|
||||
Returns unique course_ids from notifications
|
||||
"""
|
||||
course_ids = []
|
||||
for notification in notifications:
|
||||
if notification.course_id not in course_ids:
|
||||
course_ids.append(notification.course_id)
|
||||
return course_ids
|
||||
|
||||
|
||||
def get_enabled_notification_types_for_cadence(preferences, cadence_type=EmailCadence.DAILY):
|
||||
"""
|
||||
Returns a dictionary that returns notification_types with cadence_types for course_ids
|
||||
"""
|
||||
if cadence_type not in [EmailCadence.DAILY, EmailCadence.WEEKLY]:
|
||||
raise ValueError('Invalid cadence_type')
|
||||
course_types = {}
|
||||
for preference in preferences:
|
||||
key = preference.course_id
|
||||
value = []
|
||||
config = preference.notification_preference_config
|
||||
for app_data in config.values():
|
||||
for notification_type, type_dict in app_data['notification_types'].items():
|
||||
if type_dict['email_cadence'] == cadence_type:
|
||||
value.append(notification_type)
|
||||
if 'core' in value:
|
||||
value.remove('core')
|
||||
value.extend(app_data['core_notification_types'])
|
||||
course_types[key] = value
|
||||
return course_types
|
||||
|
||||
|
||||
def filter_notification_with_email_enabled_preferences(notifications, preferences, cadence_type=EmailCadence.DAILY):
|
||||
"""
|
||||
Filter notifications for types with email cadence preference enabled
|
||||
"""
|
||||
enabled_course_prefs = get_enabled_notification_types_for_cadence(preferences, cadence_type)
|
||||
filtered_notifications = []
|
||||
for notification in notifications:
|
||||
if notification.notification_type in enabled_course_prefs[notification.course_id]:
|
||||
filtered_notifications.append(notification)
|
||||
return filtered_notifications
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Management command for sending email digest
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
|
||||
from openedx.core.djangoapps.notifications.email.tasks import send_digest_email_to_all_users
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Invoke with:
|
||||
|
||||
python manage.py lms send_email_digest [cadence_type]
|
||||
cadence_type: Daily or Weekly
|
||||
"""
|
||||
help = (
|
||||
"Send email digest to user."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""
|
||||
Adds management commands parser arguments
|
||||
"""
|
||||
cadence_type_choices = [EmailCadence.DAILY, EmailCadence.WEEKLY]
|
||||
parser.add_argument('cadence_type', choices=cadence_type_choices, required=True)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""
|
||||
Start task to send email digest to users
|
||||
"""
|
||||
send_digest_email_to_all_users.delay(args=(kwargs['cadence_type'],))
|
||||
@@ -1,15 +1,18 @@
|
||||
{% for update in email_content %}
|
||||
<style>
|
||||
.notification-content > p { margin: 0; padding: 0}
|
||||
</style>
|
||||
{% for notification_app in email_content %}
|
||||
<h3 style="font-size: 1.375rem; font-weight:700; line-height:28px; margin: 0.75rem 0 0;">
|
||||
{{ update.title }}
|
||||
{{ notification_app.title }}
|
||||
</h3>
|
||||
{% if update.help_text %}
|
||||
{% if notification_app.help_text %}
|
||||
<p style="margin: 0; height: 1.5rem; font-weight: 400; font-size: 14px; line-height: 24px">
|
||||
<span style="float:left; color:#707070;">
|
||||
{{ update.help_text }}
|
||||
{{ notification_app.help_text }}
|
||||
</span>
|
||||
{% if update.help_text_url %}
|
||||
{% if notification_app.help_text_url %}
|
||||
<span style="float:right; margin-right: 0.25rem">
|
||||
<a href="{{help_text_url}}" style="text-decoration: none; color: #00688D">
|
||||
<a href="{{notification_app.help_text_url}}" style="text-decoration: none; color: #00688D">
|
||||
View all
|
||||
</a>
|
||||
</span>
|
||||
@@ -20,27 +23,27 @@
|
||||
<p style="margin: 0;">
|
||||
<table style="background-color: #FBFAF9; border-radius: 8px; padding: 0.5rem 0" width="100%">
|
||||
<tbody>
|
||||
{% for content in update.content %}
|
||||
{% for notification in notification_app.notifications %}
|
||||
<tr>
|
||||
<td align="left" valign="top" style="padding: 1rem 0 1rem 1rem">
|
||||
<img
|
||||
src="https://edx-notifications-static.edx.org/icons/newspaper.png"
|
||||
src="{{notification.icon}}"
|
||||
style="max-height: 28px; max-width: 28px; margin: 0.75rem 1rem 0.75rem 0"
|
||||
/>
|
||||
</td>
|
||||
<td width="100%" align="left" valign="top" style=" padding: 1rem 1rem 1rem 0.5rem">
|
||||
<td class="notification-content" width="100%" align="left" valign="top" style=" padding: 1rem 1rem 1rem 0.5rem">
|
||||
<p style="font-size: 0.875rem; font-weight:400; line-height:24px; color:#454545; margin: 0;">
|
||||
{{ content.title }}
|
||||
{{ notification.content | safe }}
|
||||
</p>
|
||||
<p style="height: 0.5rem; margin: 0"></p>
|
||||
<p style="color:#707070; margin: 0">
|
||||
<span style="float: left">
|
||||
<span>{{ content.course_name }}</span>
|
||||
<span>{{ notification.course_name }}</span>
|
||||
<span style="padding: 0 0.375rem">·</span>
|
||||
<span>{{ content.time_ago }}</span>
|
||||
<span>{{ notification.time_ago }}</span>
|
||||
</span>
|
||||
<span style="float: right">
|
||||
<a href="{{content.url}}" style="text-decoration: none; color: #00688D">
|
||||
<a href="{{notification.content_url}}" style="text-decoration: none; color: #00688D">
|
||||
View
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<table width="100%" border="0" cellpadding="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
{% for update in updates %}
|
||||
{% for update in email_digest_updates %}
|
||||
<td width="50%">
|
||||
<p style="
|
||||
{% if not forloop.last %}padding-right: 0.5rem{% endif %}
|
||||
@@ -58,7 +58,7 @@
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td style="font-weight: 600; font-size: 0.875rem; line-height: 20px; padding: 0">
|
||||
{{update.type}}
|
||||
{{update.title}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -433,3 +433,67 @@ class TestDeleteNotificationTask(ModuleStoreTestCase):
|
||||
delete_notifications({'course_id': self.course_1.id})
|
||||
assert not Notification.objects.filter(course_id=self.course_1.id)
|
||||
assert Notification.objects.filter(course_id=self.course_2.id)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class NotificationCreationOnChannelsTests(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for notification creation and channels value.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a course and users for tests.
|
||||
"""
|
||||
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.course = CourseFactory.create(
|
||||
org='testorg',
|
||||
number='testcourse',
|
||||
run='testrun'
|
||||
)
|
||||
|
||||
self.preference = CourseNotificationPreference.objects.create(
|
||||
user_id=self.user.id,
|
||||
course_id=self.course.id,
|
||||
config_version=0,
|
||||
)
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
@ddt.data(
|
||||
(False, False, 0),
|
||||
(False, True, 1),
|
||||
(True, False, 1),
|
||||
(True, True, 1),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_notification_is_created_when_any_channel_is_enabled(self, web_value, email_value, generated_count):
|
||||
"""
|
||||
Tests if notification is created if any preference is enabled
|
||||
"""
|
||||
app_name = 'discussion'
|
||||
notification_type = 'new_discussion_post'
|
||||
app_prefs = self.preference.notification_preference_config[app_name]
|
||||
app_prefs['notification_types'][notification_type]['web'] = web_value
|
||||
app_prefs['notification_types'][notification_type]['email'] = email_value
|
||||
kwargs = {
|
||||
'user_ids': [self.user.id],
|
||||
'course_key': str(self.course.id),
|
||||
'app_name': app_name,
|
||||
'notification_type': notification_type,
|
||||
'content_url': 'https://example.com/',
|
||||
'context': {
|
||||
'post_title': 'Post title',
|
||||
'username': 'user name',
|
||||
},
|
||||
}
|
||||
self.preference.save()
|
||||
with patch('openedx.core.djangoapps.notifications.tasks.notification_generated_event') as event_mock:
|
||||
send_notifications(**kwargs)
|
||||
notifications = Notification.objects.all()
|
||||
assert len(notifications) == generated_count
|
||||
if notifications:
|
||||
notification = Notification.objects.all()[0]
|
||||
assert notification.web == web_value
|
||||
assert notification.email == email_value
|
||||
|
||||
Reference in New Issue
Block a user