From a110fc79d5c1cbe6a4507ac41209a063e8e950bd Mon Sep 17 00:00:00 2001 From: Saad Yousaf Date: Fri, 2 Feb 2024 12:41:30 +0500 Subject: [PATCH] feat: add notification for course updates --- cms/djangoapps/contentstore/config/waffle.py | 13 ++++ .../contentstore/course_info_model.py | 18 +++-- cms/djangoapps/contentstore/utils.py | 29 ++++++++ cms/djangoapps/contentstore/views/course.py | 4 +- .../notifications/audience_filters.py | 1 + .../notifications/base_notification.py | 29 +++++++- .../core/djangoapps/notifications/models.py | 2 +- .../notifications/tests/test_views.py | 71 +++++++++++++++++-- .../core/djangoapps/notifications/utils.py | 24 +++---- 9 files changed, 164 insertions(+), 27 deletions(-) diff --git a/cms/djangoapps/contentstore/config/waffle.py b/cms/djangoapps/contentstore/config/waffle.py index 8f431fa493..b10566bb04 100644 --- a/cms/djangoapps/contentstore/config/waffle.py +++ b/cms/djangoapps/contentstore/config/waffle.py @@ -53,3 +53,16 @@ REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND = WaffleFlag( # .. toggle_warning: Flag course_experience.relative_dates should also be active for relative dates functionalities to work. # .. toggle_tickets: https://openedx.atlassian.net/browse/AA-844 CUSTOM_RELATIVE_DATES = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.custom_relative_dates', __name__) + + +# .. toggle_name: studio.enable_course_update_notifications +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable course update notifications. +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 14-Feb-2024 +# .. toggle_target_removal_date: 14-Mar-2024 +ENABLE_COURSE_UPDATE_NOTIFICATIONS = CourseWaffleFlag( + f'{WAFFLE_NAMESPACE}.enable_course_update_notifications', + __name__ +) diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index 8f59998e71..77a6a00c4b 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -19,7 +19,8 @@ import re from django.http import HttpResponseBadRequest from django.utils.translation import gettext as _ -from cms.djangoapps.contentstore.utils import track_course_update_event +from cms.djangoapps.contentstore.config.waffle import ENABLE_COURSE_UPDATE_NOTIFICATIONS +from cms.djangoapps.contentstore.utils import track_course_update_event, send_course_update_notification from openedx.core.lib.xblock_utils import get_course_update_items from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -43,7 +44,7 @@ def get_course_updates(location, provided_id, user_id): return _get_visible_update(course_update_items) -def update_course_updates(location, update, passed_id=None, user=None): +def update_course_updates(location, update, passed_id=None, user=None, request_method=None): """ Either add or update the given course update. Add: @@ -86,8 +87,17 @@ def update_course_updates(location, update, passed_id=None, user=None): # update db record save_course_update_items(location, course_updates, course_update_items, user) - # track course update event - track_course_update_event(location.course_key, user, course_update_dict) + + if request_method == "POST": + # track course update event + track_course_update_event(location.course_key, user, course_update_dict) + + # send course update notification + if ENABLE_COURSE_UPDATE_NOTIFICATIONS.is_enabled(location.course_key): + send_course_update_notification( + location.course_key, course_update_dict["content"], user, + ) + # remove status key if "status" in course_update_dict: del course_update_dict["status"] diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index c72a7e3c3a..e186b0754a 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -4,6 +4,7 @@ Common utility functions useful throughout the contentstore from __future__ import annotations import configparser import logging +import re from collections import defaultdict from contextlib import contextmanager from datetime import datetime, timezone @@ -22,6 +23,9 @@ from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import LibraryLocator from openedx_events.content_authoring.data import DuplicatedXBlockData from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED +from openedx_events.learning.data import CourseNotificationData +from openedx_events.learning.signals import COURSE_NOTIFICATION_REQUESTED + from milestones import api as milestones_api from pytz import UTC from xblock.fields import Scope @@ -62,6 +66,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_ from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.lib.courses import course_image_url +from openedx.core.lib.html_to_text import html_to_text from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML @@ -1951,3 +1956,27 @@ def track_course_update_event(course_key, user, event_data=None): context = contexts.course_context_from_course_id(course_key) with tracker.get_tracker().context(event_name, context): tracker.emit(event_name, event_data) + + +def send_course_update_notification(course_key, content, user): + """ + Send course update notification + """ + text_content = re.sub(r"(\s| |//)+", " ", html_to_text(content)) + course = modulestore().get_course(course_key) + extra_context = { + 'author_id': user.id, + 'course_name': course.display_name, + } + notification_data = CourseNotificationData( + course_key=course_key, + content_context={ + "course_update_content": text_content, + **extra_context, + }, + notification_type="course_update", + content_url=f"{settings.LMS_BASE}/courses/{str(course_key)}/course/updates", + app_name="updates", + audience_filters={}, + ) + COURSE_NOTIFICATION_REQUESTED.send_event(course_notification_data=notification_data) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index eac3c1048d..0535141238 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -1016,7 +1016,9 @@ def course_info_update_handler(request, course_key_string, provided_id=None): # can be either and sometimes django is rewriting one to the other: elif request.method in ('POST', 'PUT'): try: - return JsonResponse(update_course_updates(usage_key, request.json, provided_id, request.user)) + return JsonResponse(update_course_updates( + usage_key, request.json, provided_id, request.user, request.method + )) except: # lint-amnesty, pylint: disable=bare-except return HttpResponseBadRequest( "Failed to save", diff --git a/openedx/core/djangoapps/notifications/audience_filters.py b/openedx/core/djangoapps/notifications/audience_filters.py index fe9a047f78..9bd18a90c6 100644 --- a/openedx/core/djangoapps/notifications/audience_filters.py +++ b/openedx/core/djangoapps/notifications/audience_filters.py @@ -104,6 +104,7 @@ class EnrollmentAudienceFilter(NotificationAudienceFilterBase): return CourseEnrollment.objects.filter( course_id=self.course_key, mode__in=enrollment_modes, + is_active=True, ).values_list('user_id', flat=True) diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index 510d2b9125..1942da5f5c 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -161,7 +161,24 @@ COURSE_NOTIFICATION_TYPES = { }, 'email_template': '', 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE] - } + }, + 'course_update': { + 'notification_app': 'updates', + 'name': 'course_update', + 'is_core': False, + 'info': '', + 'web': True, + 'email': True, + 'push': True, + 'non_editable': [], + 'content_template': _('<{p}>You have a new course update: ' + '<{strong}>{course_update_content}'), + 'content_context': { + 'course_update_content': 'Course update', + }, + 'email_template': '', + 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE] + }, } COURSE_NOTIFICATION_APPS = { @@ -173,7 +190,15 @@ COURSE_NOTIFICATION_APPS = { 'core_email': True, 'core_push': True, 'non_editable': ['web'] - } + }, + 'updates': { + 'enabled': True, + 'core_info': _('Notifications for new announcements and updates from the course team.'), + 'core_web': True, + 'core_email': True, + 'core_push': True, + 'non_editable': [] + }, } diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index 1014683f06..1fae970a40 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -21,7 +21,7 @@ log = logging.getLogger(__name__) NOTIFICATION_CHANNELS = ['web', 'push', 'email'] # Update this version when there is a change to any course specific notification type or app. -COURSE_NOTIFICATION_CONFIG_VERSION = 6 +COURSE_NOTIFICATION_CONFIG_VERSION = 7 def get_course_notification_preference_config(): diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 45040e6b06..2c44f4804c 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -249,13 +249,47 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): 'info': 'Notifications for responses and comments on your posts, and the ones you’re ' 'following, including endorsements to your responses and on your posts.' }, - 'new_discussion_post': {'web': False, 'email': False, 'push': False, 'info': ''}, - 'new_question_post': {'web': False, 'email': False, 'push': False, 'info': ''}, - 'content_reported': {'web': True, 'email': True, 'push': True, 'info': ''}, + 'new_discussion_post': { + 'web': False, + 'email': False, + 'push': False, + 'info': '' + }, + 'new_question_post': { + 'web': False, + 'email': False, + 'push': False, + 'info': '' + }, + 'content_reported': { + 'web': True, + 'email': True, + 'push': True, + 'info': '' + }, }, 'non_editable': { 'core': ['web'] } + }, + 'updates': { + 'enabled': True, + 'core_notification_types': [], + 'notification_types': { + 'course_update': { + 'web': True, + 'email': True, + 'push': True, + 'info': '' + }, + 'core': { + 'web': True, + 'email': True, + 'push': True, + 'info': 'Notifications for new announcements and updates from the course team.' + } + }, + 'non_editable': {} } } } @@ -293,8 +327,8 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): @mock.patch.dict(COURSE_NOTIFICATION_TYPES, { **COURSE_NOTIFICATION_TYPES, **{ - 'new_question_post': { - 'name': 'new_question_post', + 'content_reported': { + 'name': 'content_reported', 'visible_to': [FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_ADMINISTRATOR] } } @@ -318,7 +352,9 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): response = self.client.get(self.path) self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_response = self._expected_api_response() + if not role: expected_response = remove_notifications_with_visibility_settings(expected_response) @@ -474,6 +510,27 @@ class UserNotificationChannelPreferenceAPITest(ModuleStoreTestCase): 'non_editable': { 'core': ['web'] } + }, + 'updates': { + 'enabled': True, + 'core_notification_types': [ + + ], + 'notification_types': { + 'course_update': { + 'web': True, + 'email': True, + 'push': True, + 'info': '' + }, + 'core': { + 'web': True, + 'email': True, + 'push': True, + 'info': 'Notifications for new announcements and updates from the course team.' + } + }, + 'non_editable': {} } } } @@ -742,7 +799,7 @@ class NotificationCountViewSetTestCase(ModuleStoreTestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['count'], 4) self.assertEqual(response.data['count_by_app_name'], { - 'App Name 1': 2, 'App Name 2': 1, 'App Name 3': 1, 'discussion': 0}) + 'App Name 1': 2, 'App Name 2': 1, 'App Name 3': 1, 'discussion': 0, 'updates': 0}) self.assertEqual(response.data['show_notifications_tray'], show_notifications_tray_enabled) def test_get_unseen_notifications_count_for_unauthenticated_user(self): @@ -763,7 +820,7 @@ class NotificationCountViewSetTestCase(ModuleStoreTestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['count'], 0) - self.assertEqual(response.data['count_by_app_name'], {'discussion': 0}) + self.assertEqual(response.data['count_by_app_name'], {'discussion': 0, 'updates': 0}) def test_get_expiry_days_in_count_view(self): """ diff --git a/openedx/core/djangoapps/notifications/utils.py b/openedx/core/djangoapps/notifications/utils.py index 05d9e4ecd2..c3ce819c00 100644 --- a/openedx/core/djangoapps/notifications/utils.py +++ b/openedx/core/djangoapps/notifications/utils.py @@ -121,19 +121,19 @@ def filter_out_visible_notifications( :param user_forum_roles: List of forum roles for the user :return: Updated user preferences dictionary """ - for key in user_preferences: - if 'notification_types' in user_preferences[key]: - # Iterate over the types to remove and pop them from the dictionary - for notification_type, is_visible_to in notifications_with_visibility.items(): - is_visible = False - for role in is_visible_to: - if role in user_forum_roles: - is_visible = True - break - if is_visible: - continue + discussion_user_preferences = user_preferences.get('discussion', {}) + if 'notification_types' in discussion_user_preferences: + # Iterate over the types to remove and pop them from the dictionary + for notification_type, is_visible_to in notifications_with_visibility.items(): + is_visible = False + for role in is_visible_to: + if role in user_forum_roles: + is_visible = True + break + if is_visible: + continue - user_preferences[key]['notification_types'].pop(notification_type) + discussion_user_preferences['notification_types'].pop(notification_type) return user_preferences