Merge branch 'openedx:master' into INF-1230
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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}
|
||||
);
|
||||
</%static:require_module>
|
||||
</%block>
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user