diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 8ea4a84410..a754a2c995 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -307,6 +307,15 @@ ORA_MICROFRONTEND_URL = 'http://localhost:1992' ############################ AI_TRANSLATIONS ################################## AI_TRANSLATIONS_API_URL = 'http://localhost:18760/api/v1' +############################ CSRF ################################## + +# MFEs that will call this service in devstack +CSRF_TRUSTED_ORIGINS = [ + 'http://localhost:3001', # frontend-app-library-authoring + 'http://localhost:2001', # frontend-app-course-authoring + 'http://localhost:1992', # frontend-app-ora +] + #################### Event bus backend ######################## EVENT_BUS_PRODUCER = 'edx_event_bus_redis.create_producer' diff --git a/lms/djangoapps/courseware/toggles.py b/lms/djangoapps/courseware/toggles.py index 78cecac7f5..130153c966 100644 --- a/lms/djangoapps/courseware/toggles.py +++ b/lms/djangoapps/courseware/toggles.py @@ -126,6 +126,19 @@ ENABLE_OPTIMIZELY_IN_COURSEWARE = WaffleSwitch( # lint-amnesty, pylint: disable 'RET.enable_optimizely_in_courseware', __name__ ) +# .. toggle_name: courseware.discovery_default_language_filter +# .. toggle_implementation: WaffleSwitch +# .. toggle_default: False +# .. toggle_description: Enable courses to be filtered by user language by default. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2023-11-02 +# .. toggle_target_removal_date: None +# .. toggle_warning: The ENABLE_COURSE_DISCOVERY feature flag should be enabled. +# .. toggle_tickets: https://github.com/openedx/edx-platform/pull/33647 +ENABLE_COURSE_DISCOVERY_DEFAULT_LANGUAGE_FILTER = WaffleSwitch( + f'{WAFFLE_FLAG_NAMESPACE}.discovery_default_language_filter', __name__ +) + def courseware_mfe_is_active() -> bool: """ diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index d0e657775b..e4fbdb33a8 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -137,7 +137,10 @@ from openedx.features.enterprise_support.api import data_sharing_consent_require from ..block_render import get_block, get_block_by_usage_id, get_block_for_descriptor from ..tabs import _get_dynamic_tabs -from ..toggles import COURSEWARE_OPTIMIZED_RENDER_XBLOCK +from ..toggles import ( + COURSEWARE_OPTIMIZED_RENDER_XBLOCK, + ENABLE_COURSE_DISCOVERY_DEFAULT_LANGUAGE_FILTER, +) log = logging.getLogger("edx.courseware") @@ -275,6 +278,7 @@ def courses(request): """ courses_list = [] course_discovery_meanings = getattr(settings, 'COURSE_DISCOVERY_MEANINGS', {}) + set_default_filter = ENABLE_COURSE_DISCOVERY_DEFAULT_LANGUAGE_FILTER.is_enabled() if not settings.FEATURES.get('ENABLE_COURSE_DISCOVERY'): courses_list = get_courses(request.user) @@ -292,6 +296,7 @@ def courses(request): { 'courses': courses_list, 'course_discovery_meanings': course_discovery_meanings, + 'set_default_filter': set_default_filter, 'programs_list': programs_list, } ) diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index e241852fdd..e922d574ec 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -537,6 +537,17 @@ AUTH_DOCUMENTATION_URL = 'https://course-catalog-api-guide.readthedocs.io/en/lat ############################ AI_TRANSLATIONS ################################## AI_TRANSLATIONS_API_URL = 'http://localhost:18760/api/v1' +############################ CSRF ################################## + +# MFEs that will call this service in devstack +CSRF_TRUSTED_ORIGINS = [ + 'http://localhost:2000', # frontend-app-learning + 'http://localhost:1997', # frontend-app-account + 'http://localhost:1995', # frontend-app-profile + 'http://localhost:1992', # frontend-app-ora +] + + ################# New settings must go ABOVE this line ################# ######################################################################## # See if the developer has any local overrides. diff --git a/lms/static/js/discovery/discovery_factory.js b/lms/static/js/discovery/discovery_factory.js index 662db9c8e1..d26841f86f 100644 --- a/lms/static/js/discovery/discovery_factory.js +++ b/lms/static/js/discovery/discovery_factory.js @@ -5,7 +5,7 @@ 'js/discovery/views/search_form', 'js/discovery/views/courses_listing', 'js/discovery/views/filter_bar', 'js/discovery/views/refine_sidebar'], function(Backbone, SearchState, Filters, SearchForm, CoursesListing, FilterBar, RefineSidebar) { - return function(meanings, searchQuery, userLanguage, userTimezone) { + return function(meanings, searchQuery, userLanguage, userTimezone, setDefaultFilter) { var dispatcher = _.extend({}, Backbone.Events); var search = new SearchState(); var filters = new Filters(); @@ -21,10 +21,16 @@ userLanguage: userLanguage, userTimezone: userTimezone }; + if (setDefaultFilter && userLanguage) { + filters.add({ + type: 'language', + query: userLanguage, + name: refineSidebar.termName('language', userLanguage) + }); + } listing = new CoursesListing({model: courseListingModel}); dispatcher.listenTo(form, 'search', function(query) { - filters.reset(); form.showLoadingIndicator(); search.performSearch(query, filters.getTerms()); }); @@ -42,6 +48,7 @@ dispatcher.listenTo(filterBar, 'clearFilter', removeFilter); dispatcher.listenTo(filterBar, 'clearAll', function() { + filters.reset(); form.doSearch(''); }); diff --git a/lms/static/js/discovery/views/search_form.js b/lms/static/js/discovery/views/search_form.js index e242904b49..8125a6111d 100644 --- a/lms/static/js/discovery/views/search_form.js +++ b/lms/static/js/discovery/views/search_form.js @@ -52,11 +52,13 @@ }, showNotFoundMessage: function(term) { - var msg = interpolate( - gettext('We couldn\'t find any results for "%s".'), - [_.escape(term)] - ); - this.$message.html(msg); + if (term) { + var msg = interpolate( + gettext('We couldn\'t find any results for "%s".'), + [_.escape(term)] + ); + this.$message.html(msg); + } this.clearSearch(); }, diff --git a/lms/static/js/spec/discovery/discovery_factory_spec.js b/lms/static/js/spec/discovery/discovery_factory_spec.js index a42390399e..6ce1a9e2d6 100644 --- a/lms/static/js/spec/discovery/discovery_factory_spec.js +++ b/lms/static/js/spec/discovery/discovery_factory_spec.js @@ -45,7 +45,8 @@ define([ start: '1970-01-01T05:00:00+00:00', image_url: '/c4x/edX/DemoX/asset/images_course_image.jpg', org: 'edX', - id: 'edX/DemoX/Demo_Course' + id: 'edX/DemoX/Demo_Course', + language: 'en' } } ], @@ -195,4 +196,33 @@ define([ expect($('.active-filter [data-value="edX1"]').length).toBe(0); }); }); + + describe('discovery.DiscoveryFactory default filters', function() { + beforeEach(function() { + loadFixtures('js/fixtures/discovery.html'); + TemplateHelpers.installTemplates([ + 'templates/discovery/course_card', + 'templates/discovery/facet', + 'templates/discovery/facet_option', + 'templates/discovery/filter', + 'templates/discovery/filter_bar' + ]); + // setDefaultFilter to true + DiscoveryFactory(MEANINGS, '', 'en', 'Asia/Kolkata', true); + + jasmine.clock().install(); + }); + + afterEach(function() { + jasmine.clock().uninstall(); + }); + + it('filters by default language', function() { + var requests = AjaxHelpers.requests(this); + $('.discovery-submit').trigger('click'); + var request = AjaxHelpers.currentRequest(requests); + // make sure language filter is set + expect(request.requestBody).toMatch(/language=en/); + }); + }); }); diff --git a/lms/static/js/spec/discovery/views/search_form_spec.js b/lms/static/js/spec/discovery/views/search_form_spec.js index 8c1b0927c0..837880e05a 100644 --- a/lms/static/js/spec/discovery/views/search_form_spec.js +++ b/lms/static/js/spec/discovery/views/search_form_spec.js @@ -41,12 +41,19 @@ define(['jquery', 'js/discovery/views/search_form'], function($, SearchForm) { it('shows messages', function() { this.form.showFoundMessage(123); expect($('#discovery-message')).toContainHtml(123); - this.form.showNotFoundMessage(); - expect($('#discovery-message')).not.toBeEmpty(); this.form.showErrorMessage(); expect($('#discovery-message')).not.toBeEmpty(); }); + it('shows not found messages', function() { + // message should not be displayed if search term is empty + this.form.showNotFoundMessage(); + expect($('#discovery-message')).toBeEmpty(); + this.form.showNotFoundMessage('xyz'); + expect($('#discovery-message')).not.toBeEmpty(); + expect($('#discovery-message')).toContainHtml('xyz'); + }); + it('shows default error message', function() { this.form.showErrorMessage(); expect(this.form.$message).toContainHtml('There was an error, try searching again.'); diff --git a/lms/templates/courseware/courses.html b/lms/templates/courseware/courses.html index a3ecb7c58c..11ad5079ea 100644 --- a/lms/templates/courseware/courses.html +++ b/lms/templates/courseware/courses.html @@ -23,7 +23,8 @@ ${course_discovery_meanings | n, dump_js_escaped_json}, getParameterByName('search_query'), "${user_language | n, js_escaped_string}", - "${user_timezone | n, js_escaped_string}" + "${user_timezone | n, js_escaped_string}", + ${set_default_filter | n, dump_js_escaped_json} ); diff --git a/openedx/core/djangoapps/notifications/serializers.py b/openedx/core/djangoapps/notifications/serializers.py index 7c1c93d1e4..8f44c5a290 100644 --- a/openedx/core/djangoapps/notifications/serializers.py +++ b/openedx/core/djangoapps/notifications/serializers.py @@ -156,10 +156,65 @@ class UserNotificationPreferenceUpdateSerializer(serializers.Serializer): 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 b094a974aa..957837c578 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -384,6 +384,144 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): 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 ca20774ac5..cef8d1b549 100644 --- a/openedx/core/djangoapps/notifications/urls.py +++ b/openedx/core/djangoapps/notifications/urls.py @@ -11,7 +11,8 @@ from .views import ( NotificationCountView, NotificationListAPIView, NotificationReadAPIView, - UserNotificationPreferenceView + UserNotificationPreferenceView, + UserNotificationChannelPreferenceView ) router = routers.DefaultRouter() @@ -24,6 +25,11 @@ urlpatterns = [ 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 42ac74d7c4..364a619ec0 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -35,7 +35,8 @@ from .serializers import ( NotificationCourseEnrollmentSerializer, NotificationSerializer, UserCourseNotificationPreferenceSerializer, - UserNotificationPreferenceUpdateSerializer + UserNotificationPreferenceUpdateSerializer, + UserNotificationChannelPreferenceUpdateSerializer, ) from .utils import get_show_notifications_tray @@ -232,6 +233,58 @@ class UserNotificationPreferenceView(APIView): 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): """