diff --git a/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py b/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py new file mode 100644 index 0000000000..e9ed344f9d --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py @@ -0,0 +1,117 @@ +""" +Test the enable/disable discussions for all units API endpoint. +""" +import json + +from django.urls import reverse +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory + +from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient +from common.djangoapps.student.tests.factories import UserFactory + + +class BulkEnableDisableDiscussionsTestCase(ModuleStoreTestCase): + """ + Test the enable/disable discussions for all units API endpoint. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory(is_staff=True, is_superuser=True) + self.user.set_password(self.user_password) + self.user.save() + + self.course_key = CourseKey.from_string("course-v1:edx+TestX+2025") + + self.url = reverse('bulk_enable_disable_discussions', args=[str(self.course_key)]) + self.client = AjaxEnabledTestClient() + self.client.login(username=self.user.username, password=self.user_password) + + # Create a test course + self.course = CourseFactory.create( + org=self.course_key.org, + course=self.course_key.course, + run=self.course_key.run, + default_store=ModuleStoreEnum.Type.split, + display_name="EnableDisableDiscussionsTestCase Course", + ) + with self.store.bulk_operations(self.course_key): + section = BlockFactory.create( + parent=self.course, + category='chapter', + display_name="Generated Section", + ) + sequence = BlockFactory.create( + parent=section, + category='sequential', + display_name="Generated Sequence", + ) + unit1 = BlockFactory.create( + parent=sequence, + category='vertical', + display_name="Unit in Section1", + discussion_enabled=True, + ) + unit2 = BlockFactory.create( + parent=sequence, + category='vertical', + display_name="Unit in Section2", + discussion_enabled=True, + ) + + def test_disable_discussions_for_all_units(self): + """ + Test that the API successfully disables discussions for all units. + """ + self.enable_disable_discussions_for_all_units(False) + + def test_enable_discussions_for_all_units(self): + """ + Test that the API successfully enables discussions for all units. + """ + self.enable_disable_discussions_for_all_units(True) + + def enable_disable_discussions_for_all_units(self, is_enabled): + """ + Test that the API successfully enables/disables discussions for all units. + """ + data = { + "discussion_enabled": is_enabled + } + response = self.client.put(self.url, data=json.dumps(data), content_type='application/json') + self.assertEqual(response.status_code, 200) + response_data = response.json() + print(response_data) + self.assertEqual(response_data['updated_and_republished'], 0 if is_enabled else 2) + + # Check that all verticals now have discussion_enabled set to the expected value + with self.store.bulk_operations(self.course_key): + verticals = self.store.get_items(self.course_key, qualifiers={'block_type': 'vertical'}) + for vertical in verticals: + self.assertEqual(vertical.discussion_enabled, is_enabled) + + def test_permission_denied_for_non_staff(self): + """ + Test that non-staff users are denied access to the API. + """ + # Create a non-staff user + non_staff_user = UserFactory(is_staff=False, is_superuser=False) + non_staff_user.set_password(self.user_password) + non_staff_user.save() + + # Create a new client for the non-staff user + non_staff_client = AjaxEnabledTestClient() + non_staff_client.login(username=non_staff_user.username, password=self.user_password) + + response = non_staff_client.put(self.url, content_type='application/json') + self.assertEqual(response.status_code, 403) + + def test_badrequest_for_empty_request_body(self): + """ + Test that the API returns a 400 for an empty request body. + """ + response = self.client.put(self.url, data=json.dumps({}), content_type='application/json') + self.assertEqual(response.status_code, 400) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 81ad1eb6dd..2a75710be7 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -135,7 +135,7 @@ __all__ = ['course_info_handler', 'course_handler', 'course_listing', 'course_notifications_handler', 'textbooks_list_handler', 'textbooks_detail_handler', 'group_configurations_list_handler', 'group_configurations_detail_handler', - 'get_course_and_check_access'] + 'get_course_and_check_access', 'bulk_enable_disable_discussions'] class AccessListFallback(Exception): @@ -1710,6 +1710,62 @@ def group_configurations_detail_handler(request, course_key_string, group_config ) +@login_required +@expect_json +@ensure_csrf_cookie +@require_http_methods(["PUT"]) +def bulk_enable_disable_discussions(request, course_key_string): + """ + API endpoint to enable/disable discussions for all verticals in the course and republish them. + + PUT + json: enable/disable discussions for all units and republish + """ + try: + # Validate the course key + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError: + return JsonResponseBadRequest({"error": "Invalid course key format"}) + + user = request.user + + # check that logged in user has permissions to update this course + if not has_studio_write_access(user, course_key): + raise PermissionDenied() + + if 'application/json' not in request.META.get('HTTP_ACCEPT', 'application/json'): + return JsonResponseBadRequest({"error": "Only supports json requests"}) + + if 'discussion_enabled' not in request.json: + return JsonResponseBadRequest({"error": "Missing 'discussion_enabled' field in request body"}) + discussion_enabled = request.json['discussion_enabled'] + log.info( + "User %s is attempting to %s discussions for all verticals in course %s", + user.username, + "enable" if discussion_enabled else "disable", + course_key + ) + + if request.method == 'PUT': + try: + store = modulestore() + changed = 0 + with store.bulk_operations(course_key): + verticals = store.get_items(course_key, qualifiers={'block_type': 'vertical'}) + for vertical in verticals: + if vertical.discussion_enabled != discussion_enabled: + vertical.discussion_enabled = discussion_enabled + store.update_item(vertical, user.id) + + if store.has_published_version(vertical): + store.publish(vertical.location, user.id) + changed += 1 + return JsonResponse({"updated_and_republished": changed}) + except Exception as e: # lint-amnesty, pylint: disable=broad-except + log.exception("Exception occurred while enabling/disabling discussion: %s", str(e)) + return JsonResponseBadRequest({"error": str(e)}) + + def are_content_experiments_enabled(course): """ Returns True if content experiments have been enabled for the course. diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index 7ecf299722..f6022c6ac0 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -211,12 +211,6 @@ upstream_info = UpstreamLink.try_get_for_block(xblock, log_error=False) % endif - % elif not show_inline: -
  • - - ${_("Details")} - -
  • % endif % endif diff --git a/cms/urls.py b/cms/urls.py index d01e89d9d2..f2c2c8b31a 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -201,6 +201,9 @@ urlpatterns = oauth2_urlpatterns + [ path('accessibility', contentstore_views.accessibility, name='accessibility'), re_path(fr'api/youtube/courses/{COURSELIKE_KEY_PATTERN}/edx-video-ids$', contentstore_views.get_course_youtube_edx_videos_ids, name='youtube_edx_video_ids'), + re_path(fr'^api/courses/{settings.COURSE_KEY_PATTERN}/bulk_enable_disable_discussions$', + contentstore_views.bulk_enable_disable_discussions, + name='bulk_enable_disable_discussions'), ] if not settings.DISABLE_DEPRECATED_SIGNIN_URL: diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py index ad47c942f3..94cb99d0ce 100644 --- a/common/djangoapps/student/models/user.py +++ b/common/djangoapps/student/models/user.py @@ -696,10 +696,6 @@ def user_profile_pre_save_callback(sender, **kwargs): """ user_profile = kwargs['instance'] - # Remove profile images for users who require parental consent - if user_profile.requires_parental_consent() and user_profile.has_profile_image: - user_profile.profile_image_uploaded_at = None - # Cache "old" field values on the model instance so that they can be # retrieved in the post_save callback when we emit an event with new and # old field values. diff --git a/common/djangoapps/student/tests/test_parental_controls.py b/common/djangoapps/student/tests/test_parental_controls.py index 62cd30707d..a8ae471c61 100644 --- a/common/djangoapps/student/tests/test_parental_controls.py +++ b/common/djangoapps/student/tests/test_parental_controls.py @@ -65,23 +65,3 @@ class ProfileParentalControlsTest(TestCase): self.set_year_of_birth(current_year - 14) assert not self.profile.requires_parental_consent() assert not self.profile.requires_parental_consent(year=current_year) - - def test_profile_image(self): - """Verify that a profile's image obeys parental controls.""" - - # Verify that an image cannot be set for a user with no year of birth set - self.profile.profile_image_uploaded_at = now() - self.profile.save() - assert not self.profile.has_profile_image - - # Verify that an image can be set for an adult user - current_year = now().year - self.set_year_of_birth(current_year - 20) - self.profile.profile_image_uploaded_at = now() - self.profile.save() - assert self.profile.has_profile_image - - # verify that a user's profile image is removed when they switch to requiring parental controls - self.set_year_of_birth(current_year - 10) - self.profile.save() - assert not self.profile.has_profile_image diff --git a/docs/decisions/0005-studio-lms-subdomain-boundaries.rst b/docs/decisions/0005-studio-lms-subdomain-boundaries.rst index 1090e3f0bb..34aa774cd2 100644 --- a/docs/decisions/0005-studio-lms-subdomain-boundaries.rst +++ b/docs/decisions/0005-studio-lms-subdomain-boundaries.rst @@ -74,7 +74,7 @@ of Content Groups to Cohorts. While this might sound a little cumbersome, it actually allows for a cleaner separation of concerns. Content Groups describe what the content is: restricted -copyright, advanced material, labratory exercises, etc. Cohorts describe who is +copyright, advanced material, laboratory exercises, etc. Cohorts describe who is consuming that material: on campus students, alumni, the general MOOC audience, etc. The Content Group is an Authoring decision based on the properties of the content itself. The Cohort mapping is a policy decision about the Learning diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py index 4d55645d7a..4d04204078 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -28,6 +28,7 @@ from xmodule.capa.tests.response_xml_factory import ( OptionResponseXMLFactory, SchematicResponseXMLFactory ) +from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.xqueue_interface import XQueueInterface from common.djangoapps.course_modes.models import CourseMode from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule @@ -810,6 +811,7 @@ class ProblemWithUploadedFilesTest(TestSubmittingProblems): self.assertEqual(list(kwargs['files'].keys()), filenames.split()) +@use_unsafe_codejail() class TestPythonGradedResponse(TestSubmittingProblems): """ Check that we can submit a schematic and custom response, and it answers properly. diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index a710398a0f..9b2cfda1fa 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -2742,22 +2742,35 @@ class ExportOra2DataView(DeveloperErrorViewMixin, APIView): return JsonResponse({"error": str(err)}, status=400) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -@common_exceptions_400 -def export_ora2_summary(request, course_id): +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class ExportOra2SummaryView(DeveloperErrorViewMixin, APIView): """ - Pushes a Celery task which will aggregate a summary students' progress in ora2 tasks for a course into a .csv + Pushes a Celery task which will aggregate a summary of students' progress in ora2 tasks for a course into a .csv """ - course_key = CourseKey.from_string(course_id) - report_type = _('ORA summary') - task_api.submit_export_ora2_summary(request, course_key) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH - return JsonResponse({"status": success_status}) + @method_decorator(ensure_csrf_cookie) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + def post(self, request, course_id): + """ + Initiates a Celery task to generate an ORA summary report for the specified course. + + Args: + request: The HTTP request object + course_id: The string representation of the course key + + Returns: + Response: A JSON response with a status message indicating the report generation has started + """ + course_key = CourseKey.from_string(course_id) + report_type = _('ORA summary') + try: + task_api.submit_export_ora2_summary(request, course_key) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + return Response({"status": success_status}) + except (AlreadyRunningError, QueueConnectionError, AttributeError) as err: + return JsonResponse({"error": str(err)}, status=400) @transaction.non_atomic_requests diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index ed4cc95b8b..1415541893 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -67,7 +67,7 @@ urlpatterns = [ # Reports.. path('get_course_survey_results', api.GetCourseSurveyResults.as_view(), name='get_course_survey_results'), path('export_ora2_data', api.ExportOra2DataView.as_view(), name='export_ora2_data'), - path('export_ora2_summary', api.export_ora2_summary, name='export_ora2_summary'), + path('export_ora2_summary', api.ExportOra2SummaryView.as_view(), name='export_ora2_summary'), path('export_ora2_submission_files', api.export_ora2_submission_files, name='export_ora2_submission_files'), diff --git a/lms/djangoapps/instructor_task/tests/test_integration.py b/lms/djangoapps/instructor_task/tests/test_integration.py index 004cba1cda..267f8021cd 100644 --- a/lms/djangoapps/instructor_task/tests/test_integration.py +++ b/lms/djangoapps/instructor_task/tests/test_integration.py @@ -22,6 +22,7 @@ from django.urls import reverse from xmodule.capa.responsetypes import StudentInputError from xmodule.capa.tests.response_xml_factory import CodeResponseXMLFactory, CustomResponseXMLFactory +from xmodule.capa.tests.test_util import use_unsafe_codejail from lms.djangoapps.courseware.model_data import StudentModule from lms.djangoapps.grades.api import CourseGradeFactory from lms.djangoapps.instructor_task.api import ( @@ -71,6 +72,7 @@ class TestIntegrationTask(InstructorTaskModuleTestCase): @ddt.ddt @override_settings(RATELIMIT_ENABLE=False) +@use_unsafe_codejail() class TestRescoringTask(TestIntegrationTask): """ Integration-style tests for rescoring problems in a background task. diff --git a/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py index 7902990fcc..0bfef6d0ac 100644 --- a/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py +++ b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py @@ -188,7 +188,7 @@ class Command(BaseCommand): return True site = Site.objects.get_current() - account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/') + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') message_context = get_base_template_context(site) message_context.update({ 'platform_name': settings.PLATFORM_NAME, diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index 4091532bd0..5caede3dab 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -251,7 +251,7 @@ class IDVerificationService: Returns a string: Returns URL for IDV on Account Microfrontend """ - account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/') + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') location = f'{account_base_url}/id-verification' if course_id: location += f'?course_id={quote(str(course_id))}' diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 0552611902..deda08e0c7 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -1128,7 +1128,7 @@ def results_callback(request): # lint-amnesty, pylint: disable=too-many-stateme log.info("[COSMO-184] Denied verification for receipt_id={receipt_id}.".format(receipt_id=receipt_id)) attempt.deny(json.dumps(reason), error_code=error_code) - account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/') + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') reverify_url = f'{account_base_url}/id-verification' verification_status_email_vars['reasons'] = reason verification_status_email_vars['reverify_url'] = reverify_url diff --git a/lms/envs/common.py b/lms/envs/common.py index 3ace69ab06..7097b4c2a0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5411,6 +5411,10 @@ NOTIFICATION_TYPE_ICONS = {} DEFAULT_NOTIFICATION_ICON_URL = "" NOTIFICATION_DIGEST_LOGO = DEFAULT_EMAIL_LOGO_URL +############## SELF PACED EMAIL ############## +SELF_PACED_BANNER_URL = "" +SELF_PACED_CLOUD_URL = "" + ############## NUDGE EMAILS ############### # .. setting_name: DISABLED_ORGS_FOR_PROGRAM_NUDGE # .. setting_default: [] diff --git a/lms/envs/test.py b/lms/envs/test.py index 46ff557895..628195b902 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -651,28 +651,40 @@ TOKEN_SIGNING = { 'JWT_ISSUER': 'token-test-issuer', 'JWT_SIGNING_ALGORITHM': 'RS512', 'JWT_SUPPORTED_VERSION': '1.2.0', - 'JWT_PRIVATE_SIGNING_JWK': '''{ - "e": "AQAB", - "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ", - "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", - "q": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE", - "p": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", - "kid": "token-test-sign", "kty": "RSA" - }''', - 'JWT_PUBLIC_SIGNING_JWK_SET': '''{ - "keys": [ + 'JWT_PRIVATE_SIGNING_JWK': """ + { + "kid": "token-test-sign", + "kty": "RSA", + "key_ops": [ + "sign" + ], + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "e": "AQAB", + "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ", + "p": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE", + "q": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", + "dp": "Azh08H8r2_sJuBXAzx_mQ6iZnAZQ619PnJFOXjTqnMgcaK8iSHLL2CgDIUQwteUcBphgP0uBrfWIBs5jmM8rUtVz4CcrPb5jdjhHjuu4NxmnFbPlhNoOp8OBUjPP3S-h-fPoaFjxDrUqz_zCdPVzp4S6UTkf6Hu-SiI9CFVFZ8E", + "dq": "WQ44_KTIbIej9qnYUPMA1DoaAF8ImVDIdiOp9c79dC7FvCpN3w-lnuugrYDM1j9Tk5bRrY7-JuE6OaKQgOtajoS1BIxjYHj5xAVPD15CVevOihqeq5Zx0ZAAYmmCKRrfUe0iLx2QnIcoKH1-Azs23OXeeo6nysznZjvv9NVJv60", + "qi": "KSWGH607H1kNG2okjYdmVdNgLxTUB-Wye9a9FNFE49UmQIOJeZYXtDzcjk8IiK3g-EU3CqBeDKVUgHvHFu4_Wj3IrIhKYizS4BeFmOcPDvylDQCmJcC9tXLQgHkxM_MEJ7iLn9FOLRshh7GPgZphXxMhezM26Cz-8r3_mACHu84" + } + """, # noqa: E501, + + 'JWT_PUBLIC_SIGNING_JWK_SET': """ + { + "keys": [ { - "kid":"token-test-wrong-key", - "e": "AQAB", - "kty": "RSA", - "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dffgRQLD1qf5D6sprmYfWVokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ" + "kid": "token-test-sign", + "kty": "RSA", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "e": "AQAB" }, { - "kid":"token-test-sign", - "e": "AQAB", - "kty": "RSA", - "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ" + "kid": "token-test-wrong-key", + "kty": "RSA", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "e": "AQAB" } - ] - }''', + ] + } + """, # noqa: E501 } diff --git a/lms/lib/courseware_search/lms_filter_generator.py b/lms/lib/courseware_search/lms_filter_generator.py index b0c0564df4..5b2592e4cd 100644 --- a/lms/lib/courseware_search/lms_filter_generator.py +++ b/lms/lib/courseware_search/lms_filter_generator.py @@ -9,6 +9,7 @@ from openedx.core.djangoapps.course_groups.partition_scheme import CohortPartiti from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme from common.djangoapps.student.models import CourseEnrollment +from xmodule.course_block import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE INCLUDE_SCHEMES = [CohortPartitionScheme, RandomUserPartitionScheme, ] SCHEME_SUPPORTS_ASSIGNMENT = [RandomUserPartitionScheme, ] @@ -63,6 +64,6 @@ class LmsSearchFilterGenerator(SearchFilterGenerator): if not getattr(settings, "SEARCH_SKIP_INVITATION_ONLY_FILTERING", True): exclude_dictionary['invitation_only'] = True if not getattr(settings, "SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING", True): - exclude_dictionary['catalog_visibility'] = 'none' + exclude_dictionary['catalog_visibility'] = [CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE] return exclude_dictionary diff --git a/lms/lib/courseware_search/test/test_lms_filter_generator.py b/lms/lib/courseware_search/test/test_lms_filter_generator.py index 492cf64d8c..8afa94f70f 100644 --- a/lms/lib/courseware_search/test/test_lms_filter_generator.py +++ b/lms/lib/courseware_search/test/test_lms_filter_generator.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch from lms.lib.courseware_search.lms_filter_generator import LmsSearchFilterGenerator from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory +from xmodule.course_block import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -139,3 +140,9 @@ class LmsSearchFilterGeneratorTestCase(ModuleStoreTestCase): assert 'org' not in exclude_dictionary assert 'org' in field_dictionary assert ['TestSite3'] == field_dictionary['org'] + + @patch('django.conf.settings.SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING', False) + def test_excludes_catalog_visibility(self): + _, _, exclude_dictionary = LmsSearchFilterGenerator.generate_field_filters(user=self.user) + assert 'catalog_visibility' in exclude_dictionary + assert exclude_dictionary['catalog_visibility'] == [CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE] diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py index b56b3fe97b..43ba900022 100644 --- a/openedx/core/djangoapps/notifications/email/utils.py +++ b/openedx/core/djangoapps/notifications/email/utils.py @@ -97,7 +97,7 @@ def create_email_template_context(username): 'channel': 'email', 'value': False } - account_base_url = settings.ACCOUNT_MICROFRONTEND_URL.rstrip('/') + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') return { "platform_name": settings.PLATFORM_NAME, "mailing_address": settings.CONTACT_MAILING_ADDRESS, diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py index 40d565962b..b3dce6f054 100644 --- a/openedx/core/djangoapps/schedules/resolvers.py +++ b/openedx/core/djangoapps/schedules/resolvers.py @@ -548,6 +548,8 @@ class CourseNextSectionUpdate(PrefixedDebugLoggerMixin, RecipientResolver): 'course_id': str(course.id), 'course_ids': [str(course.id)], 'unsubscribe_url': unsubscribe_url, + 'self_paced_banner_url': settings.SELF_PACED_BANNER_URL, + 'self_paced_cloud_url': settings.SELF_PACED_CLOUD_URL, }) template_context.update(_get_upsell_information_for_schedule(user, schedule)) diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/base_body.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/base_body.html new file mode 100644 index 0000000000..80021696a4 --- /dev/null +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/base_body.html @@ -0,0 +1,91 @@ +{% load django_markup %} +{% load i18n %} + +{% load ace %} + +{% load acetags %} + +{% get_current_language as LANGUAGE_CODE %} +{% get_current_language_bidi as LANGUAGE_BIDI %} + +{# This is preview text that is visible in the inbox view of many email clients but not visible in the actual #} +{# email itself. #} + +
    +{% block preview_text %}{% endblock %} +
    + +{% for image_src in channel.tracker_image_sources %} + +{% endfor %} + +{% google_analytics_tracking_pixel %} + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html index fd43f9933b..b4be9cdfb9 100644 --- a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html @@ -1,6 +1,8 @@ -{% extends 'ace_common/edx_ace/common/base_body.html' %} +{% extends 'schedules/edx_ace/courseupdate/email/base_body.html' %} + {% load i18n %} {% load django_markup %} +{% load static %} {% block preview_text %} {% filter force_escape %} @@ -11,38 +13,123 @@ {% endblock %} {% block content %} - +
    + +{% if route_enabled %} +{% endif %} + + + + + + + + + + + + + + + +
    -

    - {% blocktrans trimmed asvar tmsg %} - We hope you're enjoying {start_strong}{course_name}{end_strong}! - We want to let you know what you can look forward to in week {week_num}: - {% endblocktrans %} - {% interpolate_html tmsg start_strong=''|safe end_strong=''|safe course_name=course_name|force_escape|safe week_num=week_num|force_escape|safe %} -

      - {% for highlight in week_highlights %} -
    • {{ highlight }}
    • - {% endfor %} -
    -

    {% filter force_escape %} - {% blocktrans trimmed %} - With self-paced courses, you learn on your own schedule. - We encourage you to spend time with the course each week. - Your focused attention will pay off in the end! - {% endblocktrans %} + {% blocktrans %}This is a routed Account Activation email for {{ routed_profile_name }} ({{ routed_user_email }}): {{ routed_profile_name }}{% endblocktrans %} {% endfilter %} +

    - - {% filter force_escape %} - {% blocktrans asvar course_cta_text %}Resume your course now{% endblocktrans %} - {% endfilter %} - {% include "ace_common/edx_ace/common/return_to_course_cta.html" with course_cta_text=course_cta_text%} - - {% include "ace_common/edx_ace/common/upsell_cta.html"%}
    + {% trans 'Welcome to edX. It’s time for your next career move' as tmsg %}{{ tmsg | force_escape }} +
    +

    + {% trans "We hope you’re enjoying Introduction to Data Science with Python!" as tmsg %}{{ tmsg | force_escape }} +

    +

    + {% trans "We want to let you know what you can look forward to in week two: " as tmsg %}{{ tmsg | force_escape }} +

    +
      +
    • +

      + {% trans "Learn kNN regression" as tmsg %}{{ tmsg | force_escape }} +

      +
    • +
    • +

      + {% trans "Learn linear regression" as tmsg %}{{ tmsg | force_escape }} +

      +
    • +
    • +

      + {% trans "Find out how to choose which model you want" as tmsg %}{{ tmsg | force_escape }} +

      +
    • +
    +
    + {% filter force_escape %} + {% blocktrans asvar course_cta_text %}Resume your course {% endblocktrans %} + {% endfilter %} + {% include "schedules/edx_ace/courseupdate/email/return_to_course_cta.html" with course_cta_text=course_cta_text%} +
    +   +
    + + + + + + + + + +
    +

    + {% trans "Your focused attention will pay off in the end! " as tmsg %}{{ tmsg | force_escape }} + {% trans "With self-paced courses, you learn on your own schedule. It’s a good idea to spend time with the course each week and check in with your goals often." as tmsg %}{{ tmsg | force_escape }} +

    +
    + Message Icon +
    +
    {% endblock %} + +{% block footer%} +{%include 'schedules/edx_ace/courseupdate/email/footer.html'%} +{% endblock%} diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/footer.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/footer.html new file mode 100644 index 0000000000..a7081d0bd3 --- /dev/null +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/footer.html @@ -0,0 +1,195 @@ +{% load django_markup %} +{% load i18n %} +{% load ace %} +{% load acetags %} +{% load static %} + + + + {% if confirm_activation_link %} + {% endif %} + + + +
    + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + +
    + + + + + + +
    + + + + + + +
    + + + + + + + +
    + + {% filter force_escape %}{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}{% endfilter %} +
    + + + + + + +
    + + + + {% if social_media_urls.facebook %} + + {% endif %} + {% if social_media_urls.instagram %} + + {% endif %} + {% if social_media_urls.linkedin %} + + {% endif %} + {% if social_media_urls.twitter %} + + {% endif %} + {% if social_media_urls.reddit %} + + {% endif %} + + +
    + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on LinkedIn{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Twitter{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Reddit{% endblocktrans %}{% endfilter %} + +
    +
    +
    +
    +
    + + + + + + +
    + + + + + + +
    + + + + {% if mobile_store_urls.apple %} + + {% endif %} + + {% if mobile_store_urls.google %} + + {% endif %} + + +
    + + {% trans + + + + {% trans + +
    +
    +
    +
    + {% if disclaimer %} + {{ disclaimer }}
    + {% endif %} + {% trans "edX is the trusted platform for education and learning" as tmsg %}{{ tmsg | force_escape }}.
    +
    + © {% now "Y" %} {{ platform_name }} LLC. {% trans "All rights reserved" as tmsg %}{{ tmsg | force_escape }}.
    +
    + {% if unsubscribe_link %} + + {%if unsubscribe_text%} {{unsubscribe_text}} {%else%} {% trans "Unsubscribe from these emails." as tmsg %}{{ tmsg | force_escape }} {%endif%} +
    +
    + {% endif %} + {{ contact_mailing_address }} +
    +
    +
    \ No newline at end of file diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html index 366ada7ad9..602b11e4ae 100644 --- a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html @@ -1 +1,40 @@ -{% extends 'ace_common/edx_ace/common/base_head.html' %} +{% load django_markup %} +{% load i18n %} +{% load ace %} +{% load acetags %} +{% load static %} + + + + + +
    + + + + +
    + + + + + + +
     
    + + + + +
    + + + + +
    + + {% filter force_escape %}{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}{% endfilter %} +
    +
    +
     
    +
    +
    \ No newline at end of file diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/return_to_course_cta.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/return_to_course_cta.html new file mode 100644 index 0000000000..a0f4f8b557 --- /dev/null +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/return_to_course_cta.html @@ -0,0 +1,35 @@ +{% load i18n %} +{% load ace %} + +

    + {# email client support for style sheets is pretty spotty, so we have to inline all of these styles #} + 1 %} + href="{% with_link_tracking dashboard_url %}" + {% else %} + href="{% with_link_tracking course_url %}" + {% endif %} + {% endif %} + style=" + text-decoration: none; + color: white; + background-color: #ED5C13; + text-align: center; + vertical-align: middle; + user-select: none; + font-weight: 500; + font-size: 12px; + text-decoration-style: solid; + display: inline-flex; + flex-direction: row; + border-radius: 30.22px; + "> + {# old email clients require the use of the font tag :( #} + {{ course_cta_text }} + +

    diff --git a/openedx/core/djangoapps/schedules/tests/test_resolvers.py b/openedx/core/djangoapps/schedules/tests/test_resolvers.py index a23c9dbb21..2c37608e5c 100644 --- a/openedx/core/djangoapps/schedules/tests/test_resolvers.py +++ b/openedx/core/djangoapps/schedules/tests/test_resolvers.py @@ -271,6 +271,8 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor @override_settings(CONTACT_MAILING_ADDRESS='123 Sesame Street') @override_settings(LOGO_URL_PNG='https://www.logo.png') + @override_settings(SELF_PACED_BANNER_URL='') + @override_settings(SELF_PACED_CLOUD_URL='') def test_schedule_context(self): resolver = self.create_resolver() # using this to make sure the select_related stays intact @@ -316,6 +318,8 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor 'twitter': twitter_url}, 'template_revision': 'release', 'unsubscribe_url': None, + 'self_paced_banner_url': '', + 'self_paced_cloud_url': '', 'week_highlights': ['good stuff 2'], 'week_num': 2, } diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 1c92aa22c7..466e1e278a 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -403,17 +403,18 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe assert data['social_links'] is not None assert data['time_zone'] is None - def _verify_private_account_response(self, response, requires_parental_consent=False): + def _verify_private_account_response(self, response, requires_parental_consent=False, has_profile_image=True): """ Verify that only the public fields are returned if a user does not want to share account fields """ data = response.data assert 3 == len(data) assert PRIVATE_VISIBILITY == data['account_privacy'] - self._verify_profile_image_data(data, not requires_parental_consent) + self._verify_profile_image_data(data, has_profile_image) assert self.user.username == data['username'] - def _verify_full_account_response(self, response, requires_parental_consent=False, year_of_birth=2000): + def _verify_full_account_response(self, response, requires_parental_consent=False, + has_profile_image=True, year_of_birth=2000): """ Verify that all account fields are returned (even those that are not shareable). """ @@ -426,7 +427,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe UserPreference.get_value(self.user, 'account_privacy') ) assert expected_account_privacy == data['account_privacy'] - self._verify_profile_image_data(data, not requires_parental_consent) + self._verify_profile_image_data(data, has_profile_image) assert self.user.username == data['username'] # additional shareable fields (8) @@ -1271,11 +1272,11 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe assert data['requires_parental_consent'] assert PRIVATE_VISIBILITY == data['account_privacy'] else: - self._verify_private_account_response(response, requires_parental_consent=True) + self._verify_private_account_response(response, requires_parental_consent=True, has_profile_image=False) # Verify that the shared view is still private response = self.send_get(client, query_parameters='view=shared') - self._verify_private_account_response(response, requires_parental_consent=True) + self._verify_private_account_response(response, requires_parental_consent=True, has_profile_image=False) @skip_unless_lms diff --git a/openedx/core/djangoapps/user_authn/tests/test_cookies.py b/openedx/core/djangoapps/user_authn/tests/test_cookies.py index d4d9a59fc9..aa2e687102 100644 --- a/openedx/core/djangoapps/user_authn/tests/test_cookies.py +++ b/openedx/core/djangoapps/user_authn/tests/test_cookies.py @@ -1,8 +1,9 @@ # pylint: disable=missing-docstring -from datetime import date +from datetime import date, datetime import json +from pytz import UTC from unittest.mock import MagicMock, patch from urllib.parse import urljoin from django.conf import settings @@ -20,6 +21,10 @@ from common.djangoapps.student.tests.factories import AnonymousUserFactory, User from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file from openedx.core.djangoapps.profile_images.images import create_profile_images from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user + + +TEST_PROFILE_IMAGE_UPLOAD_DT = datetime(2002, 1, 9, 15, 43, 1, tzinfo=UTC) class CookieTests(TestCase): @@ -27,6 +32,8 @@ class CookieTests(TestCase): super().setUp() self.user = UserFactory.create() self.user.profile = UserProfileFactory.create(user=self.user) + self.user.profile.profile_image_uploaded_at = TEST_PROFILE_IMAGE_UPLOAD_DT + self.user.profile.save() # lint-amnesty, pylint: disable=no-member self.request = RequestFactory().get('/') self.request.user = self.user self.request.session = self._get_stub_session() @@ -43,18 +50,6 @@ class CookieTests(TestCase): return urls_obj - def _get_expected_image_urls(self): - expected_image_urls = { - 'full': '/static/default_500.png', - 'large': '/static/default_120.png', - 'medium': '/static/default_50.png', - 'small': '/static/default_30.png' - } - - expected_image_urls = self._convert_to_absolute_uris(self.request, expected_image_urls) - - return expected_image_urls - def _get_expected_header_urls(self): expected_header_urls = { 'logout': reverse('logout'), @@ -112,7 +107,7 @@ class CookieTests(TestCase): 'username': self.user.username, 'email': self.user.email, 'header_urls': self._get_expected_header_urls(), - 'user_image_urls': self._get_expected_image_urls(), + 'user_image_urls': get_profile_image_urls_for_user(self.user), } self.assertDictEqual(actual, expected) diff --git a/openedx/core/lib/jwt.py b/openedx/core/lib/jwt.py index 47642b8695..199c6fead8 100644 --- a/openedx/core/lib/jwt.py +++ b/openedx/core/lib/jwt.py @@ -2,12 +2,12 @@ JWT Token handling and signing functions. """ -import json +import jwt from time import time from django.conf import settings -from jwkest import Expired, Invalid, MissingKey, jwk -from jwkest.jws import JWS +from jwt.api_jwk import PyJWK, PyJWKSet +from jwt.exceptions import ExpiredSignatureError, InvalidSignatureError, MissingRequiredClaimError def create_jwt(lms_user_id, expires_in_seconds, additional_token_claims, now=None): @@ -40,15 +40,9 @@ def _encode_and_sign(payload): The signing key and algorithm are pulled from settings. """ - keys = jwk.KEYS() - - serialized_keypair = json.loads(settings.TOKEN_SIGNING['JWT_PRIVATE_SIGNING_JWK']) - keys.add(serialized_keypair) + private_key = PyJWK.from_json(settings.TOKEN_SIGNING['JWT_PRIVATE_SIGNING_JWK']) algorithm = settings.TOKEN_SIGNING['JWT_SIGNING_ALGORITHM'] - - data = json.dumps(payload) - jws = JWS(data, alg=algorithm) - return jws.sign_compact(keys=keys) + return jwt.encode(payload, key=private_key.key, algorithm=algorithm) def unpack_jwt(token, lms_user_id, now=None): @@ -65,27 +59,40 @@ def unpack_jwt(token, lms_user_id, now=None): Returns a valid, decoded json payload (string). """ now = now or int(time()) - payload = _unpack_and_verify(token) + payload = unpack_and_verify(token) if "lms_user_id" not in payload: - raise MissingKey("LMS user id is missing") + raise MissingRequiredClaimError("LMS user id is missing") if "exp" not in payload: - raise MissingKey("Expiration is missing") + raise MissingRequiredClaimError("Expiration is missing") if payload["lms_user_id"] != lms_user_id: - raise Invalid("User does not match") + raise InvalidSignatureError("User does not match") if payload["exp"] < now: - raise Expired("Token is expired") + raise ExpiredSignatureError("Token is expired") return payload -def _unpack_and_verify(token): +def unpack_and_verify(token): # pylint: disable=inconsistent-return-statements """ Unpack and verify the provided token. The signing key and algorithm are pulled from settings. """ - keys = jwk.KEYS() - keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET']) - decoded = JWS().verify_compact(token.encode('utf-8'), keys) - return decoded + key_set = [] + key_set.extend( + PyJWKSet.from_json(settings.TOKEN_SIGNING["JWT_PUBLIC_SIGNING_JWK_SET"]).keys + ) + + for i in range(len(key_set)): # pylint: disable=consider-using-enumerate + try: + decoded = jwt.decode( + token, + key=key_set[i].key, + algorithms=["RS256", "RS512"], + options={"verify_signature": True, "verify_aud": False}, + ) + return decoded + except Exception: # pylint: disable=broad-exception-caught + if i == len(key_set) - 1: + raise diff --git a/openedx/core/lib/tests/test_jwt.py b/openedx/core/lib/tests/test_jwt.py index 7a678dd3c0..da1f047e48 100644 --- a/openedx/core/lib/tests/test_jwt.py +++ b/openedx/core/lib/tests/test_jwt.py @@ -2,24 +2,23 @@ Tests for token handling """ import unittest +from time import time -from django.conf import settings -from jwkest import BadSignature, Expired, Invalid, MissingKey, jwk -from jwkest.jws import JWS +from jwt.exceptions import ExpiredSignatureError, InvalidSignatureError, MissingRequiredClaimError from openedx.core.djangolib.testing.utils import skip_unless_lms -from openedx.core.lib.jwt import _encode_and_sign, create_jwt, unpack_jwt +from openedx.core.lib.jwt import _encode_and_sign, create_jwt, unpack_jwt, unpack_and_verify test_user_id = 121 invalid_test_user_id = 120 -test_timeout = 60 -test_now = 1661432902 +test_timeout = 1000 +test_now = int(time()) test_claims = {"foo": "bar", "baz": "quux", "meaning": 42} expected_full_token = { "lms_user_id": test_user_id, - "iat": 1661432902, - "exp": 1661432902 + 60, + "iat": test_now, + "exp": test_now + test_timeout, "iss": "token-test-issuer", # these lines from test_settings.py "version": "1.2.0", # these lines from test_settings.py } @@ -34,7 +33,7 @@ class TestSign(unittest.TestCase): def test_create_jwt(self): token = create_jwt(test_user_id, test_timeout, {}, test_now) - decoded = _verify_jwt(token) + decoded = unpack_and_verify(token) self.assertEqual(expected_full_token, decoded) def test_create_jwt_with_claims(self): @@ -43,7 +42,7 @@ class TestSign(unittest.TestCase): expected_token_with_claims = expected_full_token.copy() expected_token_with_claims.update(test_claims) - decoded = _verify_jwt(token) + decoded = unpack_and_verify(token) self.assertEqual(expected_token_with_claims, decoded) def test_malformed_token(self): @@ -53,19 +52,8 @@ class TestSign(unittest.TestCase): expected_token_with_claims = expected_full_token.copy() expected_token_with_claims.update(test_claims) - with self.assertRaises(BadSignature): - _verify_jwt(token) - - -def _verify_jwt(jwt_token): - """ - Helper function which verifies the signature and decodes the token - from string back to claims form - """ - keys = jwk.KEYS() - keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET']) - decoded = JWS().verify_compact(jwt_token.encode('utf-8'), keys) - return decoded + with self.assertRaises(InvalidSignatureError): + unpack_and_verify(token) @skip_unless_lms @@ -97,19 +85,19 @@ class TestUnpack(unittest.TestCase): expected_token_with_claims = expected_full_token.copy() expected_token_with_claims.update(test_claims) - with self.assertRaises(BadSignature): + with self.assertRaises(InvalidSignatureError): unpack_jwt(token, test_user_id, test_now) def test_unpack_token_with_invalid_user(self): token = create_jwt(invalid_test_user_id, test_timeout, {}, test_now) - with self.assertRaises(Invalid): + with self.assertRaises(InvalidSignatureError): unpack_jwt(token, test_user_id, test_now) def test_unpack_expired_token(self): token = create_jwt(test_user_id, test_timeout, {}, test_now) - with self.assertRaises(Expired): + with self.assertRaises(ExpiredSignatureError): unpack_jwt(token, test_user_id, test_now + test_timeout + 1) def test_missing_expired_lms_user_id(self): @@ -117,7 +105,7 @@ class TestUnpack(unittest.TestCase): del payload['lms_user_id'] token = _encode_and_sign(payload) - with self.assertRaises(MissingKey): + with self.assertRaises(MissingRequiredClaimError): unpack_jwt(token, test_user_id, test_now) def test_missing_expired_key(self): @@ -125,5 +113,5 @@ class TestUnpack(unittest.TestCase): del payload['exp'] token = _encode_and_sign(payload) - with self.assertRaises(MissingKey): + with self.assertRaises(MissingRequiredClaimError): unpack_jwt(token, test_user_id, test_now) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 0b1bb96a59..260ec2bb81 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -51,7 +51,7 @@ django-stubs<6 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==6.0.3 +edx-enterprise==6.2.5 # Date: 2023-07-26 # Our legacy Sass code is incompatible with anything except this ancient libsass version. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index f800d10a2d..c1809ae42d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -421,7 +421,7 @@ edx-celeryutils==1.4.0 # -r requirements/edx/kernel.in # edx-name-affirmation # super-csv -edx-codejail==3.5.2 +edx-codejail==4.0.0 # via -r requirements/edx/kernel.in edx-completion==4.9 # via -r requirements/edx/kernel.in @@ -461,7 +461,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.0.3 +edx-enterprise==6.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index c49f64a635..b928da2b66 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -691,7 +691,7 @@ edx-celeryutils==1.4.0 # -r requirements/edx/testing.txt # edx-name-affirmation # super-csv -edx-codejail==3.5.2 +edx-codejail==4.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -740,7 +740,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.0.3 +edx-enterprise==6.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 71adb423f6..b5413f24f4 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -505,7 +505,7 @@ edx-celeryutils==1.4.0 # -r requirements/edx/base.txt # edx-name-affirmation # super-csv -edx-codejail==3.5.2 +edx-codejail==4.0.0 # via -r requirements/edx/base.txt edx-completion==4.9 # via -r requirements/edx/base.txt @@ -545,7 +545,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.0.3 +edx-enterprise==6.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index caec5c8c04..fcc502755c 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -66,7 +66,8 @@ edx-celeryutils edx-completion edx-django-release-util # Release utils for the edx release pipeline edx-django-sites-extensions -edx-codejail +# Codejail 4 brings important safety improvements (no unsafe mode by default) +edx-codejail>=4.0.0 # edx-django-utils 5.14.1 adds FrontendMonitoringMiddleware edx-django-utils>=5.14.1 # Utilities for cache, monitoring, and plugins edx-drf-extensions diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 16ba8528f5..8e0301ac7e 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -530,7 +530,7 @@ edx-celeryutils==1.4.0 # -r requirements/edx/base.txt # edx-name-affirmation # super-csv -edx-codejail==3.5.2 +edx-codejail==4.0.0 # via -r requirements/edx/base.txt edx-completion==4.9 # via -r requirements/edx/base.txt @@ -570,7 +570,7 @@ edx-drf-extensions==10.6.0 # edx-when # edxval # openedx-learning -edx-enterprise==6.0.3 +edx-enterprise==6.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/xmodule/capa/safe_exec/tests/test_safe_exec.py b/xmodule/capa/safe_exec/tests/test_safe_exec.py index d09f8c9d9b..d7679b66aa 100644 --- a/xmodule/capa/safe_exec/tests/test_safe_exec.py +++ b/xmodule/capa/safe_exec/tests/test_safe_exec.py @@ -24,8 +24,10 @@ from openedx.core.djangolib.testing.utils import skip_unless_lms from xmodule.capa.safe_exec import safe_exec, update_hash from xmodule.capa.safe_exec.remote_exec import is_codejail_in_darklaunch, is_codejail_rest_service_enabled from xmodule.capa.safe_exec.safe_exec import emsg_normalizers, normalize_error_message +from xmodule.capa.tests.test_util import use_unsafe_codejail +@use_unsafe_codejail() class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring def test_set_values(self): g = {} @@ -530,6 +532,7 @@ class DictCache(object): self.cache[key] = value +@use_unsafe_codejail() class TestSafeExecCaching(unittest.TestCase): """Test that caching works on safe_exec.""" @@ -654,6 +657,7 @@ class TestUpdateHash(unittest.TestCase): assert h1 == h2 +@use_unsafe_codejail() class TestRealProblems(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring def test_802x(self): code = textwrap.dedent("""\ diff --git a/xmodule/capa/tests/test_capa_problem.py b/xmodule/capa/tests/test_capa_problem.py index 74cf4d096f..5f42b91849 100644 --- a/xmodule/capa/tests/test_capa_problem.py +++ b/xmodule/capa/tests/test_capa_problem.py @@ -15,6 +15,7 @@ from markupsafe import Markup from xmodule.capa.correctmap import CorrectMap from xmodule.capa.responsetypes import LoncapaProblemError from xmodule.capa.tests.helpers import new_loncapa_problem +from xmodule.capa.tests.test_util import use_unsafe_codejail from openedx.core.djangolib.markup import HTML @@ -23,6 +24,7 @@ FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS['ENABLE_GRADING_METHOD_IN_PROBLEMS'] = @ddt.ddt +@use_unsafe_codejail() class CAPAProblemTest(unittest.TestCase): """ CAPA problem related tests""" @@ -424,6 +426,7 @@ class CAPAProblemTest(unittest.TestCase): @ddt.ddt +@use_unsafe_codejail() class CAPAMultiInputProblemTest(unittest.TestCase): """ TestCase for CAPA problems with multiple inputtypes """ diff --git a/xmodule/capa/tests/test_html_render.py b/xmodule/capa/tests/test_html_render.py index 0af5f1198e..46ad47d79a 100644 --- a/xmodule/capa/tests/test_html_render.py +++ b/xmodule/capa/tests/test_html_render.py @@ -11,12 +11,14 @@ from unittest import mock import ddt from lxml import etree from xmodule.capa.tests.helpers import new_loncapa_problem, mock_capa_system +from xmodule.capa.tests.test_util import use_unsafe_codejail from openedx.core.djangolib.markup import HTML from .response_xml_factory import CustomResponseXMLFactory, StringResponseXMLFactory @ddt.ddt +@use_unsafe_codejail() class CapaHtmlRenderTest(unittest.TestCase): """ CAPA HTML rendering tests class. diff --git a/xmodule/capa/tests/test_responsetypes.py b/xmodule/capa/tests/test_responsetypes.py index fa0c97fb15..ca9f5eba59 100644 --- a/xmodule/capa/tests/test_responsetypes.py +++ b/xmodule/capa/tests/test_responsetypes.py @@ -37,6 +37,7 @@ from xmodule.capa.tests.response_xml_factory import ( SymbolicResponseXMLFactory, TrueFalseResponseXMLFactory ) +from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.util import convert_files_to_filenames from xmodule.capa.xqueue_interface import dateformat @@ -108,6 +109,7 @@ class ResponseTest(unittest.TestCase): return str(rand.randint(0, 1e9)) +@use_unsafe_codejail() class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = MultipleChoiceResponseXMLFactory @@ -375,6 +377,7 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst assert correct_map.get_correctness('1_2_1') == expected_correctness +@use_unsafe_codejail() class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = OptionResponseXMLFactory @@ -422,6 +425,7 @@ class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstri assert correct_map.get_property('1_2_1', 'answervariable') == '$a' +@use_unsafe_codejail() class FormulaResponseTest(ResponseTest): """ Test the FormulaResponse class @@ -571,6 +575,7 @@ class FormulaResponseTest(ResponseTest): assert not list(problem.responders.values())[0].validate_answer('3*y+2*x') +@use_unsafe_codejail() class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = StringResponseXMLFactory @@ -1124,6 +1129,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring assert output[answer_id]['msg'] == 'Invalid grader reply. Please contact the course staff.' +@use_unsafe_codejail() class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = ChoiceResponseXMLFactory @@ -1292,6 +1298,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri self.assert_grade(problem, ['choice_1', 'choice_3'], 'incorrect') +@use_unsafe_codejail() class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = NumericalResponseXMLFactory @@ -1680,6 +1687,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs assert not responder.validate_answer('fish') +@use_unsafe_codejail() class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = CustomResponseXMLFactory @@ -2399,6 +2407,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri assert correct_map.get_msg('1_2_11') == '11' +@use_unsafe_codejail() class SchematicResponseTest(ResponseTest): """ Class containing setup and tests for Schematic responsetype. @@ -2488,6 +2497,7 @@ class AnnotationResponseTest(ResponseTest): # lint-amnesty, pylint: disable=mis assert expected_points == actual_points, ('%s should have %d points' % (answer_id, expected_points)) +@use_unsafe_codejail() class ChoiceTextResponseTest(ResponseTest): """ Class containing setup and tests for ChoiceText responsetype. diff --git a/xmodule/capa/tests/test_util.py b/xmodule/capa/tests/test_util.py index 92bc039cfa..3176ff9b9a 100644 --- a/xmodule/capa/tests/test_util.py +++ b/xmodule/capa/tests/test_util.py @@ -6,7 +6,9 @@ Tests capa util import unittest +import codejail.safe_exec import ddt +from django.test.utils import TestContextDecorator from lxml import etree from xmodule.capa.tests.helpers import mock_capa_system @@ -167,3 +169,28 @@ class UtilTest(unittest.TestCase): expected_text = '$あなたあなたあなたあなた あなたhi' contextual_text = contextualize_text(text, context) assert expected_text == contextual_text + + +class use_unsafe_codejail(TestContextDecorator): + """ + Tell codejail to run in unsafe mode for the scope of the decorator. + Use this as a decorator on Django TestCase classes or methods. + + This is needed because codejail has significant OS-level setup requirements + which we don't even attempt to fulfill for unit testing purposes. Running + tests in unsafe mode (that is, running code executions in-process, with no + sandboxing) is only safe because we control the contents of the unit tests. + It's not a perfect replica of how safe mode operates but it's generally good + enough for testing the integration and overall behavior. + """ + + def __init__(self): + self.old_be_unsafe = None + super().__init__() + + def enable(self): + self.old_be_unsafe = codejail.safe_exec.ALWAYS_BE_UNSAFE + codejail.safe_exec.ALWAYS_BE_UNSAFE = True + + def disable(self): + codejail.safe_exec.ALWAYS_BE_UNSAFE = self.old_be_unsafe diff --git a/xmodule/tests/test_capa_block.py b/xmodule/tests/test_capa_block.py index b48aa10ef8..21582e55ea 100644 --- a/xmodule/tests/test_capa_block.py +++ b/xmodule/tests/test_capa_block.py @@ -37,6 +37,7 @@ from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, Stude from xmodule.capa.xqueue_interface import XQueueInterface from xmodule.capa_block import ComplexEncoder, ProblemBlock from xmodule.tests import DATA_DIR +from xmodule.capa.tests.test_util import use_unsafe_codejail from ..capa_block import RANDOMIZATION, SHOWANSWER from . import get_test_system @@ -3635,6 +3636,7 @@ class ComplexEncoderTest(unittest.TestCase): # lint-amnesty, pylint: disable=mi @skip_unless_lms +@use_unsafe_codejail() class ProblemCheckTrackingTest(unittest.TestCase): """ Ensure correct tracking information is included in events emitted during problem checks.