From a454da9ca65e3c567d34ec4380efcb8972dd909a Mon Sep 17 00:00:00 2001 From: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com> Date: Tue, 7 May 2024 12:15:03 +0500 Subject: [PATCH] feat: added daily and weekly email digest (#34539) * feat: added daily and weekly email digest --- .../djangoapps/notifications/config/waffle.py | 14 +- .../djangoapps/notifications/email/tasks.py | 116 +++++++++++ .../notifications/email/tests/__init__.py | 0 .../notifications/email/tests/test_tasks.py | 195 +++++++++++++++++ .../notifications/email/tests/test_utils.py | 196 +++++++++++++++++ .../notifications/email/tests/utils.py | 40 ++++ .../djangoapps/notifications/email/utils.py | 197 +++++++++++++++++- .../management/commands/send_email_digest.py | 32 +++ .../notifications/digest_content.html | 29 +-- .../notifications/digest_header.html | 4 +- .../notifications/tests/test_tasks.py | 64 ++++++ 11 files changed, 862 insertions(+), 25 deletions(-) create mode 100644 openedx/core/djangoapps/notifications/email/tasks.py create mode 100644 openedx/core/djangoapps/notifications/email/tests/__init__.py create mode 100644 openedx/core/djangoapps/notifications/email/tests/test_tasks.py create mode 100644 openedx/core/djangoapps/notifications/email/tests/test_utils.py create mode 100644 openedx/core/djangoapps/notifications/email/tests/utils.py create mode 100644 openedx/core/djangoapps/notifications/management/commands/send_email_digest.py diff --git a/openedx/core/djangoapps/notifications/config/waffle.py b/openedx/core/djangoapps/notifications/config/waffle.py index 9d54a0abb8..fa1d02adf1 100644 --- a/openedx/core/djangoapps/notifications/config/waffle.py +++ b/openedx/core/djangoapps/notifications/config/waffle.py @@ -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__) diff --git a/openedx/core/djangoapps/notifications/email/tasks.py b/openedx/core/djangoapps/notifications/email/tasks.py new file mode 100644 index 0000000000..3b1783f42b --- /dev/null +++ b/openedx/core/djangoapps/notifications/email/tasks.py @@ -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' Sending email to user {user.username} ==Temp Log==') + if not is_email_notification_flag_enabled(user): + logger.info(f' 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' 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' 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 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' Sending cadence email of type {cadence_type}') + users = get_audience_for_cadence_email(cadence_type) + courses_data = {} + logger.info(f' Email Cadence Audience {len(users)}') + for user in users: + send_digest_email_to_user(user, cadence_type, courses_data=courses_data) diff --git a/openedx/core/djangoapps/notifications/email/tests/__init__.py b/openedx/core/djangoapps/notifications/email/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/notifications/email/tests/test_tasks.py b/openedx/core/djangoapps/notifications/email/tests/test_tasks.py new file mode 100644 index 0000000000..78dbc95783 --- /dev/null +++ b/openedx/core/djangoapps/notifications/email/tests/test_tasks.py @@ -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 diff --git a/openedx/core/djangoapps/notifications/email/tests/test_utils.py b/openedx/core/djangoapps/notifications/email/tests/test_utils.py new file mode 100644 index 0000000000..d7c9f6c981 --- /dev/null +++ b/openedx/core/djangoapps/notifications/email/tests/test_utils.py @@ -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 diff --git a/openedx/core/djangoapps/notifications/email/tests/utils.py b/openedx/core/djangoapps/notifications/email/tests/utils.py new file mode 100644 index 0000000000..c27f60443b --- /dev/null +++ b/openedx/core/djangoapps/notifications/email/tests/utils.py @@ -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 diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py index 2993b68fc2..9538620b06 100644 --- a/openedx/core/djangoapps/notifications/email/utils.py +++ b/openedx/core/djangoapps/notifications/email/utils.py @@ -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 diff --git a/openedx/core/djangoapps/notifications/management/commands/send_email_digest.py b/openedx/core/djangoapps/notifications/management/commands/send_email_digest.py new file mode 100644 index 0000000000..cfaef2d15f --- /dev/null +++ b/openedx/core/djangoapps/notifications/management/commands/send_email_digest.py @@ -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'],)) diff --git a/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html b/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html index aa1ee903ad..f7cb37fcfe 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html +++ b/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html @@ -1,15 +1,18 @@ -{% for update in email_content %} + +{% for notification_app in email_content %}

- {{ update.title }} + {{ notification_app.title }}

- {% if update.help_text %} + {% if notification_app.help_text %}

- {{ update.help_text }} + {{ notification_app.help_text }} - {% if update.help_text_url %} + {% if notification_app.help_text_url %} - + View all @@ -20,27 +23,27 @@

- {% for content in update.content %} + {% for notification in notification_app.notifications %} -
+

- {{ content.title }} + {{ notification.content | safe }}

- {{ content.course_name }} + {{ notification.course_name }} · - {{ content.time_ago }} + {{ notification.time_ago }} - + View diff --git a/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html b/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html index fc91d77fa0..82077e37c0 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html +++ b/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html @@ -37,7 +37,7 @@ - {% for update in updates %} + {% for update in email_digest_updates %} diff --git a/openedx/core/djangoapps/notifications/tests/test_tasks.py b/openedx/core/djangoapps/notifications/tests/test_tasks.py index 83ac9d1763..036d4d3261 100644 --- a/openedx/core/djangoapps/notifications/tests/test_tasks.py +++ b/openedx/core/djangoapps/notifications/tests/test_tasks.py @@ -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

- {{update.type}} + {{update.title}}