Skip to content

Commit

Permalink
feat: make notification channel headings clickable (#34194)
Browse files Browse the repository at this point in the history
* feat: make notification channel headings clickable in notification preferences

* refactor: serializer code updated for better readability

* test: added a test for UserNotificationChannelPreferenceView API

* fix: updated the api test that was failing due to conflicts

---------

Co-authored-by: eemaanamir <[email protected]>
  • Loading branch information
ayesha-waris and eemaanamir authored Feb 7, 2024
1 parent 4a46ae9 commit 48f74fd
Show file tree
Hide file tree
Showing 4 changed files with 254 additions and 2 deletions.
55 changes: 55 additions & 0 deletions openedx/core/djangoapps/notifications/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,65 @@ def update(self, instance, validated_data):
return instance


class UserNotificationChannelPreferenceUpdateSerializer(serializers.Serializer):
"""
Serializer for user notification preferences update for an entire channel.
"""

notification_app = serializers.CharField()
value = serializers.BooleanField()
notification_channel = serializers.CharField(required=False)

def validate(self, attrs):
"""
Validation for notification preference update form
"""
notification_app = attrs.get('notification_app')
notification_channel = attrs.get('notification_channel')

notification_app_config = self.instance.notification_preference_config

if not notification_channel:
raise ValidationError(
'notification_channel is required for notification_type.'
)

if not notification_app_config.get(notification_app, None):
raise ValidationError(
f'{notification_app} is not a valid notification app.'
)

if notification_channel and notification_channel not in get_notification_channels():
raise ValidationError(
f'{notification_channel} is not a valid notification channel.'
)

return attrs

def update(self, instance, validated_data):
"""
Update notification preference config.
"""
notification_app = validated_data.get('notification_app')
notification_channel = validated_data.get('notification_channel')
value = validated_data.get('value')
user_notification_preference_config = instance.notification_preference_config

app_prefs = user_notification_preference_config[notification_app]
for notification_type_name, notification_type_preferences in app_prefs['notification_types'].items():
non_editable_channels = app_prefs['non_editable'].get(notification_type_name, [])
if notification_channel not in non_editable_channels:
app_prefs['notification_types'][notification_type_name][notification_channel] = value

instance.save()
return instance


class NotificationSerializer(serializers.ModelSerializer):
"""
Serializer for the Notification model.
"""

class Meta:
model = Notification
fields = (
Expand Down
138 changes: 138 additions & 0 deletions openedx/core/djangoapps/notifications/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,144 @@ def test_info_is_not_saved_in_json(self):
assert 'info' not in type_prefs.keys()


@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
@override_waffle_flag(ENABLE_REPORTED_CONTENT_NOTIFICATIONS, active=True)
@ddt.ddt
class UserNotificationChannelPreferenceAPITest(ModuleStoreTestCase):
"""
Test for user notification channel preference API.
"""

def setUp(self):
super().setUp()
self.user = UserFactory()
self.course = CourseFactory.create(
org='testorg',
number='testcourse',
run='testrun'
)

course_overview = CourseOverviewFactory.create(id=self.course.id, org='AwesomeOrg')
self.course_enrollment = CourseEnrollment.objects.create(
user=self.user,
course=course_overview,
is_active=True,
mode='audit'
)
self.client = APIClient()
self.path = reverse('notification-channel-preferences', kwargs={'course_key_string': self.course.id})

enrollment_data = CourseEnrollmentData(
user=UserData(
pii=UserPersonalData(
username=self.user.username,
email=self.user.email,
name=self.user.profile.name,
),
id=self.user.id,
is_active=self.user.is_active,
),
course=CourseData(
course_key=self.course.id,
display_name=self.course.display_name,
),
mode=self.course_enrollment.mode,
is_active=self.course_enrollment.is_active,
creation_date=self.course_enrollment.created,
)
COURSE_ENROLLMENT_CREATED.send_event(
enrollment=enrollment_data
)

def _expected_api_response(self, course=None):
"""
Helper method to return expected API response.
"""
if course is None:
course = self.course
response = {
'id': 1,
'course_name': 'course-v1:testorg+testcourse+testrun Course',
'course_id': 'course-v1:testorg+testcourse+testrun',
'notification_preference_config': {
'discussion': {
'enabled': True,
'core_notification_types': [
'new_comment_on_response',
'new_comment',
'new_response',
'response_on_followed_post',
'comment_on_followed_post',
'response_endorsed_on_thread',
'response_endorsed'
],
'notification_types': {
'core': {
'web': True,
'email': True,
'push': True,
'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': ''},
},
'non_editable': {
'core': ['web']
}
}
}
}
if not ENABLE_COURSEWIDE_NOTIFICATIONS.is_enabled(course.id):
app_prefs = response['notification_preference_config']['discussion']
notification_types = app_prefs['notification_types']
for notification_type in ['new_discussion_post', 'new_question_post']:
notification_types.pop(notification_type)
return response

@ddt.data(
('discussion', 'web', True, status.HTTP_200_OK),
('discussion', 'web', False, status.HTTP_200_OK),
('invalid_notification_app', 'web', False, status.HTTP_400_BAD_REQUEST),
('discussion', 'invalid_notification_channel', False, status.HTTP_400_BAD_REQUEST),
)
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_patch_user_notification_preference(
self, notification_app, notification_channel, value, expected_status, mock_emit,
):
"""
Test update of user notification channel preference.
"""
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
payload = {
'notification_app': notification_app,
'value': value,
}
if notification_channel:
payload['notification_channel'] = notification_channel

response = self.client.patch(self.path, json.dumps(payload), content_type='application/json')
self.assertEqual(response.status_code, expected_status)

if expected_status == status.HTTP_200_OK:
expected_data = self._expected_api_response()
expected_app_prefs = expected_data['notification_preference_config'][notification_app]
for notification_type_name, notification_type_preferences in expected_app_prefs[
'notification_types'].items():
non_editable_channels = expected_app_prefs['non_editable'].get(notification_type_name, [])
if notification_channel not in non_editable_channels:
expected_app_prefs['notification_types'][notification_type_name][notification_channel] = value
self.assertEqual(response.data, expected_data)
event_name, event_data = mock_emit.call_args[0]
self.assertEqual(event_name, 'edx.notifications.preferences.updated')
self.assertEqual(event_data['notification_app'], notification_app)
self.assertEqual(event_data['notification_channel'], notification_channel)
self.assertEqual(event_data['value'], value)


class NotificationListAPIViewTest(APITestCase):
"""
Tests suit for the NotificationListAPIView.
Expand Down
8 changes: 7 additions & 1 deletion openedx/core/djangoapps/notifications/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
NotificationCountView,
NotificationListAPIView,
NotificationReadAPIView,
UserNotificationPreferenceView
UserNotificationPreferenceView,
UserNotificationChannelPreferenceView
)

router = routers.DefaultRouter()
Expand All @@ -24,6 +25,11 @@
UserNotificationPreferenceView.as_view(),
name='notification-preferences'
),
re_path(
fr'^channel/configurations/{settings.COURSE_KEY_PATTERN}$',
UserNotificationChannelPreferenceView.as_view(),
name='notification-channel-preferences'
),
path('', NotificationListAPIView.as_view(), name='notifications-list'),
path('count/', NotificationCountView.as_view(), name='notifications-count'),
path(
Expand Down
55 changes: 54 additions & 1 deletion openedx/core/djangoapps/notifications/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
NotificationCourseEnrollmentSerializer,
NotificationSerializer,
UserCourseNotificationPreferenceSerializer,
UserNotificationPreferenceUpdateSerializer
UserNotificationPreferenceUpdateSerializer,
UserNotificationChannelPreferenceUpdateSerializer,
)
from .utils import get_show_notifications_tray

Expand Down Expand Up @@ -232,6 +233,58 @@ def patch(self, request, course_key_string):
return Response(serializer.data, status=status.HTTP_200_OK)


@allow_any_authenticated_user()
class UserNotificationChannelPreferenceView(APIView):
"""
Supports retrieving and patching the UserNotificationPreference
model.
**Example Requests**
PATCH /api/notifications/configurations/{course_id}
"""

def patch(self, request, course_key_string):
"""
Update an existing user notification preference for an entire channel with the data in the request body.
Parameters:
request (Request): The request object
course_key_string (int): The ID of the course of the notification preference to be updated.
Returns:
200: The updated preference, serialized using the UserNotificationPreferenceSerializer
404: If the preference does not exist
403: If the user does not have permission to update the preference
400: Validation error
"""
course_id = CourseKey.from_string(course_key_string)
user_course_notification_preference = CourseNotificationPreference.objects.get(
user=request.user,
course_id=course_id,
is_active=True,
)
if user_course_notification_preference.config_version != get_course_notification_preference_config_version():
return Response(
{'error': _('The notification preference config version is not up to date.')},
status=status.HTTP_409_CONFLICT,
)

preference_update = UserNotificationChannelPreferenceUpdateSerializer(
user_course_notification_preference, data=request.data, partial=True
)
preference_update.is_valid(raise_exception=True)
updated_notification_preferences = preference_update.save()
notification_preference_update_event(request.user, course_id, preference_update.validated_data)

serializer_context = {
'course_id': course_id,
'user': request.user
}
serializer = UserCourseNotificationPreferenceSerializer(updated_notification_preferences,
context=serializer_context)
return Response(serializer.data, status=status.HTTP_200_OK)


@allow_any_authenticated_user()
class NotificationListAPIView(generics.ListAPIView):
"""
Expand Down

0 comments on commit 48f74fd

Please sign in to comment.