diff --git a/.github/workflows/check_python_dependencies.yml b/.github/workflows/check_python_dependencies.yml index 85a4e796ce78..b691e68d4be9 100644 --- a/.github/workflows/check_python_dependencies.yml +++ b/.github/workflows/check_python_dependencies.yml @@ -14,18 +14,18 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v4 - + - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - + - name: Install repo-tools run: pip install edx-repo-tools[find_dependencies] - name: Install setuptool - run: pip install setuptools - + run: pip install setuptools + - name: Run Python script run: | find_python_dependencies \ @@ -35,6 +35,5 @@ jobs: --ignore https://github.com/edx/braze-client \ --ignore https://github.com/edx/edx-name-affirmation \ --ignore https://github.com/mitodl/edx-sga \ - --ignore https://github.com/edx/token-utils \ --ignore https://github.com/open-craft/xblock-poll - + diff --git a/.github/workflows/publish-ci-docker-image.yml b/.github/workflows/publish-ci-docker-image.yml deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/cms/djangoapps/api/v1/views/course_runs.py b/cms/djangoapps/api/v1/views/course_runs.py index b405207bd9c6..7b27193d175f 100644 --- a/cms/djangoapps/api/v1/views/course_runs.py +++ b/cms/djangoapps/api/v1/views/course_runs.py @@ -23,6 +23,7 @@ class CourseRunViewSet(viewsets.GenericViewSet): # lint-amnesty, pylint: disabl lookup_value_regex = settings.COURSE_KEY_REGEX permission_classes = (permissions.IsAdminUser,) serializer_class = CourseRunSerializer + queryset = [] def get_object(self): lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field diff --git a/cms/djangoapps/contentstore/api/views/course_import.py b/cms/djangoapps/contentstore/api/views/course_import.py index dd7828c2d94f..3027b1926d0f 100644 --- a/cms/djangoapps/contentstore/api/views/course_import.py +++ b/cms/djangoapps/contentstore/api/views/course_import.py @@ -106,7 +106,7 @@ class CourseImportView(CourseImportExportViewMixin, GenericAPIView): # TODO: ARCH-91 # This view is excluded from Swagger doc generation because it # does not specify a serializer class. - exclude_from_schema = True + swagger_schema = None @course_author_access_required def post(self, request, course_key): diff --git a/cms/djangoapps/contentstore/api/views/course_quality.py b/cms/djangoapps/contentstore/api/views/course_quality.py index 8f647accaa91..b301f5ac1420 100644 --- a/cms/djangoapps/contentstore/api/views/course_quality.py +++ b/cms/djangoapps/contentstore/api/views/course_quality.py @@ -77,6 +77,11 @@ class CourseQualityView(DeveloperErrorViewMixin, GenericAPIView): * mode """ + # TODO: ARCH-91 + # This view is excluded from Swagger doc generation because it + # does not specify a serializer class. + swagger_schema = None + @course_author_access_required def get(self, request, course_key): """ diff --git a/cms/djangoapps/contentstore/api/views/course_validation.py b/cms/djangoapps/contentstore/api/views/course_validation.py index 0fa8d9041c1b..d1c2c2b8c46f 100644 --- a/cms/djangoapps/contentstore/api/views/course_validation.py +++ b/cms/djangoapps/contentstore/api/views/course_validation.py @@ -65,6 +65,11 @@ class CourseValidationView(DeveloperErrorViewMixin, GenericAPIView): * has_proctoring_escalation_email - whether the course has a proctoring escalation email """ + # TODO: ARCH-91 + # This view is excluded from Swagger doc generation because it + # does not specify a serializer class. + swagger_schema = None + @course_author_access_required def get(self, request, course_key): """ diff --git a/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py b/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py index 878a8dabaa27..768c3a53f7ac 100644 --- a/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py +++ b/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py @@ -71,6 +71,5 @@ def handle(self, *args, **options): if error_keys: msg = 'The following courses encountered errors and were not updated:\n' - for error_key in error_keys: - msg += f' - {error_key}\n' + msg += '\n'.join(f' - {error_key}' for error_key in error_keys) logger.info(msg) diff --git a/cms/djangoapps/contentstore/management/commands/export_content_library.py b/cms/djangoapps/contentstore/management/commands/export_content_library.py index 0b4cbfb1fbad..b56c172e374e 100644 --- a/cms/djangoapps/contentstore/management/commands/export_content_library.py +++ b/cms/djangoapps/contentstore/management/commands/export_content_library.py @@ -51,16 +51,15 @@ def handle(self, *args, **options): tarball = tasks.create_export_tarball(library, library_key, {}, None) except Exception as e: raise CommandError(f'Failed to export "{library_key}" with "{e}"') # lint-amnesty, pylint: disable=raise-missing-from - else: - with tarball: - # Save generated archive with keyed filename - prefix, suffix, n = str(library_key).replace(':', '+'), '.tar.gz', 0 - while os.path.exists(prefix + suffix): - n += 1 - prefix = '{}_{}'.format(prefix.rsplit('_', 1)[0], n) if n > 1 else f'{prefix}_1' - filename = prefix + suffix - target = os.path.join(dest_path, filename) - tarball.file.seek(0) - with open(target, 'wb') as f: - shutil.copyfileobj(tarball.file, f) - print(f'Library "{library.location.library_key}" exported to "{target}"') + with tarball: + # Save generated archive with keyed filename + prefix, suffix, n = str(library_key).replace(':', '+'), '.tar.gz', 0 + while os.path.exists(prefix + suffix): + n += 1 + prefix = '{}_{}'.format(prefix.rsplit('_', 1)[0], n) if n > 1 else f'{prefix}_1' + filename = prefix + suffix + target = os.path.join(dest_path, filename) + tarball.file.seek(0) + with open(target, 'wb') as f: + shutil.copyfileobj(tarball.file, f) + print(f'Library "{library.location.library_key}" exported to "{target}"') diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py index e3dd070573aa..e42c3e2ee397 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py @@ -14,7 +14,13 @@ class GradersSerializer(serializers.Serializer): weight = serializers.IntegerField() id = serializers.IntegerField() + class Meta: + ref_name = "authoring_grading.Graders.v0" + class CourseGradingModelSerializer(serializers.Serializer): """ Serializer for course grading model data """ graders = GradersSerializer(many=True, allow_null=True, allow_empty=True) + + class Meta: + ref_name = "authoring_grading.CourseGrading.v0" diff --git a/cms/djangoapps/contentstore/rest_api/v0/urls.py b/cms/djangoapps/contentstore/rest_api/v0/urls.py index cc1e13b0929c..6e0c11d22b8f 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v0/urls.py @@ -63,7 +63,7 @@ authoring_videos.VideoEncodingsDownloadView.as_view(), name='cms_api_videos_encodings' ), re_path( - fr'grading/{settings.COURSE_ID_PATTERN}', + fr'grading/{settings.COURSE_ID_PATTERN}$', AuthoringGradingView.as_view(), name='cms_api_update_grading' ), path( diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py b/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py index 972b6229f55a..8fb66070f3bf 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py @@ -128,6 +128,11 @@ class VideoEncodingsDownloadView(DeveloperErrorViewMixin, RetrieveAPIView): course_key: required argument, needed to authorize course authors and identify relevant videos. """ + # TODO: ARCH-91 + # This view is excluded from Swagger doc generation because it + # does not specify a serializer class. + swagger_schema = None + def dispatch(self, request, *args, **kwargs): # TODO: probably want to refactor this to a decorator. """ @@ -151,6 +156,11 @@ class VideoFeaturesView(DeveloperErrorViewMixin, RetrieveAPIView): public rest API endpoint providing a list of enabled video features. """ + # TODO: ARCH-91 + # This view is excluded from Swagger doc generation because it + # does not specify a serializer class. + swagger_schema = None + def dispatch(self, request, *args, **kwargs): # TODO: probably want to refactor this to a decorator. """ diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py index 9244ffa989b6..3b39f2918a36 100644 --- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py +++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py @@ -555,7 +555,6 @@ def _copy_paste_and_assert_link(key_to_copy): assert new_block.upstream == str(self.lib_block_key) assert new_block.upstream_version == 3 assert new_block.upstream_display_name == "MCQ-draft" - assert new_block.upstream_max_attempts == 5 return new_block_key # first verify link for copied block from library diff --git a/cms/envs/common.py b/cms/envs/common.py index 591247388a9d..7a36bfab3d96 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -843,6 +843,7 @@ # Public domain name of Studio (should be resolvable from the end-user's browser) CMS_BASE = 'localhost:18010' +CMS_ROOT_URL = "https://localhost:18010" LOG_DIR = '/edx/var/log/edx' @@ -2520,6 +2521,8 @@ CREDENTIALS_INTERNAL_SERVICE_URL = 'http://localhost:8005' CREDENTIALS_PUBLIC_SERVICE_URL = 'http://localhost:8005' CREDENTIALS_SERVICE_USERNAME = 'credentials_service_user' +# time between scheduled runs, in seconds +NOTIFY_CREDENTIALS_FREQUENCY = 14400 ANALYTICS_DASHBOARD_URL = 'http://localhost:18110/courses' ANALYTICS_DASHBOARD_NAME = 'Your Platform Name Here Insights' diff --git a/cms/envs/production.py b/cms/envs/production.py index ad7667772f9a..da5642b53c6c 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -163,6 +163,7 @@ def get_env_setting(setting): CMS_BASE = ENV_TOKENS.get('CMS_BASE') LMS_BASE = ENV_TOKENS.get('LMS_BASE') LMS_ROOT_URL = ENV_TOKENS.get('LMS_ROOT_URL') +CMS_ROOT_URL = ENV_TOKENS.get('CMS_ROOT_URL') LMS_INTERNAL_ROOT_URL = ENV_TOKENS.get('LMS_INTERNAL_ROOT_URL', LMS_ROOT_URL) ENTERPRISE_API_URL = ENV_TOKENS.get('ENTERPRISE_API_URL', LMS_INTERNAL_ROOT_URL + '/enterprise/api/v1/') ENTERPRISE_CONSENT_API_URL = ENV_TOKENS.get('ENTERPRISE_CONSENT_API_URL', LMS_INTERNAL_ROOT_URL + '/consent/api/v1/') diff --git a/cms/envs/test.py b/cms/envs/test.py index 49db50608858..6a7c17b001fe 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -136,6 +136,9 @@ LMS_ROOT_URL = f"http://{LMS_BASE}" FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost" +CMS_BASE = "localhost:8001" +CMS_ROOT_URL = f"http://{CMS_BASE}" + COURSE_AUTHORING_MICROFRONTEND_URL = "http://course-authoring-mfe" DISCUSSIONS_MICROFRONTEND_URL = "http://discussions-mfe" diff --git a/cms/lib/xblock/test/test_upstream_sync.py b/cms/lib/xblock/test/test_upstream_sync.py index cc3d661ca6e9..2f8f77ab6560 100644 --- a/cms/lib/xblock/test/test_upstream_sync.py +++ b/cms/lib/xblock/test/test_upstream_sync.py @@ -1,7 +1,9 @@ """ Test CMS's upstream->downstream syncing system """ +import datetime import ddt +from pytz import utc from organizations.api import ensure_organization from organizations.models import Organization @@ -42,13 +44,33 @@ def setUp(self): title="Test Upstream Library", ) self.upstream_key = libs.create_library_block(self.library.key, "html", "test-upstream").usage_key - libs.create_library_block(self.library.key, "video", "video-upstream") upstream = xblock.load_block(self.upstream_key, self.user) upstream.display_name = "Upstream Title V2" upstream.data = "Upstream content V2" upstream.save() + self.upstream_problem_key = libs.create_library_block(self.library.key, "problem", "problem-upstream").usage_key + libs.set_library_block_olx(self.upstream_problem_key, ( + '\n' + )) + libs.publish_changes(self.library.key, self.user.id) self.taxonomy_all_org = tagging_api.create_taxonomy( @@ -179,6 +201,100 @@ def test_sync_updates_happy_path(self): for object_tag in object_tags: assert object_tag.value in new_upstream_tags + # pylint: disable=too-many-statements + def test_sync_updates_to_downstream_only_fields(self): + """ + If we sync to modified content, will it preserve downstream-only fields, and overwrite the rest? + """ + downstream = BlockFactory.create(category='problem', parent=self.unit, upstream=str(self.upstream_problem_key)) + + # Initial sync + sync_from_upstream(downstream, self.user) + + # These fields are copied from upstream + assert downstream.upstream_display_name == "Upstream Problem Title V2" + assert downstream.display_name == "Upstream Problem Title V2" + assert downstream.rerandomize == '"always"' + assert downstream.matlab_api_key == 'abc' + assert not downstream.use_latex_compiler + + # These fields are "downstream only", so field defaults are preserved, and values are NOT copied from upstream + assert downstream.attempts_before_showanswer_button == 0 + assert downstream.due is None + assert not downstream.force_save_button + assert downstream.graceperiod is None + assert downstream.grading_method == 'last_score' + assert downstream.max_attempts is None + assert downstream.show_correctness == 'always' + assert not downstream.show_reset_button + assert downstream.showanswer == 'finished' + assert downstream.submission_wait_seconds == 0 + assert downstream.weight is None + + # Upstream updates + libs.set_library_block_olx(self.upstream_problem_key, ( + '\n' + )) + libs.publish_changes(self.library.key, self.user.id) + + # Modifing downstream-only fields are "safe" customizations + downstream.display_name = "Downstream Title Override" + downstream.attempts_before_showanswer_button = 2 + downstream.due = datetime.datetime(2025, 2, 2, tzinfo=utc) + downstream.force_save_button = True + downstream.graceperiod = '2d' + downstream.grading_method = 'last_score' + downstream.max_attempts = 100 + downstream.show_correctness = 'always' + downstream.show_reset_button = True + downstream.showanswer = 'on_expired' + downstream.submission_wait_seconds = 100 + downstream.weight = 3 + + # Modifying synchronized fields are "unsafe" customizations + downstream.rerandomize = '"onreset"' + downstream.matlab_api_key = 'hij' + downstream.save() + + # Follow-up sync. + sync_from_upstream(downstream, self.user) + + # "unsafe" customizations are overridden by upstream + assert downstream.upstream_display_name == "Upstream Problem Title V3" + assert downstream.rerandomize == '"per_student"' + assert downstream.matlab_api_key == 'def' + assert downstream.use_latex_compiler + + # but "safe" customizations survive + assert downstream.display_name == "Downstream Title Override" + assert downstream.attempts_before_showanswer_button == 2 + assert downstream.due == datetime.datetime(2025, 2, 2, tzinfo=utc) + assert downstream.force_save_button + assert downstream.graceperiod == '2d' + assert downstream.grading_method == 'last_score' + assert downstream.max_attempts == 100 + assert downstream.show_correctness == 'always' + assert downstream.show_reset_button + assert downstream.showanswer == 'on_expired' + assert downstream.submission_wait_seconds == 100 + assert downstream.weight == 3 + def test_sync_updates_to_modified_content(self): """ If we sync to modified content, will it preserve customizable fields, but overwrite the rest? diff --git a/cms/lib/xblock/upstream_sync.py b/cms/lib/xblock/upstream_sync.py index 0d95931ce29d..4723e566ffc3 100644 --- a/cms/lib/xblock/upstream_sync.py +++ b/cms/lib/xblock/upstream_sync.py @@ -252,10 +252,6 @@ def _update_customizable_fields(*, upstream: XBlock, downstream: XBlock, only_fe * Set `course_problem.upstream_display_name = lib_problem.display_name` ("fetch"). * If `not only_fetch`, and `course_problem.display_name` wasn't customized, then: * Set `course_problem.display_name = lib_problem.display_name` ("sync"). - - * Set `course_problem.upstream_max_attempts = lib_problem.max_attempts` ("fetch"). - * If `not only_fetch`, and `course_problem.max_attempts` wasn't customized, then: - * Set `course_problem.max_attempts = lib_problem.max_attempts` ("sync"). """ syncable_field_names = _get_synchronizable_fields(upstream, downstream) @@ -264,6 +260,10 @@ def _update_customizable_fields(*, upstream: XBlock, downstream: XBlock, only_fe if field_name not in syncable_field_names: continue + # Downstream-only fields don't have an upstream fetch field + if fetch_field_name is None: + continue + # FETCH the upstream's value and save it on the downstream (ie, `downstream.upstream_$FIELD`). old_upstream_value = getattr(downstream, fetch_field_name) new_upstream_value = getattr(upstream, field_name) @@ -361,6 +361,9 @@ def sever_upstream_link(downstream: XBlock) -> None: downstream.upstream = None downstream.upstream_version = None for _, fetched_upstream_field in downstream.get_customizable_fields().items(): + # Downstream-only fields don't have an upstream fetch field + if fetched_upstream_field is None: + continue setattr(downstream, fetched_upstream_field, None) # Null out upstream_display_name, et al. @@ -414,21 +417,30 @@ class UpstreamSyncMixin(XBlockMixin): help=("The value of display_name on the linked upstream block."), default=None, scope=Scope.settings, hidden=True, enforce_type=True, ) - upstream_max_attempts = Integer( - help=("The value of max_attempts on the linked upstream block."), - default=None, scope=Scope.settings, hidden=True, enforce_type=True, - ) @classmethod - def get_customizable_fields(cls) -> dict[str, str]: + def get_customizable_fields(cls) -> dict[str, str | None]: """ Mapping from each customizable field to the field which can be used to restore its upstream value. + If the customizable field is mapped to None, then it is considered "downstream only", and cannot be restored + from the upstream value. + XBlocks outside of edx-platform can override this in order to set up their own customizable fields. """ return { "display_name": "upstream_display_name", - "max_attempts": "upstream_max_attempts", + "attempts_before_showanswer_button": None, + "due": None, + "force_save_button": None, + "graceperiod": None, + "grading_method": None, + "max_attempts": None, + "show_correctness": None, + "show_reset_button": None, + "showanswer": None, + "submission_wait_seconds": None, + "weight": None, } # PRESERVING DOWNSTREAM CUSTOMIZATIONS and RESTORING UPSTREAM VALUES @@ -485,6 +497,10 @@ def get_customizable_fields(cls) -> dict[str, str]: # if field_name in self.downstream_customized: # continue # + # # If there is no restore_field name, it's a downstream-only field + # if restore_field_name is None: + # continue + # # # If this field's value doesn't match the synced upstream value, then mark the field # # as customized so that we don't clobber it later when syncing. # # NOTE: Need to consider the performance impact of all these field lookups. diff --git a/cms/static/sass/course-unit-mfe-iframe-bundle.scss b/cms/static/sass/course-unit-mfe-iframe-bundle.scss index bc0c3901b147..1f813fac253d 100644 --- a/cms/static/sass/course-unit-mfe-iframe-bundle.scss +++ b/cms/static/sass/course-unit-mfe-iframe-bundle.scss @@ -200,7 +200,7 @@ body { } } - .modal-window.modal-editor { + .modal-window.modal-editor, &.xblock-iframe-content { background-color: $white; border-radius: 6px; @@ -381,6 +381,37 @@ body { .modal-lg.modal-window.confirm.openassessment_modal_window { height: 635px; } + + // Additions for the xblock editor on the Library Authoring + &.xblock-iframe-content { + // Reset the max-height to allow the settings list to grow + .wrapper-comp-settings .list-input.settings-list { + max-height: unset; + } + + // For Google Docs and Google Calendar xblock editor + .google-edit-wrapper .xblock-inputs { + position: unset; + overflow-y: unset; + } + + .xblock-actions { + padding: ($baseline*0.75) 2% ($baseline/2) 2%; + position: sticky; + bottom: 0; + + .action-item { + @extend %t-action3; + + display: inline-block; + margin-right: ($baseline*0.75); + + &:last-child { + margin-right: 0; + } + } + } + } } .view-container .content-primary { diff --git a/cms/urls.py b/cms/urls.py index 2e64d4bbeb79..58503f9ed92f 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -168,7 +168,7 @@ contentstore_views.textbooks_detail_handler, name='textbooks_detail_handler'), re_path(fr'^videos/{settings.COURSE_KEY_PATTERN}(?:/(?P[-\w]+))?$', contentstore_views.videos_handler, name='videos_handler'), - re_path(fr'^generate_video_upload_link/{settings.COURSE_KEY_PATTERN}', + re_path(fr'^generate_video_upload_link/{settings.COURSE_KEY_PATTERN}$', contentstore_views.generate_video_upload_link_handler, name='generate_video_upload_link'), re_path(fr'^video_images/{settings.COURSE_KEY_PATTERN}(?:/(?P[-\w]+))?$', contentstore_views.video_images_handler, name='video_images_handler'), diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index d7b069865317..b4a777d2d677 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -21,7 +21,6 @@ from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from common.djangoapps.util.testing import UrlResetMixin from common.djangoapps.util.tests.mixins.discovery import CourseCatalogServiceMockMixin -from edx_toggles.toggles.testutils import override_waffle_flag # lint-amnesty, pylint: disable=wrong-import-order from lms.djangoapps.commerce.tests import test_utils as ecomm_test_utils from lms.djangoapps.commerce.tests.mocks import mock_payment_processors from lms.djangoapps.verify_student.services import IDVerificationService @@ -33,8 +32,6 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order -from ..views import VALUE_PROP_TRACK_SELECTION_FLAG - # Name of the method to mock for Content Type Gating. GATING_METHOD_NAME = 'openedx.features.content_type_gating.models.ContentTypeGatingConfig.enabled_for_enrollment' @@ -186,27 +183,6 @@ def test_suggested_prices(self, price_list): # TODO: Fix it so that response.templates works w/ mako templates, and then assert # that the right template rendered - @httpretty.activate - @ddt.data( - (['honor', 'verified', 'credit'], True), - (['honor', 'verified'], False), - ) - @ddt.unpack - def test_credit_upsell_message(self, available_modes, show_upsell): - # Create the course modes - for mode in available_modes: - CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) - - # Check whether credit upsell is shown on the page - # This should *only* be shown when a credit mode is available - url = reverse('course_modes_choose', args=[str(self.course.id)]) - response = self.client.get(url) - - if show_upsell: - self.assertContains(response, "Credit") - else: - self.assertNotContains(response, "Credit") - @httpretty.activate @patch('common.djangoapps.course_modes.views.enterprise_customer_for_request') @patch('common.djangoapps.course_modes.views.get_course_final_price') @@ -240,29 +216,6 @@ def test_display_after_discounted_price( self.assertContains(response, discounted_price) self.assertContains(response, verified_mode.min_price) - @httpretty.activate - @ddt.data(True, False) - def test_congrats_on_enrollment_message(self, create_enrollment): - # Create the course mode - CourseModeFactory.create(mode_slug='verified', course_id=self.course.id) - - if create_enrollment: - CourseEnrollmentFactory( - is_active=True, - course_id=self.course.id, - user=self.user - ) - - # Check whether congratulations message is shown on the page - # This should *only* be shown when an enrollment exists - url = reverse('course_modes_choose', args=[str(self.course.id)]) - response = self.client.get(url) - - if create_enrollment: - self.assertContains(response, "Congratulations! You are now enrolled in") - else: - self.assertNotContains(response, "Congratulations! You are now enrolled in") - @ddt.data('professional', 'no-id-professional') def test_professional_enrollment(self, mode): # The only course mode is professional ed @@ -529,26 +482,24 @@ def test_errors(self, has_perm, post_params, error_msg, status_code, mock_has_pe for mode in ('audit', 'honor', 'verified'): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) - # Value Prop TODO (REV-2378): remove waffle flag from tests once flag is removed. - with override_waffle_flag(VALUE_PROP_TRACK_SELECTION_FLAG, active=True): - mock_has_perm.return_value = has_perm - url = reverse('course_modes_choose', args=[str(self.course.id)]) + mock_has_perm.return_value = has_perm + url = reverse('course_modes_choose', args=[str(self.course.id)]) - # Choose mode (POST request) - response = self.client.post(url, post_params) - self.assertEqual(response.status_code, status_code) + # Choose mode (POST request) + response = self.client.post(url, post_params) + self.assertEqual(response.status_code, status_code) - if has_perm: - self.assertContains(response, error_msg) - self.assertContains(response, 'Sorry, we were unable to enroll you') + if has_perm: + self.assertContains(response, error_msg) + self.assertContains(response, 'Sorry, we were unable to enroll you') - # Check for CTA button on error page - marketing_root = settings.MKTG_URLS.get('ROOT') - search_courses_url = urljoin(marketing_root, '/search?tab=course') - self.assertContains(response, search_courses_url) - self.assertContains(response, 'Explore all courses') - else: - self.assertTrue(CourseEnrollment.is_enrollment_closed(self.user, self.course)) + # Check for CTA button on error page + marketing_root = settings.MKTG_URLS.get('ROOT') + search_courses_url = urljoin(marketing_root, '/search?tab=course') + self.assertContains(response, search_courses_url) + self.assertContains(response, 'Explore all courses') + else: + self.assertTrue(CourseEnrollment.is_enrollment_closed(self.user, self.course)) def _assert_fbe_page(self, response, min_price=None, **_): """ @@ -607,33 +558,19 @@ def _assert_unfbe_page(self, response, min_price=None, **_): # Check for the HTML element for courses with more than one mode self.assertContains(response, '
') - def _assert_legacy_page(self, response, **_): - """ - Assert choose.html was rendered. - """ - # Check for string unique to the legacy choose.html. - self.assertContains(response, "Choose Your Track") - # This string only occurs in lms/templates/course_modes/choose.html - # and related theme and translation files. - @override_settings(MKTG_URLS={'ROOT': 'https://www.example.edx.org'}) @ddt.data( - # gated_content_on, course_duration_limits_on, waffle_flag_on, expected_page_assertion_function - (True, True, True, _assert_fbe_page), - (True, False, True, _assert_unfbe_page), - (False, True, True, _assert_unfbe_page), - (False, False, True, _assert_unfbe_page), - (True, True, False, _assert_legacy_page), - (True, False, False, _assert_legacy_page), - (False, True, False, _assert_legacy_page), - (False, False, False, _assert_legacy_page), + # gated_content_on, course_duration_limits_on, expected_page_assertion_function + (True, True, _assert_fbe_page), + (True, False, _assert_unfbe_page), + (False, True, _assert_unfbe_page), + (False, False, _assert_unfbe_page), ) @ddt.unpack def test_track_selection_types( self, gated_content_on, course_duration_limits_on, - waffle_flag_on, expected_page_assertion_function ): """ @@ -644,7 +581,6 @@ def test_track_selection_types( verified course modes), the learner may view 3 different pages: 1. fbe.html - full FBE 2. unfbe.html - partial or no FBE - 3. choose.html - legacy track selection page This test checks that the right template is rendered. @@ -667,15 +603,11 @@ def test_track_selection_types( user=self.user ) - # Value Prop TODO (REV-2378): remove waffle flag from tests once the new Track Selection template is rolled out. - # Check whether new track selection template is rendered. - # This should *only* be shown when the waffle flag is on. - with override_waffle_flag(VALUE_PROP_TRACK_SELECTION_FLAG, active=waffle_flag_on): - with patch(GATING_METHOD_NAME, return_value=gated_content_on): - with patch(CDL_METHOD_NAME, return_value=course_duration_limits_on): - url = reverse('course_modes_choose', args=[str(self.course_that_started.id)]) - response = self.client.get(url) - expected_page_assertion_function(self, response, min_price=verified_mode.min_price) + with patch(GATING_METHOD_NAME, return_value=gated_content_on): + with patch(CDL_METHOD_NAME, return_value=course_duration_limits_on): + url = reverse('course_modes_choose', args=[str(self.course_that_started.id)]) + response = self.client.get(url) + expected_page_assertion_function(self, response, min_price=verified_mode.min_price) def test_verified_mode_only(self): # Create only the verified mode and enroll the user @@ -690,18 +622,16 @@ def test_verified_mode_only(self): user=self.user ) - # Value Prop TODO (REV-2378): remove waffle flag from tests once the new Track Selection template is rolled out. - with override_waffle_flag(VALUE_PROP_TRACK_SELECTION_FLAG, active=True): - with patch(GATING_METHOD_NAME, return_value=True): - with patch(CDL_METHOD_NAME, return_value=True): - url = reverse('course_modes_choose', args=[str(self.course_that_started.id)]) - response = self.client.get(url) - # Check that only the verified option is rendered - self.assertNotContains(response, "Choose a path for your course in") - self.assertContains(response, "Earn a certificate") - self.assertNotContains(response, "Access this course") - self.assertContains(response, '
') - self.assertNotContains(response, '
') + with patch(GATING_METHOD_NAME, return_value=True): + with patch(CDL_METHOD_NAME, return_value=True): + url = reverse('course_modes_choose', args=[str(self.course_that_started.id)]) + response = self.client.get(url) + # Check that only the verified option is rendered + self.assertNotContains(response, "Choose a path for your course in") + self.assertContains(response, "Earn a certificate") + self.assertNotContains(response, "Access this course") + self.assertContains(response, '
') + self.assertNotContains(response, '
') @skip_unless_lms diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 759073a13583..09164dc40e7c 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -29,7 +29,6 @@ from common.djangoapps.course_modes.helpers import get_course_final_price, get_verified_track_links from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.util.date_utils import strftime_localized_html -from edx_toggles.toggles import WaffleFlag # lint-amnesty, pylint: disable=wrong-import-order from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.experiments.utils import get_experiment_user_metadata_context from lms.djangoapps.verify_student.services import IDVerificationService @@ -47,17 +46,6 @@ LOG = logging.getLogger(__name__) -# .. toggle_name: course_modes.use_new_track_selection -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: This flag enables the use of the new track selection template for testing purposes before full rollout -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2021-8-23 -# .. toggle_target_removal_date: None -# .. toggle_tickets: REV-2133 -# .. toggle_warning: This temporary feature toggle does not have a target removal date. -VALUE_PROP_TRACK_SELECTION_FLAG = WaffleFlag('course_modes.use_new_track_selection', __name__) - class ChooseModeView(View): """View used when the user is asked to pick a mode. @@ -158,18 +146,6 @@ def get(self, request, course_id, error=None): # lint-amnesty, pylint: disable= ) return redirect('{}?{}'.format(reverse('dashboard'), params)) - # When a credit mode is available, students will be given the option - # to upgrade from a verified mode to a credit mode at the end of the course. - # This allows students who have completed photo verification to be eligible - # for university credit. - # Since credit isn't one of the selectable options on the track selection page, - # we need to check *all* available course modes in order to determine whether - # a credit mode is available. If so, then we show slightly different messaging - # for the verified track. - has_credit_upsell = any( - CourseMode.is_credit_mode(mode) for mode - in CourseMode.modes_for_course(course_key, only_selectable=False) - ) course_id = str(course_key) gated_content = ContentTypeGatingConfig.enabled_for_enrollment( user=request.user, @@ -184,7 +160,6 @@ def get(self, request, course_id, error=None): # lint-amnesty, pylint: disable= ), "modes": modes, "is_single_mode": is_single_mode, - "has_credit_upsell": has_credit_upsell, "course_name": course.display_name_with_default, "course_org": course.display_org_with_default, "course_num": course.display_number_with_default, @@ -204,14 +179,6 @@ def get(self, request, course_id, error=None): # lint-amnesty, pylint: disable= ) ) - title_content = '' - if enrollment_mode: - title_content = _("Congratulations! You are now enrolled in {course_name}").format( - course_name=course.display_name_with_default - ) - - context["title_content"] = title_content - if "verified" in modes: verified_mode = modes["verified"] context["suggested_prices"] = [ @@ -266,19 +233,12 @@ def get(self, request, course_id, error=None): # lint-amnesty, pylint: disable= context['audit_access_deadline'] = formatted_audit_access_date fbe_is_on = deadline and gated_content - # Route to correct Track Selection page. - # REV-2378 TODO Value Prop: remove waffle flag after all edge cases for track selection are completed. - if VALUE_PROP_TRACK_SELECTION_FLAG.is_enabled(): - if not enterprise_customer_for_request(request): # TODO: Remove by executing REV-2342 - if error: - return render_to_response("course_modes/error.html", context) - if fbe_is_on: - return render_to_response("course_modes/fbe.html", context) - else: - return render_to_response("course_modes/unfbe.html", context) - - # If enterprise_customer, failover to old choose.html page - return render_to_response("course_modes/choose.html", context) + if error: + return render_to_response("course_modes/error.html", context) + if fbe_is_on: + return render_to_response("course_modes/fbe.html", context) + else: + return render_to_response("course_modes/unfbe.html", context) @method_decorator(transaction.non_atomic_requests) @method_decorator(login_required) diff --git a/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py b/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py index a9c1d5349858..69f2e35de44a 100644 --- a/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py +++ b/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py @@ -81,7 +81,7 @@ def handle(self, *args, **options): self.change_enrollments(csv_file) else: - CommandError('No file is provided. File is required') + raise CommandError('No file is provided. File is required') def change_enrollments(self, csv_file): """ change the enrollments of the learners. """ diff --git a/common/djangoapps/track/views/segmentio.py b/common/djangoapps/track/views/segmentio.py index 2ab5306232c5..8a35a0e421cf 100644 --- a/common/djangoapps/track/views/segmentio.py +++ b/common/djangoapps/track/views/segmentio.py @@ -205,9 +205,8 @@ def track_segmentio_event(request): # pylint: disable=too-many-statements raise EventValidationError(ERROR_USER_NOT_EXIST) # lint-amnesty, pylint: disable=raise-missing-from except ValueError: raise EventValidationError(ERROR_INVALID_USER_ID) # lint-amnesty, pylint: disable=raise-missing-from - else: - context['user_id'] = user.id - context['username'] = user.username + context['user_id'] = user.id + context['username'] = user.username # course_id is expected to be provided in the context when applicable course_id = context.get('course_id') diff --git a/common/templates/xblock_v2/xblock_iframe.html b/common/templates/xblock_v2/xblock_iframe.html index 07d81b962a65..57ff4684ddbd 100644 --- a/common/templates/xblock_v2/xblock_iframe.html +++ b/common/templates/xblock_v2/xblock_iframe.html @@ -1,6 +1,7 @@ - + + @@ -81,8 +82,11 @@ href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"> - - + + + + + - - -<%block name="content"> - % if error: -
-
- -
-

${_("Sorry, there was an error when trying to enroll you")}

-
-

${error}

-
-
-
-
- %endif - -
-
-
-
- - -
- <% - b_tag_kwargs = {'b_start': HTML(''), 'b_end': HTML('')} - %> - % if "verified" in modes: -
-
- - % if has_credit_upsell: - % if content_gating_enabled or course_duration_limit_enabled: -

${_("Pursue Academic Credit with the Verified Track")}

- % else: -

${_("Pursue Academic Credit with a Verified Certificate")}

- % endif - -
-

${_("Become eligible for academic credit and highlight your new skills and knowledge with a verified certificate. Use this valuable credential to qualify for academic credit, advance your career, or strengthen your school applications.")}

-

-

-
- % if content_gating_enabled or course_duration_limit_enabled: -

${_("Benefits of the Verified Track")}

-
    -
  • ${Text(_("{b_start}Eligible for credit:{b_end} Receive academic credit after successfully completing the course")).format(**b_tag_kwargs)}
  • - % if course_duration_limit_enabled: -
  • ${Text(_("{b_start}Unlimited Course Access: {b_end}Learn at your own pace, and access materials anytime to brush up on what you've learned.")).format(**b_tag_kwargs)}
  • - % endif - % if content_gating_enabled: -
  • ${Text(_("{b_start}Graded Assignments: {b_end}Build your skills through graded assignments and projects.")).format(**b_tag_kwargs)}
  • - % endif -
  • ${Text(_("{b_start}Easily Sharable: {b_end}Add the certificate to your CV or resumé, or post it directly on LinkedIn.")).format(**b_tag_kwargs)}
  • -
- % else: -

${_("Benefits of a Verified Certificate")}

-
    -
  • ${Text(_("{b_start}Eligible for credit:{b_end} Receive academic credit after successfully completing the course")).format(**b_tag_kwargs)}
  • -
  • ${Text(_("{b_start}Official:{b_end} Receive an instructor-signed certificate with the institution's logo")).format(**b_tag_kwargs)}
  • -
  • ${Text(_("{b_start}Easily shareable:{b_end} Add the certificate to your CV or resumé, or post it directly on LinkedIn")).format(**b_tag_kwargs)}
  • -
- % endif -
-
-
    - <%include file='_upgrade_button.html' args='content_gating_enabled=content_gating_enabled, course_duration_limit_enabled=course_duration_limit_enabled, currency=currency, currency_symbol=currency_symbol, min_price=min_price, price_before_discount=price_before_discount' /> -
-
-
-

-
- % else: - % if content_gating_enabled or course_duration_limit_enabled: -

${_("Pursue the Verified Track")}

- % else: -

${_("Pursue a Verified Certificate")}

- % endif - - -
-

${_("Highlight your new knowledge and skills with a verified certificate. Use this valuable credential to improve your job prospects and advance your career, or highlight your certificate in school applications.")}

-

-

-
- % if content_gating_enabled or course_duration_limit_enabled: -

${_("Benefits of the Verified Track")}

-
    - % if course_duration_limit_enabled: -
  • ${Text(_("{b_start}Unlimited Course Access: {b_end}Learn at your own pace, and access materials anytime to brush up on what you've learned.")).format(**b_tag_kwargs)}
  • - % endif - % if content_gating_enabled: -
  • ${Text(_("{b_start}Graded Assignments: {b_end}Build your skills through graded assignments and projects.")).format(**b_tag_kwargs)}
  • - % endif -
  • ${Text(_("{b_start}Easily Sharable: {b_end}Add the certificate to your CV or resumé, or post it directly on LinkedIn.")).format(**b_tag_kwargs)}
  • -
- % else: -

${_("Benefits of a Verified Certificate")}

-
    -
  • ${Text(_("{b_start}Official: {b_end}Receive an instructor-signed certificate with the institution's logo")).format(**b_tag_kwargs)}
  • -
  • ${Text(_("{b_start}Easily shareable: {b_end}Add the certificate to your CV or resumé, or post it directly on LinkedIn")).format(**b_tag_kwargs)}
  • -
  • ${Text(_("{b_start}Motivating: {b_end}Give yourself an additional incentive to complete the course")).format(**b_tag_kwargs)}
  • -
- % endif -
-
-
    - <%include file='_upgrade_button.html' args='content_gating_enabled=content_gating_enabled, course_duration_limit_enabled=course_duration_limit_enabled, currency=currency, currency_symbol=currency_symbol, min_price=min_price, price_before_discount=price_before_discount' /> -
-
-
-

-
- % endif -
-
- % endif - - % if "honor" in modes: - - ${_("or")} - - -
-
- -

${_("Audit This Course")}

-
-

${_("Audit this course for free and have complete access to all the course material, activities, tests, and forums.")}

-
-
- -
    -
  • - -
  • -
-
- % elif "audit" in modes: - - ${_("or")} - - -
-
- -

${_("Audit This Course (No Certificate)")}

-
- ## Translators: b_start notes the beginning of a section of text bolded for emphasis, and b_end marks the end of the bolded text. - % if content_gating_enabled and course_duration_limit_enabled: -

${Text(_("Audit this course for free and have access to course materials and discussions forums. {b_start}This track does not include graded assignments, or unlimited course access.{b_end}")).format(**b_tag_kwargs)}

- % elif content_gating_enabled and not course_duration_limit_enabled: -

${Text(_("Audit this course for free and have access to course materials and discussions forums. {b_start}This track does not include graded assignments.{b_end}")).format(**b_tag_kwargs)}

- % elif not content_gating_enabled and course_duration_limit_enabled: -

${Text(_("Audit this course for free and have access to course materials and discussions forums. {b_start}This track does not include unlimited course access.{b_end}")).format(**b_tag_kwargs)}

- % else: -

${Text(_("Audit this course for free and have complete access to all the course material, activities, tests, and forums. {b_start}Please note that this track does not offer a certificate for learners who earn a passing grade.{b_end}")).format(**b_tag_kwargs)}

- % endif -
-
- -
    -
  • - -
  • -
-
- % endif - - -
-
-
-
-
- diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index 048226c5b16c..a1b7a2602450 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -81,6 +81,7 @@ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt from django.views.generic.base import TemplateResponseMixin, View +from drf_yasg.utils import swagger_auto_schema from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin from pylti1p3.exception import LtiException, OIDCException @@ -201,8 +202,10 @@ class LibraryRootView(GenericAPIView): """ Views to list, search for, and create content libraries. """ + serializer_class = ContentLibraryMetadataSerializer @apidocs.schema( + responses={200: ContentLibraryMetadataSerializer(many=True)}, parameters=[ *LibraryApiPaginationDocs.apidoc_params, apidocs.query_parameter( @@ -530,7 +533,13 @@ class LibraryPasteClipboardView(GenericAPIView): """ Paste content of clipboard into Library. """ + serializer_class = LibraryXBlockMetadataSerializer + @convert_exceptions + @swagger_auto_schema( + request_body=LibraryPasteClipboardSerializer, + responses={200: LibraryXBlockMetadataSerializer} + ) def post(self, request, lib_key_str): """ Import the contents of the user's clipboard and paste them into the Library @@ -558,6 +567,7 @@ class LibraryBlocksView(GenericAPIView): """ Views to work with XBlocks in a specific content library. """ + serializer_class = LibraryXBlockMetadataSerializer @apidocs.schema( parameters=[ @@ -595,6 +605,10 @@ def get(self, request, lib_key_str): return self.get_paginated_response(serializer.data) @convert_exceptions + @swagger_auto_schema( + request_body=LibraryXBlockCreationSerializer, + responses={200: LibraryXBlockMetadataSerializer} + ) def post(self, request, lib_key_str): """ Add a new XBlock to this content library @@ -870,6 +884,9 @@ class LibraryImportTaskViewSet(GenericViewSet): Import blocks from Courseware through modulestore. """ + queryset = [] # type: ignore[assignment] + serializer_class = ContentLibraryBlockImportTaskSerializer + @convert_exceptions def list(self, request, lib_key_str): """ @@ -889,6 +906,10 @@ def list(self, request, lib_key_str): ) @convert_exceptions + @swagger_auto_schema( + request_body=ContentLibraryBlockImportTaskCreateSerializer, + responses={200: ContentLibraryBlockImportTaskSerializer} + ) def create(self, request, lib_key_str): """ Create and queue an import tasks for this library. diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index 551f94e90e1a..ab65d444ed6f 100644 --- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py +++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py @@ -1,3 +1,4 @@ +# pylint: skip-file """ Tests for the clipboard functionality """ diff --git a/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py b/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py index cbfdd8e7337f..f79ead24d9b0 100644 --- a/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py +++ b/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py @@ -7,11 +7,13 @@ This management command will manually trigger the receivers we care about. (We don't want to trigger all receivers for these signals, since these are busy signals.) """ + import logging import shlex from datetime import datetime, timedelta import dateutil.parser +from django.conf import settings from django.core.management.base import BaseCommand, CommandError from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -58,6 +60,7 @@ class Command(BaseCommand): course-v1:edX+RecordsSelfPaced+1 for user 17 course-v1:edX+RecordsSelfPaced+1 for user 18 """ + help = ( "Simulate certificate/grade changes without actually modifying database " "content. Specifically, trigger the handlers that send data to Credentials." @@ -65,98 +68,99 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - '--dry-run', - action='store_true', - help='Just show a preview of what would happen.', + "--dry-run", + action="store_true", + help="Just show a preview of what would happen.", ) parser.add_argument( - '--site', + "--site", default=None, help="Site domain to notify for (if not specified, all sites are notified). Uses course_org_filter.", ) parser.add_argument( - '--courses', - nargs='+', - help='Send information only for specific course runs.', + "--courses", + nargs="+", + help="Send information only for specific course runs.", ) parser.add_argument( - '--program_uuids', - nargs='+', - help='Send user data for course runs for courses within a program based on program uuids provided.', + "--program_uuids", + nargs="+", + help="Send user data for course runs for courses within a program based on program uuids provided.", ) parser.add_argument( - '--start-date', + "--start-date", type=parsetime, - help='Send information only for certificates or grades that have changed since this date.', + help="Send information only for certificates or grades that have changed since this date.", ) parser.add_argument( - '--end-date', + "--end-date", type=parsetime, - help='Send information only for certificates or grades that have changed before this date.', + help="Send information only for certificates or grades that have changed before this date.", ) parser.add_argument( - '--delay', + "--delay", type=float, default=0, help="Number of seconds to sleep between processing queries, so that we don't flood our queues.", ) parser.add_argument( - '--page-size', + "--page-size", type=int, default=100, help="Number of items to query at once.", ) parser.add_argument( - '--auto', - action='store_true', - help='Use to run the management command periodically', + "--auto", + action="store_true", + help="Use to run the management command periodically", ) parser.add_argument( - '--args-from-database', - action='store_true', - help='Use arguments from the NotifyCredentialsConfig model instead of the command line.', + "--args-from-database", + action="store_true", + help="Use arguments from the NotifyCredentialsConfig model instead of the command line.", ) parser.add_argument( - '--verbose', - action='store_true', - help='Run grade/cert change signal in verbose mode', + "--verbose", + action="store_true", + help="Run grade/cert change signal in verbose mode", ) parser.add_argument( - '--notify_programs', - action='store_true', - help='Send program award notifications with course notification tasks', + "--notify_programs", + action="store_true", + help="Send program award notifications with course notification tasks", ) parser.add_argument( - '--user_ids', + "--user_ids", default=None, - nargs='+', - help='Run the command for the given user or list of users', + nargs="+", + help="Run the command for the given user or list of users", ) parser.add_argument( - '--revoke_program_certs', - action='store_true', - help="If true, system will check if any program certificates need to be revoked from learners" + "--revoke_program_certs", + action="store_true", + help="If true, system will check if any program certificates need to be revoked from learners", ) def get_args_from_database(self): - """ Returns an options dictionary from the current NotifyCredentialsConfig model. """ + """Returns an options dictionary from the current NotifyCredentialsConfig model.""" config = NotifyCredentialsConfig.current() if not config.enabled: - raise CommandError('NotifyCredentialsConfig is disabled, but --args-from-database was requested.') + raise CommandError("NotifyCredentialsConfig is disabled, but --args-from-database was requested.") # This split will allow for quotes to wrap datetimes, like "2020-10-20 04:00:00" and other # arguments as if it were the command line argv = shlex.split(config.arguments) - parser = self.create_parser('manage.py', 'notify_credentials') - return parser.parse_args(argv).__dict__ # we want a dictionary, not a non-iterable Namespace object + parser = self.create_parser("manage.py", "notify_credentials") + return parser.parse_args(argv).__dict__ # we want a dictionary, not a non-iterable Namespace object def handle(self, *args, **options): - if options['args_from_database']: + if options["args_from_database"]: options = self.get_args_from_database() - if options['auto']: - options['end_date'] = datetime.now().replace(minute=0, second=0, microsecond=0) - options['start_date'] = options['end_date'] - timedelta(hours=4) + if options["auto"]: + run_frequency = settings.NOTIFY_CREDENTIALS_FREQUENCY + options["end_date"] = datetime.now().replace(minute=0, second=0, microsecond=0) + options["start_date"] = options["end_date"] - timedelta(seconds=run_frequency) log.info( f"notify_credentials starting, dry-run={options['dry_run']}, site={options['site']}, " @@ -176,14 +180,9 @@ def handle(self, *args, **options): course_runs.extend(program_course_run_keys) course_run_keys = self._get_validated_course_run_keys(course_runs) - if not ( - course_run_keys or - options['start_date'] or - options['end_date'] or - options['user_ids'] - ): + if not (course_run_keys or options["start_date"] or options["end_date"] or options["user_ids"]): raise CommandError( - 'You must specify a filter (e.g. --courses, --program_uuids, --start-date, or --user_ids)' + "You must specify a filter (e.g. --courses, --program_uuids, --start-date, or --user_ids)" ) handle_notify_credentials.delay(options, course_run_keys) diff --git a/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py b/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py index e50173001fd3..f7bed3446aca 100644 --- a/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py +++ b/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py @@ -7,7 +7,7 @@ from django.core.management import call_command from django.core.management.base import CommandError -from django.test import TestCase, override_settings # lint-amnesty, pylint: disable=unused-import +from django.test import TestCase, override_settings from freezegun import freeze_time from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory, CourseFactory, CourseRunFactory @@ -125,6 +125,7 @@ def test_multiple_programs_uuid_args(self, mock_get_programs, mock_task): @freeze_time(datetime(2017, 5, 1, 4)) def test_auto_execution(self, mock_task): + """Verify that an automatic execution designed for scheduled windows works correctly""" self.expected_options['auto'] = True self.expected_options['start_date'] = datetime(2017, 5, 1, 0, 0) self.expected_options['end_date'] = datetime(2017, 5, 1, 4, 0) @@ -133,6 +134,19 @@ def test_auto_execution(self, mock_task): assert mock_task.called assert mock_task.call_args[0][0] == self.expected_options + @override_settings(NOTIFY_CREDENTIALS_FREQUENCY=3600) + @freeze_time(datetime(2017, 5, 1, 4)) + def test_auto_execution_different_schedule(self, mock_task): + """Verify that an automatic execution designed for scheduled windows + works correctly if the window frequency has been changed""" + self.expected_options["auto"] = True + self.expected_options["start_date"] = datetime(2017, 5, 1, 3, 0) + self.expected_options["end_date"] = datetime(2017, 5, 1, 4, 0) + + call_command(Command(), "--auto") + assert mock_task.called + assert mock_task.call_args[0][0] == self.expected_options + def test_date_args(self, mock_task): self.expected_options['start_date'] = datetime(2017, 1, 31, 0, 0, tzinfo=timezone.utc) call_command(Command(), '--start-date', '2017-01-31') diff --git a/openedx/core/djangoapps/credit/tasks.py b/openedx/core/djangoapps/credit/tasks.py index 312e278a985f..79ef613e3d19 100644 --- a/openedx/core/djangoapps/credit/tasks.py +++ b/openedx/core/djangoapps/credit/tasks.py @@ -41,8 +41,7 @@ def update_credit_course_requirements(course_id): except (InvalidKeyError, ItemNotFoundError, InvalidCreditRequirements) as exc: LOGGER.error('Error on adding the requirements for course %s - %s', course_id, str(exc)) raise update_credit_course_requirements.retry(args=[course_id], exc=exc) - else: - LOGGER.info('Requirements added for course %s', course_id) + LOGGER.info('Requirements added for course %s', course_id) def _get_course_credit_requirements(course_key): diff --git a/openedx/core/djangoapps/enrollments/data.py b/openedx/core/djangoapps/enrollments/data.py index 9986830a3491..b76042f72c9d 100644 --- a/openedx/core/djangoapps/enrollments/data.py +++ b/openedx/core/djangoapps/enrollments/data.py @@ -341,8 +341,7 @@ def get_course_enrollment_info(course_id, include_expired=False): msg = f"Requested enrollment information for unknown course {course_id}" log.warning(msg) raise CourseNotFoundError(msg) # lint-amnesty, pylint: disable=raise-missing-from - else: - return CourseSerializer(course, include_expired=include_expired).data + return CourseSerializer(course, include_expired=include_expired).data def get_user_roles(username): diff --git a/openedx/core/djangoapps/safe_sessions/middleware.py b/openedx/core/djangoapps/safe_sessions/middleware.py index f3948217efd9..950a3e08c54f 100644 --- a/openedx/core/djangoapps/safe_sessions/middleware.py +++ b/openedx/core/djangoapps/safe_sessions/middleware.py @@ -244,14 +244,13 @@ def parse(cls, safe_cookie_string): raise SafeCookieError( # lint-amnesty, pylint: disable=raise-missing-from f"SafeCookieData BWC parse error: {safe_cookie_string!r}." ) - else: - if safe_cookie_data.version != cls.CURRENT_VERSION: - raise SafeCookieError( - "SafeCookieData version {!r} is not supported. Current version is {}.".format( - safe_cookie_data.version, - cls.CURRENT_VERSION, - )) - return safe_cookie_data + if safe_cookie_data.version != cls.CURRENT_VERSION: + raise SafeCookieError( + "SafeCookieData version {!r} is not supported. Current version is {}.".format( + safe_cookie_data.version, + cls.CURRENT_VERSION, + )) + return safe_cookie_data def __str__(self): """ diff --git a/openedx/core/djangoapps/user_api/management/commands/bulk_user_org_email_optout.py b/openedx/core/djangoapps/user_api/management/commands/bulk_user_org_email_optout.py index e465ff5610ed..d194e58ee880 100644 --- a/openedx/core/djangoapps/user_api/management/commands/bulk_user_org_email_optout.py +++ b/openedx/core/djangoapps/user_api/management/commands/bulk_user_org_email_optout.py @@ -135,11 +135,10 @@ def handle(self, *args, **options): optout_rows[end_idx][0], optout_rows[end_idx][1], str(err)) raise - else: - cursor.execute('COMMIT;') - log.info("Committed opt-out for rows (%s, %s) through (%s, %s).", - optout_rows[start_idx][0], optout_rows[start_idx][1], - optout_rows[end_idx][0], optout_rows[end_idx][1]) + cursor.execute('COMMIT;') + log.info("Committed opt-out for rows (%s, %s) through (%s, %s).", + optout_rows[start_idx][0], optout_rows[start_idx][1], + optout_rows[end_idx][0], optout_rows[end_idx][1]) log.info("Sleeping %s seconds...", sleep_between) time.sleep(sleep_between) curr_row_idx += chunk_size diff --git a/openedx/core/djangoapps/user_authn/views/login_form.py b/openedx/core/djangoapps/user_authn/views/login_form.py index bb78a9df1a3c..3729c40868f6 100644 --- a/openedx/core/djangoapps/user_authn/views/login_form.py +++ b/openedx/core/djangoapps/user_authn/views/login_form.py @@ -187,12 +187,7 @@ def login_and_registration_form(request, initial_mode="login"): log.exception("Unknown tpa_hint provider: %s", ex) # Redirect to authn MFE if it is enabled - # AND - # user is not an enterprise user - # AND - # tpa_hint_provider is not available - # AND - # user is not coming from a SAML IDP. + # except if user is an enterprise user with tpa_hint_provider coming from a SAML IDP. saml_provider = False running_pipeline = pipeline.get(request) if running_pipeline: @@ -202,10 +197,8 @@ def login_and_registration_form(request, initial_mode="login"): enterprise_customer = enterprise_customer_for_request(request) - if should_redirect_to_authn_microfrontend() and \ - not enterprise_customer and \ - not tpa_hint_provider and \ - not saml_provider: + if should_redirect_to_authn_microfrontend() and not \ + (enterprise_customer and tpa_hint_provider and saml_provider): # This is to handle a case where a logged-in cookie is not present but the user is authenticated. # Note: If we don't handle this learner is redirected to authn MFE and then back to dashboard diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_logistration.py b/openedx/core/djangoapps/user_authn/views/tests/test_logistration.py index 3220cd513974..5b387adaf8ca 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_logistration.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_logistration.py @@ -648,6 +648,80 @@ def test_browser_language_dialent(self): assert response['Content-Language'] == 'es-es' + @ddt.data( + (None, None, None, True), + ({ + 'name': 'Test Enterprise', + 'uuid': 'test-uuid' + }, None, None, True), + ({ + 'name': 'Test Enterprise', + 'uuid': 'test-uuid' + }, 'test-provider', None, True), + ({ + 'name': 'Test Enterprise', + 'uuid': 'test-uuid' + }, 'test-provider', True, False), + ) + @ddt.unpack + @override_settings(FEATURES=FEATURES_WITH_AUTHN_MFE_ENABLED) + def test_enterprise_saml_redirection(self, enterprise_customer_data, provider_id, is_saml, should_redirect): + """ + Test that authentication MFE redirection respects the enterprise + SAML provider conditions. + In particular, verify that if we have an enterprise customer with a SAML-based tpa_hint_provider, + we do NOT redirect to the MFE, but handle the request in LMS. All other combinations should + redirect to the MFE when it's enabled. + """ + if provider_id and is_saml: + self.enable_saml() + self._configure_testshib_provider('TestShib', provider_id) + + with mock.patch( + 'openedx.core.djangoapps.user_authn.views.login_form.enterprise_customer_for_request') as mock_ec, \ + mock.patch( + 'openedx.core.djangoapps.user_authn.views.login_form.should_redirect_to_authn_microfrontend') as mock_should_redirect, \ + mock.patch( + 'openedx.core.djangoapps.user_authn.views.login_form.third_party_auth.utils.is_saml_provider') as mock_is_saml: + + mock_ec.return_value = enterprise_customer_data + mock_should_redirect.return_value = should_redirect + mock_is_saml.return_value = (True, None) if is_saml else (False, None) + + params = {} + if provider_id: + params['tpa_hint'] = provider_id + + if provider_id and is_saml: + pipeline_target = 'openedx.core.djangoapps.user_authn.views.login_form.third_party_auth.pipeline' + with mock.patch(pipeline_target + '.get') as mock_pipeline: + pipeline_data = { + 'backend': 'tpa-saml', + 'kwargs': { + 'response': { + 'idp_name': provider_id + }, + 'details': { + 'email': 'test@example.com', + 'fullname': 'Test User', + 'username': 'testuser' + } + } + } + mock_pipeline.return_value = pipeline_data + response = self.client.get(reverse('signin_user'), params) + else: + response = self.client.get(reverse('signin_user'), params) + + if should_redirect: + self.assertRedirects( + response, + settings.AUTHN_MICROFRONTEND_URL + '/login' + + ('?' + urlencode(params) if params else ''), + fetch_redirect_response=False + ) + else: + self.assertEqual(response.status_code, 200) + @skip_unless_lms class AccountCreationTestCaseWithSiteOverrides(SiteMixin, TestCase): diff --git a/openedx/core/djangoapps/util/management/commands/dump_settings.py b/openedx/core/djangoapps/util/management/commands/dump_settings.py new file mode 100644 index 000000000000..965ea16c6d27 --- /dev/null +++ b/openedx/core/djangoapps/util/management/commands/dump_settings.py @@ -0,0 +1,100 @@ +""" +Defines the dump_settings management command. +""" +import inspect +import json +import re + +from django.conf import settings +from django.core.management.base import BaseCommand + + +SETTING_NAME_REGEX = re.compile(r'^[A-Z][A-Z0-9_]*$') + + +class Command(BaseCommand): + """ + Dump current Django settings to JSON for debugging/diagnostics. + + BEWARE: OUTPUT IS NOT SUITABLE FOR CONSUMPTION BY PRODUCTION SYSTEMS. + The purpose of this output is to be *helpful* for a *human* operator to understand how their settings are being + rendered and how they differ between different settings files. The serialization format is NOT perfect: there are + certain situations where two different settings will output identical JSON. For example, this command does NOT: + + disambiguate between lists and tuples: + * (1, 2, 3) # <-- this tuple will be printed out as [1, 2, 3] + * [1, 2, 3] + + disambiguate between sets and sorted lists: + * {2, 1, 3} # <-- this set will be printed out as [1, 2, 3] + * [1, 2, 3] + + disambiguate between internationalized and non-internationalized strings: + * _("hello") # <-- this will become just "hello" + * "hello" + + Furthermore, functions and classes are printed as JSON objects like: + { + "module": "path.to.module", + "qualname": "MyClass.MyInnerClass.my_method", // Or, "" + "source_hint": "MY_SETTING = lambda: x + y", // For s only + } + + And everything else will be stringified as its `repr(...)`. + """ + + def handle(self, *args, **kwargs): + """ + Handle the command. + """ + settings_json = { + name: _to_json_friendly_repr(getattr(settings, name)) + for name in dir(settings) + if SETTING_NAME_REGEX.match(name) + } + print(json.dumps(settings_json, indent=4)) + + +def _to_json_friendly_repr(value: object) -> object: + """ + Turn the value into something that we can print to a JSON file (that is: str, bool, None, int, float, list, dict). + + See the docstring of `Command` for warnings about this function's behavior. + """ + if isinstance(value, (type(None), bool, int, float, str)): + # All these types can be printed directly + return value + if isinstance(value, (list, tuple, set)): + if isinstance(value, set): + # Print sets by sorting them (so that order doesn't matter) into a JSON array. + elements = sorted(value) + else: + # Print both lists and tuples as JSON arrays. + elements = value + return [_to_json_friendly_repr(element) for ix, element in enumerate(elements)] + if isinstance(value, dict): + # Print dicts as JSON objects + for subkey in value.keys(): + if not isinstance(subkey, (str, int)): + raise ValueError(f"Unexpected dict key {subkey} of type {type(subkey)}") + return {subkey: _to_json_friendly_repr(subval) for subkey, subval in value.items()} + if proxy_args := getattr(value, "_proxy____args", None): + if len(proxy_args) == 1 and isinstance(proxy_args[0], str): + # Print gettext_lazy as simply the wrapped string + return proxy_args[0] + try: + module = value.__module__ + qualname = value.__qualname__ + except AttributeError: + pass + else: + # Handle functions and classes by printing their location (plus approximate source, for lambdas) + return { + "module": module, + "qualname": qualname, + **({ + "source_hint": inspect.getsource(value).strip(), + } if qualname == "" else {}), + } + # For all other objects, print the repr + return repr(value) diff --git a/openedx/core/djangoapps/util/tests/test_dump_settings.py b/openedx/core/djangoapps/util/tests/test_dump_settings.py new file mode 100644 index 000000000000..90171eb48c95 --- /dev/null +++ b/openedx/core/djangoapps/util/tests/test_dump_settings.py @@ -0,0 +1,64 @@ +""" +Basic tests for dump_settings management command. + +These are moreso testing that dump_settings works, less-so testing anything about the Django +settings files themselves. Remember that tests only run with (lms,cms)/envs/test.py, +which are based on (lms,cms)/envs/common.py, so these tests will not execute any of the +YAML-loading or post-processing defined in (lms,cms)/envs/production.py. +""" +import json + +from django.core.management import call_command + +from openedx.core.djangolib.testing.utils import skip_unless_lms, skip_unless_cms + + +@skip_unless_lms +def test_for_lms_settings(capsys): + """ + Ensure LMS's test settings can be dumped, and sanity-check them for certain values. + """ + dump = _get_settings_dump(capsys) + + # Check: something LMS-specific + assert dump['MODULESTORE_BRANCH'] == "published-only" + + # Check: tuples are converted to lists + assert isinstance(dump['XBLOCK_MIXINS'], list) + + # Check: classes are converted to dicts of info on the class location + assert {"module": "xmodule.x_module", "qualname": "XModuleMixin"} in dump['XBLOCK_MIXINS'] + + # Check: nested dictionaries come through OK, and int'l strings are just strings + assert dump['COURSE_ENROLLMENT_MODES']['audit']['display_name'] == "Audit" + + +@skip_unless_cms +def test_for_cms_settings(capsys): + """ + Ensure CMS's test settings can be dumped, and sanity-check them for certain values. + """ + dump = _get_settings_dump(capsys) + + # Check: something CMS-specific + assert dump['MODULESTORE_BRANCH'] == "draft-preferred" + + # Check: tuples are converted to lists + assert isinstance(dump['XBLOCK_MIXINS'], list) + + # Check: classes are converted to dicts of info on the class location + assert {"module": "xmodule.x_module", "qualname": "XModuleMixin"} in dump['XBLOCK_MIXINS'] + + # Check: nested dictionaries come through OK, and int'l strings are just strings + assert dump['COURSE_ENROLLMENT_MODES']['audit']['display_name'] == "Audit" + + +def _get_settings_dump(captured_sys): + """ + Call dump_settings, ensure no error output, and return parsed JSON. + """ + call_command('dump_settings') + out, err = captured_sys.readouterr() + assert out + assert not err + return json.loads(out) diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py index a1fbd1e062a6..0fd202b4eff2 100644 --- a/openedx/core/djangoapps/xblock/rest_api/views.py +++ b/openedx/core/djangoapps/xblock/rest_api/views.py @@ -118,10 +118,12 @@ def embed_block_view(request, usage_key: UsageKeyV2, view_name: str): # for key in itertools.chain([block.scope_ids.usage_id], getattr(block, 'children', [])) # } lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL) + cms_root_url = configuration_helpers.get_value('CMS_ROOT_URL', settings.CMS_ROOT_URL) context = { 'fragment': fragment, 'handler_urls_json': json.dumps(handler_urls), 'lms_root_url': lms_root_url, + 'cms_root_url': cms_root_url, 'view_name': view_name, 'is_development': settings.DEBUG, } diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index 054755ae3cc1..d876e49ae579 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -265,8 +265,7 @@ def __len__(self): def __iter__(self): # Yield all the known data first - for item in self._data: - yield item + yield from self._data # Capture and yield data from the underlying iterator # until it is exhausted diff --git a/openedx/core/lib/celery/task_utils.py b/openedx/core/lib/celery/task_utils.py index 738f074be68c..9a54f1b3a550 100644 --- a/openedx/core/lib/celery/task_utils.py +++ b/openedx/core/lib/celery/task_utils.py @@ -50,9 +50,8 @@ def emulate_http_request(site=None, user=None, middleware_classes=None): for middleware in reversed(middleware_instances): _run_method_if_implemented(middleware, 'process_exception', request, exc) raise - else: - for middleware in reversed(middleware_instances): - _run_method_if_implemented(middleware, 'process_response', request, response) + for middleware in reversed(middleware_instances): + _run_method_if_implemented(middleware, 'process_response', request, response) def _run_method_if_implemented(instance, method_name, *args, **kwargs): diff --git a/openedx/core/lib/jwt.py b/openedx/core/lib/jwt.py new file mode 100644 index 000000000000..47642b869560 --- /dev/null +++ b/openedx/core/lib/jwt.py @@ -0,0 +1,91 @@ +""" +JWT Token handling and signing functions. +""" + +import json +from time import time + +from django.conf import settings +from jwkest import Expired, Invalid, MissingKey, jwk +from jwkest.jws import JWS + + +def create_jwt(lms_user_id, expires_in_seconds, additional_token_claims, now=None): + """ + Produce an encoded JWT (string) indicating some temporary permission for the indicated user. + + What permission that is must be encoded in additional_claims. + Arguments: + lms_user_id (int): LMS user ID this token is being generated for + expires_in_seconds (int): Time to token expiry, specified in seconds. + additional_token_claims (dict): Additional claims to include in the token. + now(int): optional now value for testing + """ + now = now or int(time()) + + payload = { + 'lms_user_id': lms_user_id, + 'exp': now + expires_in_seconds, + 'iat': now, + 'iss': settings.TOKEN_SIGNING['JWT_ISSUER'], + 'version': settings.TOKEN_SIGNING['JWT_SUPPORTED_VERSION'], + } + payload.update(additional_token_claims) + return _encode_and_sign(payload) + + +def _encode_and_sign(payload): + """ + Encode and sign the provided 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) + algorithm = settings.TOKEN_SIGNING['JWT_SIGNING_ALGORITHM'] + + data = json.dumps(payload) + jws = JWS(data, alg=algorithm) + return jws.sign_compact(keys=keys) + + +def unpack_jwt(token, lms_user_id, now=None): + """ + Unpack and verify an encoded JWT. + + Validate the user and expiration. + + Arguments: + token (string): The token to be unpacked and verified. + lms_user_id (int): LMS user ID this token should match with. + now (int): Optional now value for testing. + + Returns a valid, decoded json payload (string). + """ + now = now or int(time()) + payload = _unpack_and_verify(token) + + if "lms_user_id" not in payload: + raise MissingKey("LMS user id is missing") + if "exp" not in payload: + raise MissingKey("Expiration is missing") + if payload["lms_user_id"] != lms_user_id: + raise Invalid("User does not match") + if payload["exp"] < now: + raise Expired("Token is expired") + + return payload + + +def _unpack_and_verify(token): + """ + 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 diff --git a/openedx/core/lib/tests/test_jwt.py b/openedx/core/lib/tests/test_jwt.py new file mode 100644 index 000000000000..7a678dd3c09b --- /dev/null +++ b/openedx/core/lib/tests/test_jwt.py @@ -0,0 +1,129 @@ +""" +Tests for token handling +""" +import unittest + +from django.conf import settings +from jwkest import BadSignature, Expired, Invalid, MissingKey, jwk +from jwkest.jws import JWS + +from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.core.lib.jwt import _encode_and_sign, create_jwt, unpack_jwt + + +test_user_id = 121 +invalid_test_user_id = 120 +test_timeout = 60 +test_now = 1661432902 +test_claims = {"foo": "bar", "baz": "quux", "meaning": 42} +expected_full_token = { + "lms_user_id": test_user_id, + "iat": 1661432902, + "exp": 1661432902 + 60, + "iss": "token-test-issuer", # these lines from test_settings.py + "version": "1.2.0", # these lines from test_settings.py +} + + +@skip_unless_lms +class TestSign(unittest.TestCase): + """ + Tests for JWT creation and signing. + """ + + def test_create_jwt(self): + token = create_jwt(test_user_id, test_timeout, {}, test_now) + + decoded = _verify_jwt(token) + self.assertEqual(expected_full_token, decoded) + + def test_create_jwt_with_claims(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + + expected_token_with_claims = expected_full_token.copy() + expected_token_with_claims.update(test_claims) + + decoded = _verify_jwt(token) + self.assertEqual(expected_token_with_claims, decoded) + + def test_malformed_token(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + token = token + "a" + + 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 + + +@skip_unless_lms +class TestUnpack(unittest.TestCase): + """ + Tests for JWT unpacking. + """ + + def test_unpack_jwt(self): + token = create_jwt(test_user_id, test_timeout, {}, test_now) + decoded = unpack_jwt(token, test_user_id, test_now) + + self.assertEqual(expected_full_token, decoded) + + def test_unpack_jwt_with_claims(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + + expected_token_with_claims = expected_full_token.copy() + expected_token_with_claims.update(test_claims) + + decoded = unpack_jwt(token, test_user_id, test_now) + + self.assertEqual(expected_token_with_claims, decoded) + + def test_malformed_token(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + token = token + "a" + + expected_token_with_claims = expected_full_token.copy() + expected_token_with_claims.update(test_claims) + + with self.assertRaises(BadSignature): + 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): + 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): + unpack_jwt(token, test_user_id, test_now + test_timeout + 1) + + def test_missing_expired_lms_user_id(self): + payload = expected_full_token.copy() + del payload['lms_user_id'] + token = _encode_and_sign(payload) + + with self.assertRaises(MissingKey): + unpack_jwt(token, test_user_id, test_now) + + def test_missing_expired_key(self): + payload = expected_full_token.copy() + del payload['exp'] + token = _encode_and_sign(payload) + + with self.assertRaises(MissingKey): + unpack_jwt(token, test_user_id, test_now) diff --git a/pylint_django_settings.py b/pylint_django_settings.py index 46abfd81f883..6051d9ab4b56 100644 --- a/pylint_django_settings.py +++ b/pylint_django_settings.py @@ -1,5 +1,5 @@ -from pylint_django.checkers import ForeignKeyStringsChecker -from pylint_plugin_utils import get_checker +import os +import sys class ArgumentCompatibilityError(Exception): @@ -47,6 +47,4 @@ def load_configuration(linter): """ Configures the Django settings module based on the command-line arguments passed to pylint. """ - name_checker = get_checker(linter, ForeignKeyStringsChecker) - arguments = linter.cmdline_parser.parse_args()[1] - name_checker.config.django_settings_module = _get_django_settings_module(arguments) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", _get_django_settings_module(sys.argv[1:])) diff --git a/pylintrc b/pylintrc index 55a9bbab3b9c..e06941853774 100644 --- a/pylintrc +++ b/pylintrc @@ -64,7 +64,7 @@ # SERIOUSLY. # # ------------------------------ -# Generated by edx-lint version: 5.3.7 +# Generated by edx-lint version: 5.4.1 # ------------------------------ [MASTER] ignore = ,.git,.tox,migrations,node_modules,.pycharm_helpers @@ -72,10 +72,10 @@ persistent = yes load-plugins = edx_lint.pylint,pylint_django_settings,pylint_django,pylint_celery,pylint_pytest [MESSAGES CONTROL] -enable = +enable = blacklisted-name, line-too-long, - + abstract-class-instantiated, abstract-method, access-member-before-definition, @@ -184,26 +184,26 @@ enable = used-before-assignment, using-constant-test, yield-outside-function, - + astroid-error, fatal, method-check-failed, parse-error, raw-checker-failed, - + empty-docstring, invalid-characters-in-docstring, missing-docstring, wrong-spelling-in-comment, wrong-spelling-in-docstring, - + unused-argument, unused-import, unused-variable, - + eval-used, exec-used, - + bad-classmethod-argument, bad-mcs-classmethod-argument, bad-mcs-method-argument, @@ -234,30 +234,30 @@ enable = unneeded-not, useless-else-on-loop, wrong-assert-type, - + deprecated-method, deprecated-module, - + too-many-boolean-expressions, too-many-nested-blocks, too-many-statements, - + wildcard-import, wrong-import-order, wrong-import-position, - + missing-final-newline, mixed-line-endings, trailing-newlines, trailing-whitespace, unexpected-line-ending-format, - + bad-inline-option, bad-option-value, deprecated-pragma, unrecognized-inline-option, useless-suppression, -disable = +disable = bad-indentation, broad-exception-raised, consider-using-f-string, @@ -282,10 +282,10 @@ disable = unspecified-encoding, unused-wildcard-import, use-maxsplit-arg, - + feature-toggle-needs-doc, illegal-waffle-usage, - + logging-fstring-interpolation, import-outside-toplevel, inconsistent-return-statements, @@ -314,6 +314,13 @@ disable = c-extension-no-member, no-name-in-module, unnecessary-lambda-assignment, + too-many-positional-arguments, + possibly-used-before-assignment, + use-dict-literal, + useless-return, + superfluous-parens, + logging-not-lazy, + broad-exception-caught, [REPORTS] output-format = text @@ -356,7 +363,7 @@ ignore-imports = no ignore-mixin-members = yes ignored-classes = SQLObject unsafe-load-any-extension = yes -generated-members = +generated-members = REQUEST, acl_users, aq_parent, @@ -382,7 +389,7 @@ generated-members = [VARIABLES] init-import = no dummy-variables-rgx = _|dummy|unused|.*_unused -additional-builtins = +additional-builtins = [CLASSES] defining-attr-methods = __init__,__new__,setUp @@ -403,11 +410,11 @@ max-public-methods = 20 [IMPORTS] deprecated-modules = regsub,TERMIOS,Bastion,rexec -import-graph = -ext-import-graph = -int-import-graph = +import-graph = +ext-import-graph = +int-import-graph = [EXCEPTIONS] overgeneral-exceptions = builtins.Exception -# e624ea03d8124aa9cf2e577f830632344a0a07d9 +# 85c3d025c367597a0a7b23a05fdde9d8c63e1374 diff --git a/pylintrc_tweaks b/pylintrc_tweaks index 1633da5c10a4..fb0bebfd2a12 100644 --- a/pylintrc_tweaks +++ b/pylintrc_tweaks @@ -33,6 +33,13 @@ disable+ = c-extension-no-member, no-name-in-module, unnecessary-lambda-assignment, + too-many-positional-arguments, + possibly-used-before-assignment, + use-dict-literal, + useless-return, + superfluous-parens, + logging-not-lazy, + broad-exception-caught, [BASIC] attr-rgx = [a-z_][a-z0-9_]{2,40}$ diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 283ab625f42b..94d960359a91 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -80,7 +80,7 @@ django-storages<1.14.4 # 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==5.6.4 +edx-enterprise==5.6.6 # Date: 2024-05-09 # This has to be constrained as well because newer versions of edx-i18n-tools need the @@ -131,7 +131,7 @@ optimizely-sdk<5.0 # Date: 2023-09-18 # pinning this version to avoid updates while the library is being developed # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269 -openedx-learning==0.18.1 +openedx-learning==0.18.2 # Date: 2023-11-29 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. @@ -149,11 +149,6 @@ path<16.12.0 # Constraint can be removed once the issue https://github.com/PyCQA/pycodestyle/issues/1090 is fixed. pycodestyle<2.9.0 -# Date: 2021-07-12 -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/33560 -pylint<2.16.0 # greater version failing quality test. Fix them in seperate ticket. -astroid<2.14.0 - # Date: 2021-08-25 # At the time of writing this comment, we do not know whether py2neo>=2022 # will support our currently-deployed Neo4j version (3.5). diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt index da781eb84587..821664fe9fc3 100644 --- a/requirements/edx-sandbox/base.txt +++ b/requirements/edx-sandbox/base.txt @@ -20,7 +20,7 @@ cryptography==44.0.0 # via -r requirements/edx-sandbox/base.in cycler==0.12.1 # via matplotlib -fonttools==4.55.4 +fonttools==4.55.6 # via matplotlib joblib==1.4.2 # via nltk diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 4861b7be1f63..66428919cc61 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -37,7 +37,7 @@ asgiref==3.8.1 # django-countries asn1crypto==1.5.1 # via snowflake-connector-python -attrs==24.3.0 +attrs==25.1.0 # via # -r requirements/edx/kernel.in # aiohttp @@ -72,13 +72,13 @@ bleach[css]==6.2.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/kernel.in -boto3==1.36.3 +boto3==1.36.6 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.36.3 +botocore==1.36.6 # via # -r requirements/edx/kernel.in # boto3 @@ -87,7 +87,7 @@ bridgekeeper==0.9 # via -r requirements/edx/kernel.in cachecontrol==0.14.2 # via firebase-admin -cachetools==5.5.0 +cachetools==5.5.1 # via google-auth camel-converter[pydantic]==4.0.1 # via meilisearch @@ -221,7 +221,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -468,7 +467,7 @@ edx-drf-extensions==10.5.0 # edx-when # edxval # openedx-learning -edx-enterprise==5.6.4 +edx-enterprise==5.6.6 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -514,7 +513,7 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring -edx-search==4.1.1 +edx-search==4.1.2 # via # -r requirements/edx/kernel.in # openedx-forum @@ -538,8 +537,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via -r requirements/edx/kernel.in edx-when==2.5.1 # via # -r requirements/edx/kernel.in @@ -598,7 +595,7 @@ google-api-core[grpc]==2.24.0 # google-cloud-storage google-api-python-client==2.159.0 # via firebase-admin -google-auth==2.37.0 +google-auth==2.38.0 # via # google-api-core # google-api-python-client @@ -626,11 +623,11 @@ googleapis-common-protos==1.66.0 # via # google-api-core # grpcio-status -grpcio==1.69.0 +grpcio==1.70.0 # via # google-api-core # grpcio-status -grpcio-status==1.69.0 +grpcio-status==1.70.0 # via google-api-core gunicorn==23.0.0 # via -r requirements/edx/kernel.in @@ -705,7 +702,7 @@ lazy==1.6 # xblock loremipsum==1.0.5 # via ora2 -lti-consumer-xblock==9.13.1 +lti-consumer-xblock==9.13.2 # via -r requirements/edx/kernel.in lxml[html-clean,html_clean]==5.3.0 # via @@ -830,7 +827,7 @@ openedx-filters==1.12.0 # ora2 openedx-forum==0.1.6 # via -r requirements/edx/kernel.in -openedx-learning==0.18.1 +openedx-learning==0.18.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -922,7 +919,7 @@ pycryptodomex==3.21.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.10.5 +pydantic==2.10.6 # via camel-converter pydantic-core==2.27.2 # via pydantic @@ -931,7 +928,6 @@ pygments==2.19.1 pyjwkest==1.4.2 # via # -r requirements/edx/kernel.in - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via @@ -1044,7 +1040,7 @@ redis==5.2.1 # via # -r requirements/edx/kernel.in # walrus -referencing==0.36.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications @@ -1093,7 +1089,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.11.1 +s3transfer==0.11.2 # via boto3 sailthru-client==2.2.3 # via edx-ace @@ -1139,7 +1135,7 @@ slumber==0.7.1 # -r requirements/edx/kernel.in # edx-bulk-grades # edx-enterprise -snowflake-connector-python==3.12.4 +snowflake-connector-python==3.13.0 # via edx-enterprise social-auth-app-django==5.4.1 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index ae377bd5cc7c..faab27d2a6ba 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -82,15 +82,14 @@ asn1crypto==1.5.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # snowflake-connector-python -astroid==2.13.5 +astroid==3.3.8 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pylint # pylint-celery # sphinx-autoapi -attrs==24.3.0 +attrs==25.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -145,14 +144,14 @@ boto==2.49.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -boto3==1.36.3 +boto3==1.36.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-ses # fs-s3fs # ora2 -botocore==1.36.3 +botocore==1.36.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -171,7 +170,7 @@ cachecontrol==0.14.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -cachetools==5.5.0 +cachetools==5.5.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -395,7 +394,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -747,7 +745,7 @@ edx-drf-extensions==10.5.0 # edx-when # edxval # openedx-learning -edx-enterprise==5.6.4 +edx-enterprise==5.6.6 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -767,7 +765,7 @@ edx-i18n-tools==1.5.0 # -r requirements/edx/testing.txt # ora2 # xblocks-contrib -edx-lint==5.4.1 +edx-lint==5.6.0 # via -r requirements/edx/testing.txt edx-milestones==0.6.0 # via @@ -813,7 +811,7 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/testing.txt # edx-enterprise # edx-proctoring -edx-search==4.1.1 +edx-search==4.1.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -845,10 +843,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt edx-when==2.5.1 # via # -r requirements/edx/doc.txt @@ -889,11 +883,11 @@ execnet==2.1.1 # pytest-xdist factory-boy==3.3.1 # via -r requirements/edx/testing.txt -faker==33.3.1 +faker==35.0.0 # via # -r requirements/edx/testing.txt # factory-boy -fastapi==0.115.6 +fastapi==0.115.7 # via # -r requirements/edx/testing.txt # pact-python @@ -967,7 +961,7 @@ google-api-python-client==2.159.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -google-auth==2.37.0 +google-auth==2.38.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1019,13 +1013,13 @@ grimp==3.5 # via # -r requirements/edx/testing.txt # import-linter -grpcio==1.69.0 +grpcio==1.70.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-api-core # grpcio-status -grpcio-status==1.69.0 +grpcio-status==1.70.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1175,11 +1169,6 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lazy-object-proxy==1.10.0 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt - # astroid libsass==0.10.0 # via # -c requirements/edx/../constraints.txt @@ -1189,7 +1178,7 @@ loremipsum==1.0.5 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -lti-consumer-xblock==9.13.1 +lti-consumer-xblock==9.13.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1392,7 +1381,7 @@ openedx-forum==0.1.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-learning==0.18.1 +openedx-learning==0.18.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -1424,7 +1413,7 @@ packaging==24.2 # snowflake-connector-python # sphinx # tox -pact-python==2.3.0 +pact-python==2.3.1 # via -r requirements/edx/testing.txt pansi==2024.11.0 # via @@ -1569,7 +1558,7 @@ pycryptodomex==3.21.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.10.5 +pydantic==2.10.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1598,7 +1587,6 @@ pyjwkest==1.4.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via @@ -1619,9 +1607,8 @@ pylatexenc==2.10 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # olxcleaner -pylint==2.15.10 +pylint==3.3.3 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/testing.txt # edx-lint # pylint-celery @@ -1632,7 +1619,7 @@ pylint-celery==0.3 # via # -r requirements/edx/testing.txt # edx-lint -pylint-django==2.5.5 +pylint-django==2.6.1 # via # -r requirements/edx/testing.txt # edx-lint @@ -1641,7 +1628,7 @@ pylint-plugin-utils==0.8.2 # -r requirements/edx/testing.txt # pylint-celery # pylint-django -pylint-pytest==0.3.0 +pylint-pytest==1.1.8 # via -r requirements/edx/testing.txt pylti1p3==2.0.0 # via @@ -1705,7 +1692,7 @@ pysrt==1.1.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edxval -pytest==8.3.4 +pytest==8.2.0 # via # -r requirements/edx/testing.txt # pylint-pytest @@ -1724,7 +1711,7 @@ pytest-django==4.9.0 # via -r requirements/edx/testing.txt pytest-json-report==1.5.0 # via -r requirements/edx/testing.txt -pytest-metadata==1.8.0 +pytest-metadata==3.1.1 # via # -r requirements/edx/testing.txt # pytest-json-report @@ -1822,7 +1809,7 @@ redis==5.2.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # walrus -referencing==0.36.1 +referencing==0.36.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1888,7 +1875,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.11.1 +s3transfer==0.11.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1969,7 +1956,7 @@ snowballstemmer==2.2.0 # via # -r requirements/edx/doc.txt # sphinx -snowflake-connector-python==3.12.4 +snowflake-connector-python==3.13.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2067,7 +2054,7 @@ staff-graded-xblock==3.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -starlette==0.41.3 +starlette==0.45.3 # via # -r requirements/edx/testing.txt # fastapi @@ -2252,7 +2239,6 @@ wrapt==1.17.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # astroid xblock[django]==5.1.1 # via # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 354e85c98f55..0f927c5218f8 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -57,11 +57,9 @@ asn1crypto==1.5.1 # via # -r requirements/edx/base.txt # snowflake-connector-python -astroid==2.13.5 - # via - # -c requirements/edx/../constraints.txt - # sphinx-autoapi -attrs==24.3.0 +astroid==3.3.8 + # via sphinx-autoapi +attrs==25.1.0 # via # -r requirements/edx/base.txt # aiohttp @@ -107,13 +105,13 @@ bleach[css]==6.2.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.36.3 +boto3==1.36.6 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.36.3 +botocore==1.36.6 # via # -r requirements/edx/base.txt # boto3 @@ -124,7 +122,7 @@ cachecontrol==0.14.2 # via # -r requirements/edx/base.txt # firebase-admin -cachetools==5.5.0 +cachetools==5.5.1 # via # -r requirements/edx/base.txt # google-auth @@ -280,7 +278,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -555,7 +552,7 @@ edx-drf-extensions==10.5.0 # edx-when # edxval # openedx-learning -edx-enterprise==5.6.4 +edx-enterprise==5.6.6 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -603,7 +600,7 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring -edx-search==4.1.1 +edx-search==4.1.2 # via # -r requirements/edx/base.txt # openedx-forum @@ -629,8 +626,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via -r requirements/edx/base.txt edx-when==2.5.1 # via # -r requirements/edx/base.txt @@ -708,7 +703,7 @@ google-api-python-client==2.159.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.37.0 +google-auth==2.38.0 # via # -r requirements/edx/base.txt # google-api-core @@ -748,12 +743,12 @@ googleapis-common-protos==1.66.0 # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio==1.69.0 +grpcio==1.70.0 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.69.0 +grpcio-status==1.70.0 # via # -r requirements/edx/base.txt # google-api-core @@ -854,13 +849,11 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lazy-object-proxy==1.10.0 - # via astroid loremipsum==1.0.5 # via # -r requirements/edx/base.txt # ora2 -lti-consumer-xblock==9.13.1 +lti-consumer-xblock==9.13.2 # via -r requirements/edx/base.txt lxml[html-clean]==5.3.0 # via @@ -1007,7 +1000,7 @@ openedx-filters==1.12.0 # ora2 openedx-forum==0.1.6 # via -r requirements/edx/base.txt -openedx-learning==0.18.1 +openedx-learning==0.18.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1126,7 +1119,7 @@ pycryptodomex==3.21.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.10.5 +pydantic==2.10.6 # via # -r requirements/edx/base.txt # camel-converter @@ -1147,7 +1140,6 @@ pygments==2.19.1 pyjwkest==1.4.2 # via # -r requirements/edx/base.txt - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via @@ -1275,7 +1267,7 @@ redis==5.2.1 # via # -r requirements/edx/base.txt # walrus -referencing==0.36.1 +referencing==0.36.2 # via # -r requirements/edx/base.txt # jsonschema @@ -1332,7 +1324,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.11.1 +s3transfer==0.11.2 # via # -r requirements/edx/base.txt # boto3 @@ -1390,7 +1382,7 @@ smmap==5.0.2 # via gitdb snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.12.4 +snowflake-connector-python==3.13.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1580,9 +1572,7 @@ wheel==0.45.1 # -r requirements/edx/base.txt # django-pipeline wrapt==1.17.2 - # via - # -r requirements/edx/base.txt - # astroid + # via -r requirements/edx/base.txt xblock[django]==5.1.1 # via # -r requirements/edx/base.txt diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index d1a132778133..a17b9db4c868 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -84,7 +84,6 @@ edx-rest-api-client edx-search edx-submissions edx-toggles # Feature toggles management -edx-token-utils # Validate exam access tokens edx-when edxval event-tracking diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt index 65a0b794b9e6..9d25000148f3 100644 --- a/requirements/edx/semgrep.txt +++ b/requirements/edx/semgrep.txt @@ -4,7 +4,7 @@ # # make upgrade # -attrs==24.3.0 +attrs==25.1.0 # via # glom # jsonschema @@ -34,7 +34,7 @@ colorama==0.4.6 # via semgrep defusedxml==0.7.1 # via semgrep -deprecated==1.2.15 +deprecated==1.2.18 # via # opentelemetry-api # opentelemetry-exporter-otlp-proto-http @@ -92,13 +92,13 @@ packaging==24.2 # via semgrep peewee==3.17.8 # via semgrep -protobuf==4.25.5 +protobuf==4.25.6 # via # googleapis-common-protos # opentelemetry-proto pygments==2.19.1 # via rich -referencing==0.36.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications @@ -116,7 +116,7 @@ ruamel-yaml==0.18.10 # via semgrep ruamel-yaml-clib==0.2.12 # via ruamel-yaml -semgrep==1.103.0 +semgrep==1.104.0 # via -r requirements/edx/semgrep.in tomli==2.0.2 # via semgrep diff --git a/requirements/edx/testing.in b/requirements/edx/testing.in index cf57aeb0fc46..14a0c781da82 100644 --- a/requirements/edx/testing.in +++ b/requirements/edx/testing.in @@ -37,13 +37,13 @@ pytest-attrib # Select tests based on attributes pytest-cov # pytest plugin for measuring code coverage pytest-django # Django support for pytest pytest-json-report # Output json formatted warnings after running pytest -pytest-metadata==1.8.0 # To prevent 'make upgrade' failure, dependency of pytest-json-report +pytest-metadata # To prevent 'make upgrade' failure, dependency of pytest-json-report pytest-randomly # pytest plugin to randomly order tests pytest-xdist[psutil] # Parallel execution of tests on multiple CPU cores or hosts singledispatch # Backport of functools.singledispatch from Python 3.4+, used in tests of XBlock rendering testfixtures # Provides a LogCapture utility used by several tests tox # virtualenv management for tests unidiff # Required by coverage_pytest_plugin -pylint-pytest==0.3.0 # A Pylint plugin to suppress pytest-related false positives. +pylint-pytest # A Pylint plugin to suppress pytest-related false positives. pact-python # Library for contract testing py # Needed for pytest configurations, was previously been fetched through tox diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 799f8b16ed26..5c991ae3e7d6 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -55,12 +55,11 @@ asn1crypto==1.5.1 # via # -r requirements/edx/base.txt # snowflake-connector-python -astroid==2.13.5 +astroid==3.3.8 # via - # -c requirements/edx/../constraints.txt # pylint # pylint-celery -attrs==24.3.0 +attrs==25.1.0 # via # -r requirements/edx/base.txt # aiohttp @@ -104,13 +103,13 @@ bleach[css]==6.2.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.36.3 +boto3==1.36.6 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.36.3 +botocore==1.36.6 # via # -r requirements/edx/base.txt # boto3 @@ -121,7 +120,7 @@ cachecontrol==0.14.2 # via # -r requirements/edx/base.txt # firebase-admin -cachetools==5.5.0 +cachetools==5.5.1 # via # -r requirements/edx/base.txt # google-auth @@ -306,7 +305,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -576,7 +574,7 @@ edx-drf-extensions==10.5.0 # edx-when # edxval # openedx-learning -edx-enterprise==5.6.4 +edx-enterprise==5.6.6 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -590,7 +588,7 @@ edx-i18n-tools==1.5.0 # -r requirements/edx/base.txt # ora2 # xblocks-contrib -edx-lint==5.4.1 +edx-lint==5.6.0 # via -r requirements/edx/testing.in edx-milestones==0.6.0 # via -r requirements/edx/base.txt @@ -626,7 +624,7 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring -edx-search==4.1.1 +edx-search==4.1.2 # via # -r requirements/edx/base.txt # openedx-forum @@ -652,8 +650,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via -r requirements/edx/base.txt edx-when==2.5.1 # via # -r requirements/edx/base.txt @@ -684,9 +680,9 @@ execnet==2.1.1 # via pytest-xdist factory-boy==3.3.1 # via -r requirements/edx/testing.in -faker==33.3.1 +faker==35.0.0 # via factory-boy -fastapi==0.115.6 +fastapi==0.115.7 # via pact-python fastavro==1.10.0 # via @@ -739,7 +735,7 @@ google-api-python-client==2.159.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.37.0 +google-auth==2.38.0 # via # -r requirements/edx/base.txt # google-api-core @@ -781,12 +777,12 @@ googleapis-common-protos==1.66.0 # grpcio-status grimp==3.5 # via import-linter -grpcio==1.69.0 +grpcio==1.70.0 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.69.0 +grpcio-status==1.70.0 # via # -r requirements/edx/base.txt # google-api-core @@ -897,13 +893,11 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lazy-object-proxy==1.10.0 - # via astroid loremipsum==1.0.5 # via # -r requirements/edx/base.txt # ora2 -lti-consumer-xblock==9.13.1 +lti-consumer-xblock==9.13.2 # via -r requirements/edx/base.txt lxml[html-clean]==5.3.0 # via @@ -1054,7 +1048,7 @@ openedx-filters==1.12.0 # ora2 openedx-forum==0.1.6 # via -r requirements/edx/base.txt -openedx-learning==0.18.1 +openedx-learning==0.18.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1076,7 +1070,7 @@ packaging==24.2 # pytest # snowflake-connector-python # tox -pact-python==2.3.0 +pact-python==2.3.1 # via -r requirements/edx/testing.in pansi==2024.11.0 # via @@ -1193,7 +1187,7 @@ pycryptodomex==3.21.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.10.5 +pydantic==2.10.6 # via # -r requirements/edx/base.txt # camel-converter @@ -1211,7 +1205,6 @@ pygments==2.19.1 pyjwkest==1.4.2 # via # -r requirements/edx/base.txt - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via @@ -1230,9 +1223,8 @@ pylatexenc==2.10 # via # -r requirements/edx/base.txt # olxcleaner -pylint==2.15.10 +pylint==3.3.3 # via - # -c requirements/edx/../constraints.txt # edx-lint # pylint-celery # pylint-django @@ -1240,13 +1232,13 @@ pylint==2.15.10 # pylint-pytest pylint-celery==0.3 # via edx-lint -pylint-django==2.5.5 +pylint-django==2.6.1 # via edx-lint pylint-plugin-utils==0.8.2 # via # pylint-celery # pylint-django -pylint-pytest==0.3.0 +pylint-pytest==1.1.8 # via -r requirements/edx/testing.in pylti1p3==2.0.0 # via -r requirements/edx/base.txt @@ -1291,7 +1283,7 @@ pysrt==1.1.2 # via # -r requirements/edx/base.txt # edxval -pytest==8.3.4 +pytest==8.2.0 # via # -r requirements/edx/testing.in # pylint-pytest @@ -1310,7 +1302,7 @@ pytest-django==4.9.0 # via -r requirements/edx/testing.in pytest-json-report==1.5.0 # via -r requirements/edx/testing.in -pytest-metadata==1.8.0 +pytest-metadata==3.1.1 # via # -r requirements/edx/testing.in # pytest-json-report @@ -1388,7 +1380,7 @@ redis==5.2.1 # via # -r requirements/edx/base.txt # walrus -referencing==0.36.1 +referencing==0.36.2 # via # -r requirements/edx/base.txt # jsonschema @@ -1445,7 +1437,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.11.1 +s3transfer==0.11.2 # via # -r requirements/edx/base.txt # boto3 @@ -1504,7 +1496,7 @@ slumber==0.7.1 # edx-enterprise sniffio==1.3.1 # via anyio -snowflake-connector-python==3.12.4 +snowflake-connector-python==3.13.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1536,7 +1528,7 @@ sqlparse==0.5.3 # django staff-graded-xblock==3.0.0 # via -r requirements/edx/base.txt -starlette==0.41.3 +starlette==0.45.3 # via fastapi stevedore==5.4.0 # via @@ -1670,9 +1662,7 @@ wheel==0.45.1 # -r requirements/edx/base.txt # django-pipeline wrapt==1.17.2 - # via - # -r requirements/edx/base.txt - # astroid + # via -r requirements/edx/base.txt xblock[django]==5.1.1 # via # -r requirements/edx/base.txt diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt index 5629aee43eea..35cf225d6b1b 100644 --- a/scripts/user_retirement/requirements/base.txt +++ b/scripts/user_retirement/requirements/base.txt @@ -6,17 +6,17 @@ # asgiref==3.8.1 # via django -attrs==24.3.0 +attrs==25.1.0 # via zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.in -boto3==1.36.3 +boto3==1.36.6 # via -r scripts/user_retirement/requirements/base.in -botocore==1.36.3 +botocore==1.36.6 # via # boto3 # s3transfer -cachetools==5.5.0 +cachetools==5.5.1 # via google-auth certifi==2024.12.14 # via requests @@ -54,7 +54,7 @@ google-api-core==2.24.0 # via google-api-python-client google-api-python-client==2.159.0 # via -r scripts/user_retirement/requirements/base.in -google-auth==2.37.0 +google-auth==2.38.0 # via # google-api-core # google-api-python-client @@ -136,7 +136,7 @@ requests-toolbelt==1.0.0 # via zeep rsa==4.9 # via google-auth -s3transfer==0.11.1 +s3transfer==0.11.2 # via boto3 simple-salesforce==1.12.6 # via -r scripts/user_retirement/requirements/base.in diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt index ad69c332b702..4f13157aee07 100644 --- a/scripts/user_retirement/requirements/testing.txt +++ b/scripts/user_retirement/requirements/testing.txt @@ -8,23 +8,23 @@ asgiref==3.8.1 # via # -r scripts/user_retirement/requirements/base.txt # django -attrs==24.3.0 +attrs==25.1.0 # via # -r scripts/user_retirement/requirements/base.txt # zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.txt -boto3==1.36.3 +boto3==1.36.6 # via # -r scripts/user_retirement/requirements/base.txt # moto -botocore==1.36.3 +botocore==1.36.6 # via # -r scripts/user_retirement/requirements/base.txt # boto3 # moto # s3transfer -cachetools==5.5.0 +cachetools==5.5.1 # via # -r scripts/user_retirement/requirements/base.txt # google-auth @@ -78,7 +78,7 @@ google-api-core==2.24.0 # google-api-python-client google-api-python-client==2.159.0 # via -r scripts/user_retirement/requirements/base.txt -google-auth==2.37.0 +google-auth==2.38.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core @@ -235,7 +235,7 @@ rsa==4.9 # via # -r scripts/user_retirement/requirements/base.txt # google-auth -s3transfer==0.11.1 +s3transfer==0.11.2 # via # -r scripts/user_retirement/requirements/base.txt # boto3 diff --git a/xmodule/capa/tests/test_input_templates.py b/xmodule/capa/tests/test_input_templates.py index 4b14bd5ef86c..3048f62095de 100644 --- a/xmodule/capa/tests/test_input_templates.py +++ b/xmodule/capa/tests/test_input_templates.py @@ -76,8 +76,7 @@ def render_to_xml(self, context_dict): except Exception as exc: raise TemplateError("Could not parse XML from '{0}': {1}".format( # lint-amnesty, pylint: disable=raise-missing-from xml_str, str(exc))) - else: - return xml + return xml def assert_has_xpath(self, xml_root, xpath, context_dict, exact_num=1): """ diff --git a/xmodule/split_test_block.py b/xmodule/split_test_block.py index 05ca3a5db454..52f40547879a 100644 --- a/xmodule/split_test_block.py +++ b/xmodule/split_test_block.py @@ -419,9 +419,8 @@ def log_child_render(self, request, suffix=''): # lint-amnesty, pylint: disable ) ) raise - else: - self.runtime.publish(self, 'xblock.split_test.child_render', {'child_id': child_id}) - return Response() + self.runtime.publish(self, 'xblock.split_test.child_render', {'child_id': child_id}) + return Response() def get_icon_class(self): return self.child.get_icon_class() if self.child else 'other'