diff --git a/openedx/core/djangoapps/notifications/serializers.py b/openedx/core/djangoapps/notifications/serializers.py index 7c1c93d1e483..8f44c5a29003 100644 --- a/openedx/core/djangoapps/notifications/serializers.py +++ b/openedx/core/djangoapps/notifications/serializers.py @@ -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 = ( diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index b094a974aaaa..957837c57873 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -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. diff --git a/openedx/core/djangoapps/notifications/urls.py b/openedx/core/djangoapps/notifications/urls.py index ca20774ac559..cef8d1b5491f 100644 --- a/openedx/core/djangoapps/notifications/urls.py +++ b/openedx/core/djangoapps/notifications/urls.py @@ -11,7 +11,8 @@ NotificationCountView, NotificationListAPIView, NotificationReadAPIView, - UserNotificationPreferenceView + UserNotificationPreferenceView, + UserNotificationChannelPreferenceView ) router = routers.DefaultRouter() @@ -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( diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py index 42ac74d7c4b5..364a619ec089 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -35,7 +35,8 @@ NotificationCourseEnrollmentSerializer, NotificationSerializer, UserCourseNotificationPreferenceSerializer, - UserNotificationPreferenceUpdateSerializer + UserNotificationPreferenceUpdateSerializer, + UserNotificationChannelPreferenceUpdateSerializer, ) from .utils import get_show_notifications_tray @@ -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): """