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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ {% block content %}{% endblock %}
+ |
+
+
+
+
+
+
+
+
+
+
+
+
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 %}
|
-
- {% 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"%}
|
+{% endif %}
+
+
+
+ |
+
+
+ |
+
+ {% 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 }}
+
+ |
+
+
+ |
+
+
+
+ |
+
{% 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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ {% 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 %}
+
+
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if mobile_store_urls.apple %}
+
+
+
+
+ |
+ {% endif %}
+ |
+ {% if mobile_store_urls.google %}
+
+
+
+
+ |
+ {% endif %}
+
+
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+ |
+
+
+
+
+ {% 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 %}
+
+
+
+
+
+
+
+
+ | |
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+ | |
+
+ |
+
+
+ |
+
+
\ 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.