From cfe40599bfd80d5f10ce9ab251890142d6bdaf41 Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Fri, 4 Jul 2025 13:04:28 +0500 Subject: [PATCH] feat: added API to get account level preferences (#36957) * feat: added API to get account-level preferences * feat: added update api for account level preferences (#36978) --- .../core/djangoapps/notifications/tasks.py | 5 +- .../notifications/tests/test_views.py | 308 +++++++++++++++++- openedx/core/djangoapps/notifications/urls.py | 7 +- .../core/djangoapps/notifications/views.py | 198 ++++++++++- 4 files changed, 511 insertions(+), 7 deletions(-) diff --git a/openedx/core/djangoapps/notifications/tasks.py b/openedx/core/djangoapps/notifications/tasks.py index 936802e4aa..88d38e10dd 100644 --- a/openedx/core/djangoapps/notifications/tasks.py +++ b/openedx/core/djangoapps/notifications/tasks.py @@ -21,13 +21,14 @@ from openedx.core.djangoapps.notifications.base_notification import ( get_default_values_of_preference, get_notification_content ) + +from openedx.core.djangoapps.notifications.email.tasks import send_immediate_cadence_email from openedx.core.djangoapps.notifications.config.waffle import ( - ENABLE_ACCOUNT_LEVEL_PREFERENCES, ENABLE_NOTIFICATION_GROUPING, ENABLE_NOTIFICATIONS, + ENABLE_ACCOUNT_LEVEL_PREFERENCES, ENABLE_PUSH_NOTIFICATIONS ) -from openedx.core.djangoapps.notifications.email.tasks import send_immediate_cadence_email from openedx.core.djangoapps.notifications.email_notifications import EmailCadence from openedx.core.djangoapps.notifications.events import notification_generated_event from openedx.core.djangoapps.notifications.grouping_notifications import ( diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 740824b780..0bac12fb7f 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -36,7 +36,7 @@ from openedx.core.djangoapps.notifications.email.utils import encrypt_object, en from openedx.core.djangoapps.notifications.models import ( CourseNotificationPreference, Notification, - get_course_notification_preference_config_version + get_course_notification_preference_config_version, NotificationPreference ) from openedx.core.djangoapps.notifications.serializers import NotificationCourseEnrollmentSerializer from openedx.core.djangoapps.user_api.models import UserPreference @@ -44,7 +44,8 @@ from openedx.core.djangoapps.notifications.email.utils import update_user_prefer from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from ..base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES, NotificationAppManager +from ..base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES, NotificationAppManager, \ + NotificationTypeManager from ..utils import get_notification_types_with_visibility_settings User = get_user_model() @@ -1414,3 +1415,306 @@ class GetAggregateNotificationPreferencesTest(APITestCase): prefs = response.data['data'] self.assertDictEqual(prefs['updates']['non_editable'], {'course_updates': ['email']}) self.assertDictEqual(prefs['discussion']['non_editable'], {'core': ['web']}) + + +class TestNotificationPreferencesView(APITestCase): + """ + Tests for the NotificationPreferencesView API view. + """ + + def setUp(self): + # Set up a user and API client + self.default_data = { + "status": "success", + "message": "Notification preferences retrieved successfully.", + "data": { + "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": { + "new_discussion_post": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "new_question_post": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "content_reported": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + }, + "new_instructor_all_learners_post": { + "web": True, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": {} + }, + "updates": { + "enabled": True, + "core_notification_types": [], + "notification_types": { + "course_updates": { + "web": True, + "email": False, + "push": True, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": {} + }, + "grading": { + "enabled": True, + "core_notification_types": [], + "notification_types": { + "ora_staff_notifications": { + "web": True, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "ora_grade_assigned": { + "web": True, + "email": True, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": {} + } + } + } + self.user = User.objects.create_user(username='testuser', password='testpass') + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.url = reverse('notification-preferences-aggregated-v2') # Adjust with the actual name + + def test_get_notification_preferences(self): + """ + Test case: Get notification preferences for the authenticated user + """ + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + self.assertIn('data', response.data) + self.assertEqual(response.data['data'], self.default_data['data']) + + def test_if_data_is_correctly_aggregated(self): + """ + Test case: Check if the data is correctly formatted + """ + + self.client.get(self.url) + NotificationPreference.objects.all().update( + web=False, + push=False, + email=False, + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + self.assertIn('data', response.data) + data = { + "status": "success", + "message": "Notification preferences retrieved successfully.", + "data": { + "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": { + "new_discussion_post": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "new_question_post": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "content_reported": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "new_instructor_all_learners_post": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + } + }, + "non_editable": {} + }, + "updates": { + "enabled": True, + "core_notification_types": [], + "notification_types": { + "course_updates": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": {} + }, + "grading": { + "enabled": True, + "core_notification_types": [], + "notification_types": { + "ora_staff_notifications": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "ora_grade_assigned": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": {} + } + } + } + self.assertEqual(response.data, data) + + def test_api_view_permissions(self): + """ + Test case: Ensure the API view has the correct permissions + """ + # Check if the view requires authentication + self.client.logout() + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # Re-authenticate and check again + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_update_preferences_core(self): + """ + Test case: Update notification preferences for the authenticated user + """ + update_data = { + "notification_app": "discussion", + "notification_type": "core", + "notification_channel": "email_cadence", + "email_cadence": "Weekly" + } + __, core_types = NotificationTypeManager().get_notification_app_preference('discussion') + self.client.get(self.url) + response = self.client.put(self.url, update_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + cadence_set = NotificationPreference.objects.filter(type__in=core_types).values_list('email_cadence', flat=True) + self.assertEqual(len(set(cadence_set)), 1) + self.assertIn('Weekly', set(cadence_set)) + + def test_update_preferences(self): + """ + Test case: Update notification preferences for the authenticated user + """ + update_data = { + "notification_app": "discussion", + "notification_type": "new_discussion_post", + "notification_channel": "web", + "value": True + } + self.client.get(self.url) + response = self.client.put(self.url, update_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + preference = NotificationPreference.objects.get( + type='new_discussion_post', + user__id=self.user.id + ) + self.assertEqual(preference.web, True) + + def test_update_preferences_non_core_email(self): + """ + Test case: Update notification preferences for the authenticated user + """ + update_data = { + "notification_app": "discussion", + "notification_type": "new_discussion_post", + "notification_channel": "email_cadence", + "email_cadence": 'Weekly' + } + self.client.get(self.url) + response = self.client.put(self.url, update_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + preference = NotificationPreference.objects.get( + type='new_discussion_post', + user__id=self.user.id + ) + self.assertEqual(preference.email_cadence, 'Weekly') diff --git a/openedx/core/djangoapps/notifications/urls.py b/openedx/core/djangoapps/notifications/urls.py index 9892fa72de..17e9f272f9 100644 --- a/openedx/core/djangoapps/notifications/urls.py +++ b/openedx/core/djangoapps/notifications/urls.py @@ -13,7 +13,7 @@ from .views import ( NotificationReadAPIView, UpdateAllNotificationPreferencesView, UserNotificationPreferenceView, - preference_update_from_encrypted_username_view, AggregatedNotificationPreferences + preference_update_from_encrypted_username_view, AggregatedNotificationPreferences, NotificationPreferencesView ) router = routers.DefaultRouter() @@ -30,6 +30,11 @@ urlpatterns = [ AggregatedNotificationPreferences.as_view(), name='notification-preferences-aggregated' ), + path( + 'v2/configurations/', + NotificationPreferencesView.as_view(), + name='notification-preferences-aggregated-v2' + ), 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 190c115fa2..773ca4270b 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -20,12 +20,14 @@ from rest_framework.views import APIView from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY from openedx.core.djangoapps.notifications.email.utils import update_user_preferences_from_patch -from openedx.core.djangoapps.notifications.models import get_course_notification_preference_config_version +from openedx.core.djangoapps.notifications.models import get_course_notification_preference_config_version, \ + NotificationPreference from openedx.core.djangoapps.notifications.permissions import allow_any_authenticated_user from openedx.core.djangoapps.notifications.serializers import add_info_to_notification_config from openedx.core.djangoapps.user_api.models import UserPreference -from .base_notification import COURSE_NOTIFICATION_APPS +from .base_notification import COURSE_NOTIFICATION_APPS, NotificationAppManager, COURSE_NOTIFICATION_TYPES, \ + NotificationTypeManager from .config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFY_ALL_LEARNERS from .events import ( notification_preference_update_event, @@ -43,6 +45,7 @@ from .serializers import ( UserNotificationPreferenceUpdateSerializer, add_non_editable_in_preference ) +from .tasks import create_notification_preference from .utils import ( aggregate_notification_configs, filter_out_visible_preferences_by_course_ids, @@ -626,3 +629,194 @@ class AggregatedNotificationPreferences(APIView): 'message': 'Notification preferences retrieved', 'data': add_non_editable_in_preference(notification_configs) }, status=status.HTTP_200_OK) + + +@allow_any_authenticated_user() +class NotificationPreferencesView(APIView): + """ + API view to retrieve and structure the notification preferences for the + authenticated user. + """ + + def get(self, request): + """ + Handles GET requests to retrieve notification preferences. + + This method fetches the user's active notification preferences and + merges them with a default structure provided by NotificationAppManager. + This provides a complete view of all possible notifications and the + user's current settings for them. + + Returns: + Response: A DRF Response object containing the structured + notification preferences or an error message. + """ + user_preferences_qs = NotificationPreference.objects.filter(user=request.user) + user_preferences_map = {pref.type: pref for pref in user_preferences_qs} + + # Ensure all notification types are present in the user's preferences. + # If any are missing, create them with default values. + diff = set(COURSE_NOTIFICATION_TYPES.keys()) - set(user_preferences_map.keys()) + missing_types = [] + for missing_type in diff: + new_pref = create_notification_preference( + user_id=request.user.id, + notification_type=missing_type, + + ) + missing_types.append(new_pref) + user_preferences_map[missing_type] = new_pref + if missing_types: + NotificationPreference.objects.bulk_create(missing_types) + + # If no user preferences are found, return an error response. + if not user_preferences_map: + return Response({ + 'status': 'error', + 'message': 'No active notification preferences found for this user.' + }, status=status.HTTP_404_NOT_FOUND) + + # Get the structured preferences from the NotificationAppManager. + # This will include all apps and their notification types. + structured_preferences = NotificationAppManager().get_notification_app_preferences() + + for app_name, app_settings in structured_preferences.items(): + notification_types = app_settings.get('notification_types', {}) + + # Process all notification types (core and non-core) in a single loop. + for type_name, type_details in notification_types.items(): + if type_name == 'core': + if structured_preferences[app_name]['core_notification_types']: + # If the app has core notification types, use the first one as the type name. + # This assumes that the first core notification type is representative of the core settings. + notification_type = structured_preferences[app_name]['core_notification_types'][0] + else: + notification_type = 'core' + user_pref = user_preferences_map.get(notification_type) + else: + user_pref = user_preferences_map.get(type_name) + if user_pref: + # If a preference exists, update the dictionary for this type. + # This directly modifies the 'type_details' dictionary. + type_details['web'] = user_pref.web + type_details['email'] = user_pref.email + type_details['push'] = user_pref.push + type_details['email_cadence'] = user_pref.email_cadence + + return Response({ + 'status': 'success', + 'message': 'Notification preferences retrieved successfully.', + 'data': add_non_editable_in_preference(structured_preferences) + }, status=status.HTTP_200_OK) + + def put(self, request): + """ + Handles PUT requests to update notification preferences. + + This method updates the user's notification preferences based on the + provided data in the request body. It expects a dictionary with + notification types and their settings. + + Returns: + Response: A DRF Response object indicating success or failure. + """ + # Validate incoming data + serializer = UserNotificationPreferenceUpdateAllSerializer(data=request.data) + if not serializer.is_valid(): + return Response({ + 'status': 'error', + 'message': serializer.errors + }, status=status.HTTP_400_BAD_REQUEST) + + # Get validated data for easier access + validated_data = serializer.validated_data + + # Build query set based on notification type + query_set = NotificationPreference.objects.filter(user_id=request.user.id) + + if validated_data['notification_type'] == 'core': + # Get core notification types for the app + __, core_types = NotificationTypeManager().get_notification_app_preference( + notification_app=validated_data['notification_app'] + ) + query_set = query_set.filter(type__in=core_types) + else: + # Filter by single notification type + query_set = query_set.filter(type=validated_data['notification_type']) + + # Prepare update data based on channel type + updated_data = self._prepare_update_data(validated_data) + + # Update preferences + query_set.update(**updated_data) + + # Log the event + self._log_preference_update_event(request.user, validated_data) + + # Prepare and return response + response_data = self._prepare_response_data(validated_data) + return Response(response_data, status=status.HTTP_200_OK) + + def _prepare_update_data(self, validated_data): + """ + Prepare the data dictionary for updating notification preferences. + + Args: + validated_data (dict): Validated serializer data + + Returns: + dict: Dictionary with update data + """ + channel = validated_data['notification_channel'] + + if channel == 'email_cadence': + return {channel: validated_data['email_cadence']} + else: + return {channel: validated_data['value']} + + def _log_preference_update_event(self, user, validated_data): + """ + Log the notification preference update event. + + Args: + user: The user making the update + validated_data (dict): Validated serializer data + """ + event_data = { + 'notification_app': validated_data['notification_app'], + 'notification_type': validated_data['notification_type'], + 'notification_channel': validated_data['notification_channel'], + 'value': validated_data.get('value'), + 'email_cadence': validated_data.get('email_cadence'), + } + notification_preference_update_event(user, [], event_data) + + def _prepare_response_data(self, validated_data): + """ + Prepare the response data dictionary. + + Args: + validated_data (dict): Validated serializer data + + Returns: + dict: Response data dictionary + """ + email_cadence = validated_data.get('email_cadence', None) + # Determine the updated value + updated_value = validated_data.get('value', email_cadence if email_cadence else None) + + # Determine the channel + channel = validated_data.get('notification_channel') + if not channel and validated_data.get('email_cadence'): + channel = 'email_cadence' + + return { + 'status': 'success', + 'message': 'Notification preferences update completed', + 'data': { + 'updated_value': updated_value, + 'notification_type': validated_data['notification_type'], + 'channel': channel, + 'app': validated_data['notification_app'], + } + }