Skip to content

Commit

Permalink
feat: add notification for course updates
Browse files Browse the repository at this point in the history
  • Loading branch information
Saad Yousaf authored and saadyousafarbi committed Feb 27, 2024
1 parent 7fec28d commit a110fc7
Show file tree
Hide file tree
Showing 9 changed files with 165 additions and 28 deletions.
13 changes: 13 additions & 0 deletions cms/djangoapps/contentstore/config/waffle.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,16 @@
# .. 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__
)
18 changes: 14 additions & 4 deletions cms/djangoapps/contentstore/course_info_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
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
Expand All @@ -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:
Expand Down Expand Up @@ -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"]
Expand Down
29 changes: 29 additions & 0 deletions cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations
import configparser
import logging
import re
from collections import defaultdict
from contextlib import contextmanager
from datetime import datetime, timezone
Expand All @@ -22,6 +23,9 @@
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
Expand Down Expand Up @@ -62,6 +66,7 @@
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
Expand Down Expand Up @@ -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)
4 changes: 3 additions & 1 deletion cms/djangoapps/contentstore/views/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions openedx/core/djangoapps/notifications/audience_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def filter(self, enrollment_modes):
return CourseEnrollment.objects.filter(
course_id=self.course_key,
mode__in=enrollment_modes,
is_active=True,
).values_list('user_id', flat=True)


Expand Down
29 changes: 27 additions & 2 deletions openedx/core/djangoapps/notifications/base_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,24 @@
},
'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}</{strong}></{p}>'),
'content_context': {
'course_update_content': 'Course update',
},
'email_template': '',
'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE]
},
}

COURSE_NOTIFICATION_APPS = {
Expand All @@ -173,7 +190,15 @@
'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': []
},
}


Expand Down
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
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():
Expand Down
71 changes: 64 additions & 7 deletions openedx/core/djangoapps/notifications/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,47 @@ def _expected_api_response(self, course=None):
'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': {}
}
}
}
Expand Down Expand Up @@ -293,8 +327,8 @@ def test_get_user_notification_preference(self, mock_emit):
@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]
}
}
Expand All @@ -318,7 +352,9 @@ def test_get_user_notification_preference_with_visibility_settings(self, role, m

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)

Expand Down Expand Up @@ -474,6 +510,27 @@ def _expected_api_response(self, course=None):
'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': {}
}
}
}
Expand Down Expand Up @@ -742,7 +799,7 @@ def test_get_unseen_notifications_count_with_show_notifications_tray(self, show_
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):
Expand All @@ -763,7 +820,7 @@ def test_get_unseen_notifications_count_for_user_with_no_notifications(self):

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):
"""
Expand Down
26 changes: 13 additions & 13 deletions openedx/core/djangoapps/notifications/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

user_preferences[key]['notification_types'].pop(notification_type)
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

discussion_user_preferences['notification_types'].pop(notification_type)
return user_preferences


Expand Down

0 comments on commit a110fc7

Please sign in to comment.